EsmtpTransport.php 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  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\Exception\TransportException;
  14. use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
  15. use Symfony\Component\Mailer\Exception\UnexpectedResponseException;
  16. use Symfony\Component\Mailer\Transport\Smtp\Auth\AuthenticatorInterface;
  17. use Symfony\Component\Mailer\Transport\Smtp\Stream\AbstractStream;
  18. use Symfony\Component\Mailer\Transport\Smtp\Stream\SocketStream;
  19. /**
  20. * Sends Emails over SMTP with ESMTP support.
  21. *
  22. * @author Fabien Potencier <fabien@symfony.com>
  23. * @author Chris Corbyn
  24. */
  25. class EsmtpTransport extends SmtpTransport
  26. {
  27. private array $authenticators = [];
  28. private string $username = '';
  29. private string $password = '';
  30. private array $capabilities;
  31. public function __construct(string $host = 'localhost', int $port = 0, ?bool $tls = null, ?EventDispatcherInterface $dispatcher = null, ?LoggerInterface $logger = null, ?AbstractStream $stream = null, ?array $authenticators = null)
  32. {
  33. parent::__construct($stream, $dispatcher, $logger);
  34. if (null === $authenticators) {
  35. // fallback to default authenticators
  36. // order is important here (roughly most secure and popular first)
  37. $authenticators = [
  38. new Auth\CramMd5Authenticator(),
  39. new Auth\LoginAuthenticator(),
  40. new Auth\PlainAuthenticator(),
  41. new Auth\XOAuth2Authenticator(),
  42. ];
  43. }
  44. $this->setAuthenticators($authenticators);
  45. /** @var SocketStream $stream */
  46. $stream = $this->getStream();
  47. if (null === $tls) {
  48. if (465 === $port) {
  49. $tls = true;
  50. } else {
  51. $tls = \defined('OPENSSL_VERSION_NUMBER') && 0 === $port && 'localhost' !== $host;
  52. }
  53. }
  54. if (!$tls) {
  55. $stream->disableTls();
  56. }
  57. if (0 === $port) {
  58. $port = $tls ? 465 : 25;
  59. }
  60. $stream->setHost($host);
  61. $stream->setPort($port);
  62. }
  63. /**
  64. * @return $this
  65. */
  66. public function setUsername(string $username): static
  67. {
  68. $this->username = $username;
  69. return $this;
  70. }
  71. public function getUsername(): string
  72. {
  73. return $this->username;
  74. }
  75. /**
  76. * @return $this
  77. */
  78. public function setPassword(#[\SensitiveParameter] string $password): static
  79. {
  80. $this->password = $password;
  81. return $this;
  82. }
  83. public function getPassword(): string
  84. {
  85. return $this->password;
  86. }
  87. public function setAuthenticators(array $authenticators): void
  88. {
  89. $this->authenticators = [];
  90. foreach ($authenticators as $authenticator) {
  91. $this->addAuthenticator($authenticator);
  92. }
  93. }
  94. public function addAuthenticator(AuthenticatorInterface $authenticator): void
  95. {
  96. $this->authenticators[] = $authenticator;
  97. }
  98. public function executeCommand(string $command, array $codes): string
  99. {
  100. return [250] === $codes && str_starts_with($command, 'HELO ') ? $this->doEhloCommand() : parent::executeCommand($command, $codes);
  101. }
  102. final protected function getCapabilities(): array
  103. {
  104. return $this->capabilities;
  105. }
  106. private function doEhloCommand(): string
  107. {
  108. try {
  109. $response = $this->executeCommand(\sprintf("EHLO %s\r\n", $this->getLocalDomain()), [250]);
  110. } catch (TransportExceptionInterface $e) {
  111. try {
  112. return parent::executeCommand(\sprintf("HELO %s\r\n", $this->getLocalDomain()), [250]);
  113. } catch (TransportExceptionInterface $ex) {
  114. if (!$ex->getCode()) {
  115. throw $e;
  116. }
  117. throw $ex;
  118. }
  119. }
  120. $this->capabilities = $this->parseCapabilities($response);
  121. /** @var SocketStream $stream */
  122. $stream = $this->getStream();
  123. // WARNING: !$stream->isTLS() is right, 100% sure :)
  124. // if you think that the ! should be removed, read the code again
  125. // if doing so "fixes" your issue then it probably means your SMTP server behaves incorrectly or is wrongly configured
  126. if (!$stream->isTLS() && \defined('OPENSSL_VERSION_NUMBER') && \array_key_exists('STARTTLS', $this->capabilities)) {
  127. $this->executeCommand("STARTTLS\r\n", [220]);
  128. if (!$stream->startTLS()) {
  129. throw new TransportException('Unable to connect with STARTTLS.');
  130. }
  131. $response = $this->executeCommand(\sprintf("EHLO %s\r\n", $this->getLocalDomain()), [250]);
  132. $this->capabilities = $this->parseCapabilities($response);
  133. }
  134. if (\array_key_exists('AUTH', $this->capabilities)) {
  135. $this->handleAuth($this->capabilities['AUTH']);
  136. }
  137. return $response;
  138. }
  139. private function parseCapabilities(string $ehloResponse): array
  140. {
  141. $capabilities = [];
  142. $lines = explode("\r\n", trim($ehloResponse));
  143. array_shift($lines);
  144. foreach ($lines as $line) {
  145. if (preg_match('/^[0-9]{3}[ -]([A-Z0-9-]+)((?:[ =].*)?)$/Di', $line, $matches)) {
  146. $value = strtoupper(ltrim($matches[2], ' ='));
  147. $capabilities[strtoupper($matches[1])] = $value ? explode(' ', $value) : [];
  148. }
  149. }
  150. return $capabilities;
  151. }
  152. private function handleAuth(array $modes): void
  153. {
  154. if (!$this->username) {
  155. return;
  156. }
  157. $code = null;
  158. $authNames = [];
  159. $errors = [];
  160. $modes = array_map('strtolower', $modes);
  161. foreach ($this->authenticators as $authenticator) {
  162. if (!\in_array(strtolower($authenticator->getAuthKeyword()), $modes, true)) {
  163. continue;
  164. }
  165. $code = null;
  166. $authNames[] = $authenticator->getAuthKeyword();
  167. try {
  168. $authenticator->authenticate($this);
  169. return;
  170. } catch (UnexpectedResponseException $e) {
  171. $code = $e->getCode();
  172. try {
  173. $this->executeCommand("RSET\r\n", [250]);
  174. } catch (TransportExceptionInterface) {
  175. // ignore this exception as it probably means that the server error was final
  176. }
  177. // keep the error message, but tries the other authenticators
  178. $errors[$authenticator->getAuthKeyword()] = $e->getMessage();
  179. }
  180. }
  181. if (!$authNames) {
  182. throw new TransportException(\sprintf('Failed to find an authenticator supported by the SMTP server, which currently supports: "%s".', implode('", "', $modes)), $code ?: 504);
  183. }
  184. $message = \sprintf('Failed to authenticate on SMTP server with username "%s" using the following authenticators: "%s".', $this->username, implode('", "', $authNames));
  185. foreach ($errors as $name => $error) {
  186. $message .= \sprintf(' Authenticator "%s" returned "%s".', $name, $error);
  187. }
  188. throw new TransportException($message, $code ?: 535);
  189. }
  190. }