1<?php 2 3/* 4 * This file is part of the Symfony package. 5 * 6 * (c) Fabien Potencier <fabien@symfony.com> 7 * 8 * For the full copyright and license information, please view the LICENSE 9 * file that was distributed with this source code. 10 */ 11 12namespace Symfony\Component\Mailer\Transport\Smtp; 13 14use Psr\Log\LoggerInterface; 15use Symfony\Component\Mailer\Envelope; 16use Symfony\Component\Mailer\Exception\LogicException; 17use Symfony\Component\Mailer\Exception\TransportException; 18use Symfony\Component\Mailer\Exception\TransportExceptionInterface; 19use Symfony\Component\Mailer\SentMessage; 20use Symfony\Component\Mailer\Transport\AbstractTransport; 21use Symfony\Component\Mailer\Transport\Smtp\Stream\AbstractStream; 22use Symfony\Component\Mailer\Transport\Smtp\Stream\SocketStream; 23use Symfony\Component\Mime\RawMessage; 24use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; 25 26/** 27 * Sends emails over SMTP. 28 * 29 * @author Fabien Potencier <fabien@symfony.com> 30 * @author Chris Corbyn 31 */ 32class SmtpTransport extends AbstractTransport 33{ 34 private $started = false; 35 private $restartThreshold = 100; 36 private $restartThresholdSleep = 0; 37 private $restartCounter; 38 private $pingThreshold = 100; 39 private $lastMessageTime = 0; 40 private $stream; 41 private $domain = '[127.0.0.1]'; 42 43 public function __construct(AbstractStream $stream = null, EventDispatcherInterface $dispatcher = null, LoggerInterface $logger = null) 44 { 45 parent::__construct($dispatcher, $logger); 46 47 $this->stream = $stream ?? new SocketStream(); 48 } 49 50 public function getStream(): AbstractStream 51 { 52 return $this->stream; 53 } 54 55 /** 56 * Sets the maximum number of messages to send before re-starting the transport. 57 * 58 * By default, the threshold is set to 100 (and no sleep at restart). 59 * 60 * @param int $threshold The maximum number of messages (0 to disable) 61 * @param int $sleep The number of seconds to sleep between stopping and re-starting the transport 62 */ 63 public function setRestartThreshold(int $threshold, int $sleep = 0): self 64 { 65 $this->restartThreshold = $threshold; 66 $this->restartThresholdSleep = $sleep; 67 68 return $this; 69 } 70 71 /** 72 * Sets the minimum number of seconds required between two messages, before the server is pinged. 73 * If the transport wants to send a message and the time since the last message exceeds the specified threshold, 74 * the transport will ping the server first (NOOP command) to check if the connection is still alive. 75 * Otherwise the message will be sent without pinging the server first. 76 * 77 * Do not set the threshold too low, as the SMTP server may drop the connection if there are too many 78 * non-mail commands (like pinging the server with NOOP). 79 * 80 * By default, the threshold is set to 100 seconds. 81 * 82 * @param int $seconds The minimum number of seconds between two messages required to ping the server 83 * 84 * @return $this 85 */ 86 public function setPingThreshold(int $seconds): self 87 { 88 $this->pingThreshold = $seconds; 89 90 return $this; 91 } 92 93 /** 94 * Sets the name of the local domain that will be used in HELO. 95 * 96 * This should be a fully-qualified domain name and should be truly the domain 97 * you're using. 98 * 99 * If your server does not have a domain name, use the IP address. This will 100 * automatically be wrapped in square brackets as described in RFC 5321, 101 * section 4.1.3. 102 */ 103 public function setLocalDomain(string $domain): self 104 { 105 if ('' !== $domain && '[' !== $domain[0]) { 106 if (filter_var($domain, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4)) { 107 $domain = '['.$domain.']'; 108 } elseif (filter_var($domain, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) { 109 $domain = '[IPv6:'.$domain.']'; 110 } 111 } 112 113 $this->domain = $domain; 114 115 return $this; 116 } 117 118 /** 119 * Gets the name of the domain that will be used in HELO. 120 * 121 * If an IP address was specified, this will be returned wrapped in square 122 * brackets as described in RFC 5321, section 4.1.3. 123 */ 124 public function getLocalDomain(): string 125 { 126 return $this->domain; 127 } 128 129 public function send(RawMessage $message, Envelope $envelope = null): ?SentMessage 130 { 131 try { 132 $message = parent::send($message, $envelope); 133 } catch (TransportExceptionInterface $e) { 134 if ($this->started) { 135 try { 136 $this->executeCommand("RSET\r\n", [250]); 137 } catch (TransportExceptionInterface $_) { 138 // ignore this exception as it probably means that the server error was final 139 } 140 } 141 142 throw $e; 143 } 144 145 $this->checkRestartThreshold(); 146 147 return $message; 148 } 149 150 public function __toString(): string 151 { 152 if ($this->stream instanceof SocketStream) { 153 $name = sprintf('smtp%s://%s', ($tls = $this->stream->isTLS()) ? 's' : '', $this->stream->getHost()); 154 $port = $this->stream->getPort(); 155 if (!(25 === $port || ($tls && 465 === $port))) { 156 $name .= ':'.$port; 157 } 158 159 return $name; 160 } 161 162 return 'smtp://sendmail'; 163 } 164 165 /** 166 * Runs a command against the stream, expecting the given response codes. 167 * 168 * @param int[] $codes 169 * 170 * @return string The server response 171 * 172 * @throws TransportException when an invalid response if received 173 * 174 * @internal 175 */ 176 public function executeCommand(string $command, array $codes): string 177 { 178 $this->stream->write($command); 179 $response = $this->getFullResponse(); 180 $this->assertResponseCode($response, $codes); 181 182 return $response; 183 } 184 185 protected function doSend(SentMessage $message): void 186 { 187 if (microtime(true) - $this->lastMessageTime > $this->pingThreshold) { 188 $this->ping(); 189 } 190 191 if (!$this->started) { 192 $this->start(); 193 } 194 195 try { 196 $envelope = $message->getEnvelope(); 197 $this->doMailFromCommand($envelope->getSender()->getEncodedAddress()); 198 foreach ($envelope->getRecipients() as $recipient) { 199 $this->doRcptToCommand($recipient->getEncodedAddress()); 200 } 201 202 $this->executeCommand("DATA\r\n", [354]); 203 try { 204 foreach (AbstractStream::replace("\r\n.", "\r\n..", $message->toIterable()) as $chunk) { 205 $this->stream->write($chunk, false); 206 } 207 $this->stream->flush(); 208 } catch (TransportExceptionInterface $e) { 209 throw $e; 210 } catch (\Exception $e) { 211 $this->stream->terminate(); 212 $this->started = false; 213 $this->getLogger()->debug(sprintf('Email transport "%s" stopped', __CLASS__)); 214 throw $e; 215 } 216 $this->executeCommand("\r\n.\r\n", [250]); 217 $message->appendDebug($this->stream->getDebug()); 218 $this->lastMessageTime = microtime(true); 219 } catch (TransportExceptionInterface $e) { 220 $e->appendDebug($this->stream->getDebug()); 221 $this->lastMessageTime = 0; 222 throw $e; 223 } 224 } 225 226 protected function doHeloCommand(): void 227 { 228 $this->executeCommand(sprintf("HELO %s\r\n", $this->domain), [250]); 229 } 230 231 private function doMailFromCommand(string $address): void 232 { 233 $this->executeCommand(sprintf("MAIL FROM:<%s>\r\n", $address), [250]); 234 } 235 236 private function doRcptToCommand(string $address): void 237 { 238 $this->executeCommand(sprintf("RCPT TO:<%s>\r\n", $address), [250, 251, 252]); 239 } 240 241 private function start(): void 242 { 243 if ($this->started) { 244 return; 245 } 246 247 $this->getLogger()->debug(sprintf('Email transport "%s" starting', __CLASS__)); 248 249 $this->stream->initialize(); 250 $this->assertResponseCode($this->getFullResponse(), [220]); 251 $this->doHeloCommand(); 252 $this->started = true; 253 $this->lastMessageTime = 0; 254 255 $this->getLogger()->debug(sprintf('Email transport "%s" started', __CLASS__)); 256 } 257 258 private function stop(): void 259 { 260 if (!$this->started) { 261 return; 262 } 263 264 $this->getLogger()->debug(sprintf('Email transport "%s" stopping', __CLASS__)); 265 266 try { 267 $this->executeCommand("QUIT\r\n", [221]); 268 } catch (TransportExceptionInterface $e) { 269 } finally { 270 $this->stream->terminate(); 271 $this->started = false; 272 $this->getLogger()->debug(sprintf('Email transport "%s" stopped', __CLASS__)); 273 } 274 } 275 276 private function ping(): void 277 { 278 if (!$this->started) { 279 return; 280 } 281 282 try { 283 $this->executeCommand("NOOP\r\n", [250]); 284 } catch (TransportExceptionInterface $e) { 285 $this->stop(); 286 } 287 } 288 289 /** 290 * @throws TransportException if a response code is incorrect 291 */ 292 private function assertResponseCode(string $response, array $codes): void 293 { 294 if (!$codes) { 295 throw new LogicException('You must set the expected response code.'); 296 } 297 298 if (!$response) { 299 throw new TransportException(sprintf('Expected response code "%s" but got an empty response.', implode('/', $codes))); 300 } 301 302 [$code] = sscanf($response, '%3d'); 303 $valid = \in_array($code, $codes); 304 305 if (!$valid) { 306 throw new TransportException(sprintf('Expected response code "%s" but got code "%s", with message "%s".', implode('/', $codes), $code, trim($response)), $code); 307 } 308 } 309 310 private function getFullResponse(): string 311 { 312 $response = ''; 313 do { 314 $line = $this->stream->readLine(); 315 $response .= $line; 316 } while ($line && isset($line[3]) && ' ' !== $line[3]); 317 318 return $response; 319 } 320 321 private function checkRestartThreshold(): void 322 { 323 // when using sendmail via non-interactive mode, the transport is never "started" 324 if (!$this->started) { 325 return; 326 } 327 328 ++$this->restartCounter; 329 if ($this->restartCounter < $this->restartThreshold) { 330 return; 331 } 332 333 $this->stop(); 334 if (0 < $sleep = $this->restartThresholdSleep) { 335 $this->getLogger()->debug(sprintf('Email transport "%s" sleeps for %d seconds after stopping', __CLASS__, $sleep)); 336 337 sleep($sleep); 338 } 339 $this->start(); 340 $this->restartCounter = 0; 341 } 342 343 /** 344 * @return array 345 */ 346 public function __sleep() 347 { 348 throw new \BadMethodCallException('Cannot serialize '.__CLASS__); 349 } 350 351 public function __wakeup() 352 { 353 throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); 354 } 355 356 public function __destruct() 357 { 358 $this->stop(); 359 } 360} 361