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

CPS-IT / mailqueue / 21161602293

20 Jan 2026 06:20AM UTC coverage: 0.0%. Remained the same
21161602293

push

github

web-flow
Merge pull request #205 from CPS-IT/fix/0.4/unserialize

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

1 existing line in 1 file now uncovered.

0 of 714 relevant lines covered (0.0%)

0.0 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
 * Copyright (C) 2024 Elias Häußler <e.haeussler@familie-redlich.de>
9
 *
10
 * This program is free software: you can redistribute it and/or modify
11
 * it under the terms of the GNU General Public License as published by
12
 * the Free Software Foundation, either version 2 of the License, or
13
 * (at your option) any later version.
14
 *
15
 * This program is distributed in the hope that it will be useful,
16
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
17
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18
 * GNU General Public License for more details.
19
 *
20
 * You should have received a copy of the GNU General Public License
21
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
22
 */
23

24
namespace CPSIT\Typo3Mailqueue\Mail\Transport;
25

26
use CPSIT\Typo3Mailqueue\Enums;
27
use CPSIT\Typo3Mailqueue\Exception;
28
use CPSIT\Typo3Mailqueue\Iterator;
29
use CPSIT\Typo3Mailqueue\Mail;
30
use Psr\Log;
31
use Symfony\Component\Mailer;
32
use Symfony\Component\Mime;
33
use Symfony\Contracts\EventDispatcher;
34
use TYPO3\CMS\Core;
35

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

48
    private readonly Core\Context\Context $context;
49

50
    public function __construct(
×
51
        string $path,
52
        ?EventDispatcher\EventDispatcherInterface $dispatcher = null,
53
        ?Log\LoggerInterface $logger = null,
54
        Core\Serializer\PolymorphicDeserializer $deserializer = new Core\Serializer\PolymorphicDeserializer(),
55
    ) {
NEW
56
        parent::__construct($path, $dispatcher, $logger, $deserializer);
×
57
        $this->context = Core\Utility\GeneralUtility::makeInstance(Core\Context\Context::class);
×
58
    }
59

60
    public function recover(int $timeout = 900): void
×
61
    {
62
        $iterator = new \DirectoryIterator($this->path);
×
63

64
        // Remove failure metadata
65
        foreach ($iterator as $file) {
×
66
            $path = (string)$file->getRealPath();
×
67

68
            if (str_ends_with($path, self::FILE_SUFFIX_FAILURE_DATA)) {
×
69
                unlink($path);
×
70
            }
71
        }
72

73
        // Recover stuck transports
74
        parent::recover($timeout);
×
75
    }
76

77
    /**
78
     * @throws Exception\SerializedMessageIsInvalid
79
     * @throws Mailer\Exception\TransportExceptionInterface
80
     */
81
    public function flushQueue(Mailer\Transport\TransportInterface $transport): int
×
82
    {
83
        $directoryIterator = new \DirectoryIterator($this->path);
×
84
        /** @var positive-int $execTime */
85
        $execTime = $this->context->getPropertyFromAspect('date', 'timestamp');
×
86
        $time = time();
×
87
        $count = 0;
×
88

89
        foreach ($directoryIterator as $file) {
×
90
            $path = (string)$file->getRealPath();
×
91

92
            if (!str_ends_with($path, self::FILE_SUFFIX_QUEUED)) {
×
93
                continue;
×
94
            }
95

96
            $item = $this->restoreItem($file);
×
97

98
            if ($this->dequeue($item, $transport)) {
×
99
                $count++;
×
100
            } else {
101
                // This message has just been caught by another process
102
                continue;
×
103
            }
104

105
            if ($this->getMessageLimit() && $count >= $this->getMessageLimit()) {
×
106
                break;
×
107
            }
108

109
            if ($this->getTimeLimit() && ($execTime - $time) >= $this->getTimeLimit()) {
×
110
                break;
×
111
            }
112
        }
113

114
        return $count;
×
115
    }
116

117
    public function enqueue(Mime\RawMessage $message, ?Mailer\Envelope $envelope = null): ?Mail\Queue\MailQueueItem
×
118
    {
119
        $sentMessage = $this->send($message, $envelope);
×
120

121
        // Early return if message was rejected
122
        if ($sentMessage === null) {
×
123
            return null;
×
124
        }
125

126
        // Look up mail in queue
127
        foreach ($this->getMailQueue() as $mailQueueItem) {
×
128
            // Loose comparison is intended
129
            if ($mailQueueItem->message == $sentMessage) {
×
130
                return $mailQueueItem;
×
131
            }
132
        }
133

134
        return null;
×
135
    }
136

137
    public function dequeue(Mail\Queue\MailQueueItem $item, Mailer\Transport\TransportInterface $transport): bool
×
138
    {
139
        $path = $this->path . DIRECTORY_SEPARATOR . $item->id;
×
140
        $sendingPath = $this->getFileVariant($path, self::FILE_SUFFIX_SENDING);
×
141
        $failurePath = $this->getFileVariant($path, self::FILE_SUFFIX_FAILURE_DATA);
×
142

143
        // We try a rename, it's an atomic operation, and avoid locking the file
144
        if ($path !== $sendingPath && !rename($path, $sendingPath)) {
×
145
            return false;
×
146
        }
147

148
        try {
149
            $transport->send($item->message->getMessage(), $item->message->getEnvelope());
×
150
        } catch (Mailer\Exception\TransportExceptionInterface $exception) {
×
151
            $this->flagFailedTransport($sendingPath, $exception);
×
152

153
            throw $exception;
×
154
        }
155

156
        // Remove message from queue
157
        unlink($sendingPath);
×
158

159
        // Remove failure metadata
160
        if (file_exists($failurePath)) {
×
161
            unlink($failurePath);
×
162
        }
163

164
        return true;
×
165
    }
166

167
    public function delete(Mail\Queue\MailQueueItem $item): bool
×
168
    {
169
        $path = $this->path . DIRECTORY_SEPARATOR . $item->id;
×
170
        $failurePath = $this->getFileVariant($path, self::FILE_SUFFIX_FAILURE_DATA);
×
171

172
        // Early return if message no longer exists in queue
173
        if (!file_exists($path)) {
×
174
            return false;
×
175
        }
176

177
        // Remove failure metadata
178
        if (file_exists($failurePath)) {
×
179
            unlink($failurePath);
×
180
        }
181

182
        return unlink($path);
×
183
    }
184

185
    public function getMailQueue(): Mail\Queue\MailQueue
×
186
    {
187
        return new Mail\Queue\MailQueue(
×
188
            $this->initializeQueueFromFilePath(...),
×
189
        );
×
190
    }
191

192
    private function flagFailedTransport(string $file, Mailer\Exception\TransportExceptionInterface $exception): void
×
193
    {
194
        $failure = Mail\TransportFailure::fromException($exception);
×
195
        $failurePath = $this->getFileVariant($file, self::FILE_SUFFIX_FAILURE_DATA);
×
196

197
        file_put_contents($failurePath, serialize($failure));
×
198
    }
199

200
    /**
201
     * @return \Generator<Mail\Queue\MailQueueItem>
202
     */
203
    private function initializeQueueFromFilePath(): \Generator
×
204
    {
205
        $iterator = new Iterator\LimitedFileIterator(
×
206
            new \DirectoryIterator($this->path),
×
207
            [
×
208
                self::FILE_SUFFIX_QUEUED,
×
209
                self::FILE_SUFFIX_SENDING,
×
210
            ],
×
211
        );
×
212

213
        foreach ($iterator as $file) {
×
214
            yield $this->restoreItem($file);
×
215
        }
216
    }
217

218
    /**
219
     * @throws Exception\SerializedMessageIsInvalid
220
     */
221
    private function restoreItem(\SplFileInfo $file): Mail\Queue\MailQueueItem
×
222
    {
223
        $path = (string)$file->getRealPath();
×
224
        $lastChanged = $file->getMTime();
×
NEW
225
        $exception = null;
×
226

227
        // Unserialize message
228
        try {
NEW
229
            $message = $this->deserializer->deserialize(
×
NEW
230
                (string)file_get_contents($path),
×
NEW
231
                [
×
NEW
232
                    Mailer\SentMessage::class,
×
NEW
233
                    Mime\RawMessage::class,
×
NEW
234
                    Mailer\Envelope::class,
×
NEW
235
                    Mime\Address::class,
×
NEW
236
                    Mime\Part\AbstractPart::class,
×
NEW
237
                    Mime\Part\File::class, // This one does not extend AbstractPart
×
NEW
238
                    Mime\Header\Headers::class,
×
NEW
239
                    Mime\Header\HeaderInterface::class,
×
NEW
240
                ],
×
NEW
241
            );
×
NEW
242
        } catch (\Throwable $exception) {
×
NEW
243
            $message = null;
×
244
        }
245

246
        if (!($message instanceof Mailer\SentMessage)) {
×
NEW
247
            throw new Exception\SerializedMessageIsInvalid($path, $exception);
×
248
        }
249

250
        // Define mail state
251
        if (str_ends_with($path, self::FILE_SUFFIX_SENDING)) {
×
252
            $state = Enums\MailState::Sending;
×
253
            $failure = $this->findFailureMetadata($path);
×
254
        } else {
255
            $state = Enums\MailState::Queued;
×
256
            $failure = null;
×
257
        }
258

259
        // Enforce failure if failure metadata were found
260
        if ($failure !== null) {
×
261
            $state = Enums\MailState::Failed;
×
262
        }
263

264
        // Add last modification date
265
        if ($lastChanged !== false) {
×
266
            $date = new \DateTimeImmutable('@' . $lastChanged, $this->getCurrentTimezone());
×
267
        } else {
268
            $date = null;
×
269
        }
270

271
        return new Mail\Queue\MailQueueItem($file->getFilename(), $message, $state, $date, $failure);
×
272
    }
273

274
    private function findFailureMetadata(string $file): ?Mail\TransportFailure
×
275
    {
276
        $failurePath = $this->getFileVariant($file, self::FILE_SUFFIX_FAILURE_DATA);
×
277

278
        try {
279
            return Mail\TransportFailure::fromFile($failurePath);
×
280
        } catch (Exception\FileDoesNotExist|Exception\SerializedFailureMetadataIsInvalid) {
×
281
            return null;
×
282
        }
283
    }
284

285
    private function getFileVariant(string $file, string $suffix): string
×
286
    {
287
        $variants = array_diff(
×
288
            [
×
289
                self::FILE_SUFFIX_FAILURE_DATA,
×
290
                self::FILE_SUFFIX_QUEUED,
×
291
                self::FILE_SUFFIX_SENDING,
×
292
            ],
×
293
            [$suffix],
×
294
        );
×
295

296
        foreach ($variants as $variant) {
×
297
            if (str_ends_with($file, $variant)) {
×
298
                return substr_replace($file, $suffix, -mb_strlen($variant));
×
299
            }
300
        }
301

302
        return $file;
×
303
    }
304

305
    private function getCurrentTimezone(): ?\DateTimeZone
×
306
    {
307
        $date = $this->context->getPropertyFromAspect('date', 'full');
×
308

309
        if (!($date instanceof \DateTimeInterface)) {
×
310
            return null;
×
311
        }
312

313
        return $date->getTimezone();
×
314
    }
315
}
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