• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

CPS-IT / mailqueue / 21161535642

20 Jan 2026 06:17AM UTC coverage: 16.49% (-0.2%) from 16.692%
21161535642

Pull #204

github

web-flow
Merge fd09aa4e1 into e438d54b6
Pull Request #204: [SECURITY] Harden message deserialization in `QueueableFileTransport`

0 of 20 new or added lines in 2 files covered. (0.0%)

1 existing line in 1 file now uncovered.

109 of 661 relevant lines covered (16.49%)

0.33 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/Classes/Mail/Transport/QueueableFileTransport.php
1
<?php
2

3
declare(strict_types=1);
4

5
/*
6
 * This file is part of the TYPO3 CMS extension "mailqueue".
7
 *
8
 * It is free software; you can redistribute it and/or modify it under
9
 * the terms of the GNU General Public License, either version 2
10
 * of the License, or any later version.
11
 *
12
 * For the full copyright and license information, please read the
13
 * LICENSE.txt file that was distributed with this source code.
14
 *
15
 * The TYPO3 project - inspiring people to share!
16
 */
17

18
namespace CPSIT\Typo3Mailqueue\Mail\Transport;
19

20
use CPSIT\Typo3Mailqueue\Enums;
21
use CPSIT\Typo3Mailqueue\Exception;
22
use CPSIT\Typo3Mailqueue\Iterator;
23
use CPSIT\Typo3Mailqueue\Mail;
24
use Psr\Log;
25
use Symfony\Component\Mailer;
26
use Symfony\Component\Mime;
27
use Symfony\Contracts\EventDispatcher;
28
use TYPO3\CMS\Core;
29

30
/**
31
 * QueueableFileTransport
32
 *
33
 * @author Elias Häußler <e.haeussler@familie-redlich.de>
34
 * @license GPL-2.0-or-later
35
 */
36
final class QueueableFileTransport extends Core\Mail\FileSpool implements RecoverableTransport
37
{
38
    private const FILE_SUFFIX_QUEUED = '.message';
39
    private const FILE_SUFFIX_SENDING = '.message.sending';
40
    private const FILE_SUFFIX_FAILURE_DATA = '.message.failure';
41

42
    private readonly Core\Context\Context $context;
43

44
    public function __construct(
×
45
        string $path,
46
        ?EventDispatcher\EventDispatcherInterface $dispatcher = null,
47
        ?Log\LoggerInterface $logger = null,
48
        Core\Serializer\PolymorphicDeserializer $deserializer = new Core\Serializer\PolymorphicDeserializer(),
49
    ) {
NEW
50
        parent::__construct($path, $dispatcher, $logger, $deserializer);
×
51
        $this->context = Core\Utility\GeneralUtility::makeInstance(Core\Context\Context::class);
×
52
    }
53

54
    /**
55
     * @throws Exception\SerializedMessageIsInvalid
56
     * @throws Mailer\Exception\TransportExceptionInterface
57
     */
58
    public function flushQueue(Mailer\Transport\TransportInterface $transport): int
×
59
    {
60
        $directoryIterator = new \DirectoryIterator($this->path);
×
61
        /** @var positive-int $execTime */
62
        $execTime = $this->context->getPropertyFromAspect('date', 'timestamp');
×
63
        $time = time();
×
64
        $count = 0;
×
65

66
        foreach ($directoryIterator as $file) {
×
67
            $path = (string)$file->getRealPath();
×
68

69
            if (!str_ends_with($path, self::FILE_SUFFIX_QUEUED)) {
×
70
                continue;
×
71
            }
72

73
            $item = $this->restoreItem($file);
×
74

75
            if ($this->dequeue($item, $transport)) {
×
76
                $count++;
×
77
            } else {
78
                // This message has just been caught by another process
79
                continue;
×
80
            }
81

82
            if ($this->getMessageLimit() && $count >= $this->getMessageLimit()) {
×
83
                break;
×
84
            }
85

86
            if ($this->getTimeLimit() && ($execTime - $time) >= $this->getTimeLimit()) {
×
87
                break;
×
88
            }
89
        }
90

91
        return $count;
×
92
    }
93

94
    public function enqueue(Mime\RawMessage $message, ?Mailer\Envelope $envelope = null): ?Mail\Queue\MailQueueItem
×
95
    {
96
        $sentMessage = $this->send($message, $envelope);
×
97

98
        // Early return if message was rejected
99
        if ($sentMessage === null) {
×
100
            return null;
×
101
        }
102

103
        // Look up mail in queue
104
        foreach ($this->getMailQueue() as $mailQueueItem) {
×
105
            // Loose comparison is intended
106
            if ($mailQueueItem->message == $sentMessage) {
×
107
                return $mailQueueItem;
×
108
            }
109
        }
110

111
        return null;
×
112
    }
113

114
    public function dequeue(Mail\Queue\MailQueueItem $item, Mailer\Transport\TransportInterface $transport): bool
×
115
    {
116
        $path = $this->path . DIRECTORY_SEPARATOR . $item->id;
×
117
        $sendingPath = $this->getFileVariant($path, self::FILE_SUFFIX_SENDING);
×
118
        $failurePath = $this->getFileVariant($path, self::FILE_SUFFIX_FAILURE_DATA);
×
119

120
        // We try a rename, it's an atomic operation, and avoid locking the file
121
        if ($path !== $sendingPath && !rename($path, $sendingPath)) {
×
122
            return false;
×
123
        }
124

125
        try {
126
            $transport->send($item->message->getMessage(), $item->message->getEnvelope());
×
127
        } catch (Mailer\Exception\TransportExceptionInterface $exception) {
×
128
            // Store failure metadata
129
            file_put_contents($failurePath, serialize(Mail\TransportFailure::fromException($exception)));
×
130

131
            throw $exception;
×
132
        }
133

134
        // Remove message from queue
135
        unlink($sendingPath);
×
136

137
        // Remove failure metadata
138
        if (file_exists($failurePath)) {
×
139
            unlink($failurePath);
×
140
        }
141

142
        return true;
×
143
    }
144

145
    public function delete(Mail\Queue\MailQueueItem $item): bool
×
146
    {
147
        $path = $this->path . DIRECTORY_SEPARATOR . $item->id;
×
148
        $failurePath = $this->getFileVariant($path, self::FILE_SUFFIX_FAILURE_DATA);
×
149

150
        // Early return if message no longer exists in queue
151
        if (!file_exists($path)) {
×
152
            return false;
×
153
        }
154

155
        // Remove failure metadata
156
        if (file_exists($failurePath)) {
×
157
            unlink($failurePath);
×
158
        }
159

160
        return unlink($path);
×
161
    }
162

163
    public function getMailQueue(): Mail\Queue\MailQueue
×
164
    {
165
        return new Mail\Queue\MailQueue(
×
166
            $this->initializeQueueFromFilePath(...),
×
167
        );
×
168
    }
169

170
    /**
171
     * @return \Generator<Mail\Queue\MailQueueItem>
172
     */
173
    private function initializeQueueFromFilePath(): \Generator
×
174
    {
175
        $iterator = new Iterator\LimitedFileIterator(
×
176
            new \DirectoryIterator($this->path),
×
177
            [
×
178
                self::FILE_SUFFIX_QUEUED,
×
179
                self::FILE_SUFFIX_SENDING,
×
180
            ],
×
181
        );
×
182

183
        foreach ($iterator as $file) {
×
184
            yield $this->restoreItem($file);
×
185
        }
186
    }
187

188
    /**
189
     * @throws Exception\SerializedMessageIsInvalid
190
     */
191
    private function restoreItem(\SplFileInfo $file): Mail\Queue\MailQueueItem
×
192
    {
193
        $path = (string)$file->getRealPath();
×
194
        $lastChanged = $file->getMTime();
×
NEW
195
        $exception = null;
×
196

197
        // Unserialize message
198
        try {
NEW
199
            $message = $this->deserializer->deserialize(
×
NEW
200
                (string)file_get_contents($path),
×
NEW
201
                [
×
NEW
202
                    Mailer\SentMessage::class,
×
NEW
203
                    Mime\RawMessage::class,
×
NEW
204
                    Mailer\Envelope::class,
×
NEW
205
                    Mime\Address::class,
×
NEW
206
                    Mime\Part\AbstractPart::class,
×
NEW
207
                    Mime\Part\File::class, // This one does not extend AbstractPart
×
NEW
208
                    Mime\Header\Headers::class,
×
NEW
209
                    Mime\Header\HeaderInterface::class,
×
NEW
210
                ],
×
NEW
211
            );
×
NEW
212
        } catch (\Throwable $exception) {
×
NEW
213
            $message = null;
×
214
        }
215

216
        if (!($message instanceof Mailer\SentMessage)) {
×
NEW
217
            throw new Exception\SerializedMessageIsInvalid($path, $exception);
×
218
        }
219

220
        // Define mail state
221
        if (str_ends_with($path, self::FILE_SUFFIX_SENDING)) {
×
222
            $state = Enums\MailState::Sending;
×
223
            $failure = $this->findFailureMetadata($path);
×
224
        } else {
225
            $state = Enums\MailState::Queued;
×
226
            $failure = null;
×
227
        }
228

229
        // Enforce failure if failure metadata were found
230
        if ($failure !== null) {
×
231
            $state = Enums\MailState::Failed;
×
232
        }
233

234
        // Add last modification date
235
        if ($lastChanged !== false) {
×
236
            $date = new \DateTimeImmutable('@' . $lastChanged, $this->getCurrentTimezone());
×
237
        } else {
238
            $date = null;
×
239
        }
240

241
        return new Mail\Queue\MailQueueItem($file->getFilename(), $message, $state, $date, $failure);
×
242
    }
243

244
    private function findFailureMetadata(string $file): ?Mail\TransportFailure
×
245
    {
246
        $failurePath = $this->getFileVariant($file, self::FILE_SUFFIX_FAILURE_DATA);
×
247

248
        try {
249
            return Mail\TransportFailure::fromFile($failurePath);
×
250
        } catch (Exception\FileDoesNotExist|Exception\SerializedFailureMetadataIsInvalid) {
×
251
            return null;
×
252
        }
253
    }
254

255
    private function getFileVariant(string $file, string $suffix): string
×
256
    {
257
        $variants = array_diff(
×
258
            [
×
259
                self::FILE_SUFFIX_FAILURE_DATA,
×
260
                self::FILE_SUFFIX_QUEUED,
×
261
                self::FILE_SUFFIX_SENDING,
×
262
            ],
×
263
            [$suffix],
×
264
        );
×
265

266
        foreach ($variants as $variant) {
×
267
            if (str_ends_with($file, $variant)) {
×
268
                return substr_replace($file, $suffix, -mb_strlen($variant));
×
269
            }
270
        }
271

272
        return $file;
×
273
    }
274

275
    private function getCurrentTimezone(): ?\DateTimeZone
×
276
    {
277
        $date = $this->context->getPropertyFromAspect('date', 'full');
×
278

279
        if (!($date instanceof \DateTimeInterface)) {
×
280
            return null;
×
281
        }
282

283
        return $date->getTimezone();
×
284
    }
285
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc