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