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