/src/Symfony/Component/Mailer/Transport/Smtp/SmtpTransport.php

https://github.com/FabienD/symfony · PHP · 368 lines · 235 code · 53 blank · 80 comment · 22 complexity · ec31ab30b430748dcd069ca7e01bae30 MD5 · raw file

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