1<?php
2
3declare(strict_types=1);
4
5/*
6 * This file is part of the TYPO3 CMS project.
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
18namespace TYPO3\CMS\Core\Mail;
19
20use DirectoryIterator;
21use Psr\Log\LoggerInterface;
22use Symfony\Component\Mailer\DelayedEnvelope;
23use Symfony\Component\Mailer\Envelope;
24use Symfony\Component\Mailer\Exception\TransportException;
25use Symfony\Component\Mailer\SentMessage;
26use Symfony\Component\Mailer\Transport\AbstractTransport;
27use Symfony\Component\Mailer\Transport\TransportInterface;
28use Symfony\Component\Mime\Email;
29use Symfony\Component\Mime\Message;
30use Symfony\Component\Mime\RawMessage;
31use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
32use TYPO3\CMS\Core\Utility\GeneralUtility;
33
34/**
35 * Inspired by SwiftMailer, adapted for TYPO3 and Symfony/Mailer
36 *
37 * @internal This class is experimental and subject to change!
38 */
39class FileSpool extends AbstractTransport implements DelayedTransportInterface
40{
41    /**
42     * The spool directory
43     * @var string
44     */
45    protected $path;
46
47    /**
48     * The logger instance.
49     *
50     * @var LoggerInterface
51     */
52    protected $logger;
53
54    /**
55     * File WriteRetry Limit.
56     *
57     * @var int
58     */
59    protected $retryLimit = 10;
60
61    /**
62     * The maximum number of messages to send per flush
63     * @var int
64     */
65    protected $messageLimit;
66
67    /**
68     * The time limit per flush
69     * @var int
70     */
71    protected $timeLimit;
72
73    /**
74     * Create a new FileSpool.
75     *
76     * @param string $path
77     * @param EventDispatcherInterface $dispatcher
78     * @param LoggerInterface $logger
79     */
80    public function __construct(
81        string $path,
82        EventDispatcherInterface $dispatcher = null,
83        LoggerInterface $logger = null
84    ) {
85        parent::__construct($dispatcher, $logger);
86
87        $this->path = $path;
88        $this->logger = $logger;
89
90        if (!file_exists($this->path)) {
91            GeneralUtility::mkdir_deep($this->path);
92        }
93    }
94
95    /**
96     * Stores a message in the queue.
97     * @param SentMessage $message
98     */
99    protected function doSend(SentMessage $message): void
100    {
101        $fileName = $this->path . '/' . $this->getRandomString(9);
102        $i = 0;
103
104        // We try an exclusive creation of the file. This is an atomic
105        // operation, it avoids a locking mechanism
106        do {
107            $fileName .= $this->getRandomString(1);
108            $filePointer = @fopen($fileName . '.message', 'x');
109        } while ($filePointer === false && ++$i < $this->retryLimit);
110
111        if ($filePointer === false) {
112            throw new TransportException('Could not create file for spooling', 1602615347);
113        }
114
115        try {
116            $ser = serialize($message);
117            if (fwrite($filePointer, $ser) === false) {
118                throw new TransportException('Could not write file for spooling', 1602615348);
119            }
120        } finally {
121            fclose($filePointer);
122        }
123    }
124
125    /**
126     * Allow to manage the enqueuing retry limit.
127     *
128     * Default, is ten and allows over 64^20 different fileNames
129     *
130     * @param int $limit
131     */
132    public function setRetryLimit(int $limit): void
133    {
134        $this->retryLimit = $limit;
135    }
136
137    /**
138     * Execute a recovery if for any reason a process is sending for too long.
139     *
140     * @param int $timeout in second Defaults is for very slow smtp responses
141     */
142    public function recover(int $timeout = 900): void
143    {
144        foreach (new DirectoryIterator($this->path) as $file) {
145            $file = (string)$file->getRealPath();
146
147            if (substr($file, -16) == '.message.sending') {
148                $lockedtime = filectime($file);
149                if ((time() - $lockedtime) > $timeout) {
150                    rename($file, substr($file, 0, -8));
151                }
152            }
153        }
154    }
155
156    /**
157     * @inheritdoc
158     */
159    public function flushQueue(TransportInterface $transport): int
160    {
161        $directoryIterator = new DirectoryIterator($this->path);
162
163        $count = 0;
164        $time = time();
165        foreach ($directoryIterator as $file) {
166            $file = (string)$file->getRealPath();
167
168            if (substr($file, -8) != '.message') {
169                continue;
170            }
171
172            /* We try a rename, it's an atomic operation, and avoid locking the file */
173            if (rename($file, $file . '.sending')) {
174                $message = unserialize((string)file_get_contents($file . '.sending'), [
175                    'allowedClasses' => [
176                        RawMessage::class,
177                        Message::class,
178                        Email::class,
179                        DelayedEnvelope::class,
180                        Envelope::class,
181                    ],
182                ]);
183
184                $transport->send($message->getMessage(), $message->getEnvelope());
185                $count++;
186
187                unlink($file . '.sending');
188            } else {
189                /* This message has just been caught by another process */
190                continue;
191            }
192
193            if ($this->getMessageLimit() && $count >= $this->getMessageLimit()) {
194                break;
195            }
196
197            if ($this->getTimeLimit() && ($GLOBALS['EXEC_TIME'] - $time) >= $this->getTimeLimit()) {
198                break;
199            }
200        }
201        return $count;
202    }
203
204    /**
205     * Returns a random string needed to generate a fileName for the queue.
206     *
207     * @param int $count
208     *
209     * @return string
210     */
211    protected function getRandomString(int $count): string
212    {
213        // This string MUST stay FS safe, avoid special chars
214        $base = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-';
215        $ret = '';
216        $strlen = strlen($base);
217        for ($i = 0; $i < $count; ++$i) {
218            $ret .= $base[((int)random_int(0, $strlen - 1))];
219        }
220
221        return $ret;
222    }
223
224    /**
225     * Sets the maximum number of messages to send per flush.
226     *
227     * @param int $limit
228     */
229    public function setMessageLimit(int $limit): void
230    {
231        $this->messageLimit = (int)$limit;
232    }
233
234    /**
235     * Gets the maximum number of messages to send per flush.
236     *
237     * @return int The limit
238     */
239    public function getMessageLimit(): int
240    {
241        return $this->messageLimit;
242    }
243
244    /**
245     * Sets the time limit (in seconds) per flush.
246     *
247     * @param int $limit The limit
248     */
249    public function setTimeLimit(int $limit): void
250    {
251        $this->timeLimit = (int)$limit;
252    }
253
254    /**
255     * Gets the time limit (in seconds) per flush.
256     *
257     * @return int The limit
258     */
259    public function getTimeLimit(): int
260    {
261        return $this->timeLimit;
262    }
263
264    public function __toString(): string
265    {
266        return 'FileSpool:' . $this->path;
267    }
268}
269