Remove support for MAIL_SMTP_STARTTLS = 'may'
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / system / email / transport / SmtpEmailTransport.class.php
CommitLineData
da71d47d 1<?php
a9229942 2
da71d47d 3namespace wcf\system\email\transport;
a9229942 4
da71d47d
TD
5use wcf\system\email\Email;
6use wcf\system\email\Mailbox;
a9229942
TD
7use wcf\system\email\transport\exception\PermanentFailure;
8use wcf\system\email\transport\exception\TransientFailure;
da71d47d
TD
9use wcf\system\exception\SystemException;
10use wcf\system\io\RemoteFile;
c6f36059 11use wcf\system\WCF;
da71d47d
TD
12use wcf\util\StringUtil;
13
14/**
15 * SmtpEmailTransport is an implementation of an email transport which sends emails via SMTP (RFC 5321, 3207 and 4954).
a9229942
TD
16 *
17 * @author Tim Duesterhus
18 * @copyright 2001-2019 WoltLab GmbH
19 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
20 * @package WoltLabSuite\Core\System\Email\Transport
21 * @since 3.0
da71d47d 22 */
b17d3ee2 23class SmtpEmailTransport implements IStatusReportingEmailTransport
a9229942
TD
24{
25 /**
26 * SMTP connection
27 * @var RemoteFile
28 */
29 protected $connection;
30
31 /**
32 * host of the smtp server to use
33 * @var string
34 */
35 protected $host;
36
37 /**
38 * port to use
39 * @var int
40 */
41 protected $port;
42
43 /**
44 * username to use for authentication
45 * @var string
46 */
47 protected $username;
48
49 /**
50 * password corresponding to the username
51 * @var string
52 */
53 protected $password;
54
55 /**
56 * STARTTLS encryption level
57 * @var string
58 */
59 protected $starttls;
60
61 /**
62 * last value written to the server
63 * @var string
64 */
65 protected $lastWrite = '';
66
67 /**
68 * ESMTP features advertised by the server
69 * @var string[]
70 */
71 protected $features = [];
72
73 /**
74 * if this property is an instance of \Exception email delivery will be locked
75 * and the \Exception will be thrown when attempting to deliver() an email
76 * @var \Exception
77 */
78 protected $locked;
79
80 /**
81 * Creates a new SmtpEmailTransport using the given host.
82 *
83 * @param string $host host of the smtp server to use
84 * @param int $port port to use
85 * @param string $username username to use for authentication
86 * @param string $password corresponding password
13144317 87 * @param string $starttls one of 'none' and 'encrypt'
a9229942
TD
88 * @throws \InvalidArgumentException
89 */
90 public function __construct(
91 $host = MAIL_SMTP_HOST,
92 $port = MAIL_SMTP_PORT,
93 $username = MAIL_SMTP_USER,
94 $password = MAIL_SMTP_PASSWORD,
95 $starttls = MAIL_SMTP_STARTTLS
96 ) {
97 $this->host = StringUtil::trim($host);
98 $this->port = \intval($port);
99 $this->username = StringUtil::trim($username);
100 $this->password = StringUtil::trim($password);
101
102 switch ($starttls) {
103 case 'none':
a9229942
TD
104 case 'encrypt':
105 $this->starttls = $starttls;
106 break;
107 default:
108 throw new \InvalidArgumentException(
13144317 109 "Invalid STARTTLS preference '" . $starttls . "'. Must be one of 'none' and 'encrypt'."
a9229942
TD
110 );
111 }
112 }
113
114 /**
115 * @inheritDoc
116 */
117 public function __destruct()
118 {
119 $this->disconnect();
120 }
121
122 /**
123 * Tests the connection by establishing a connection and optionally
124 * providing user credentials. Returns the error message or an empty
125 * string on success.
126 *
127 * @return string
128 */
129 public function testConnection()
130 {
131 try {
132 $this->connect(10);
133 $this->auth();
134 } catch (SystemException $e) {
135 if (\strpos($e->getMessage(), 'Can not connect to') === 0) {
136 return WCF::getLanguage()->get('wcf.acp.email.smtp.test.error.hostUnknown');
137 }
138
139 return $e->getMessage();
140 } catch (PermanentFailure $e) {
141 if (\strpos($e->getMessage(), 'Remote SMTP server does not support EHLO') === 0) {
142 return WCF::getLanguage()->get('wcf.acp.email.smtp.test.error.notTlsSupport');
143 } elseif (\strpos($e->getMessage(), 'Remote SMTP server does not advertise STARTTLS') === 0) {
144 return WCF::getLanguage()->get('wcf.acp.email.smtp.test.error.notTlsSupport');
145 } elseif (\strpos($e->getMessage(), "Remote SMTP server reported permanent error code: 535 (") === 0) {
146 return WCF::getLanguage()->get('wcf.acp.email.smtp.test.error.badAuth');
147 }
148
149 return $e->getMessage();
150 } catch (TransientFailure $e) {
151 if (\strpos($e->getMessage(), 'Enabling TLS failed') === 0) {
152 return WCF::getLanguage()->get('wcf.acp.email.smtp.test.error.tlsFailed');
153 }
154
155 return $e->getMessage();
156 }
157
158 $this->disconnect();
159
160 return '';
161 }
162
163 /**
164 * Reads a server reply and validates it against the given expected status codes.
165 * Returns a tuple [ status code, reply text ].
166 *
167 * @param int[] $expectedCodes
168 * @return array
169 * @throws PermanentFailure
170 * @throws TransientFailure
171 */
172 protected function read(array $expectedCodes)
173 {
174 $truncateReply = static function ($reply) {
175 return StringUtil::truncate(
176 \preg_replace('/[\x00-\x1F\x80-\xFF]/', '.', $reply),
71064fdf 177 250,
a9229942
TD
178 StringUtil::HELLIP,
179 true
180 );
181 };
182
183 $code = null;
184 $reply = '';
185 do {
186 $data = $this->connection->gets();
187 if (\preg_match('/^(\d{3})([- ])(.*)$/', $data, $matches)) {
188 if ($code === null) {
189 $code = \intval($matches[1]);
190
191 if (!\in_array($code, $expectedCodes)) {
192 // 4xx is a transient failure
193 if (400 <= $code && $code < 500) {
194 throw new TransientFailure("Remote SMTP server reported transient error code: " . $code . " (" . $truncateReply($matches[3]) . ") in reply to '" . $this->lastWrite . "'");
195 }
196
197 // 5xx is a permanent failure
198 if (500 <= $code && $code < 600) {
199 throw new PermanentFailure("Remote SMTP server reported permanent error code: " . $code . " (" . $truncateReply($matches[3]) . ") in reply to '" . $this->lastWrite . "'");
200 }
201
202 throw new TransientFailure("Remote SMTP server reported not expected code: " . $code . " (" . $truncateReply($matches[3]) . ") in reply to '" . $this->lastWrite . "'");
203 }
204 }
205
206 if ($code == $matches[1]) {
207 $reply .= \trim($matches[3]) . "\r\n";
208
209 // no more continuation lines
210 if ($matches[2] === ' ') {
211 break;
212 }
213 } else {
214 throw new TransientFailure("Unexpected reply '" . $data . "' from SMTP server. Code does not match previous codes from multiline answer.");
215 }
216 } else {
217 if ($this->connection->eof()) {
218 throw new TransientFailure("Unexpected EOF / connection close from SMTP server.");
219 }
220
221 throw new TransientFailure("Unexpected reply '" . $data . "' from SMTP server.");
222 }
223 } while (true);
224
225 return [$code, $reply];
226 }
227
228 /**
229 * Writes the given line to the server.
230 *
231 * @param string $data
232 */
233 protected function write($data)
234 {
235 $this->lastWrite = $data;
236 $this->connection->write($data . "\r\n");
237 }
238
239 /**
240 * Connects to the server and enables STARTTLS if available. Bails
241 * out if STARTTLS is not available and connection is set to 'encrypt'.
242 *
243 * @param int $overrideTimeout
244 * @throws PermanentFailure
245 */
246 protected function connect($overrideTimeout = null)
247 {
248 if ($overrideTimeout === null) {
249 $this->connection = new RemoteFile($this->host, $this->port);
250 } else {
251 $this->connection = new RemoteFile($this->host, $this->port, $overrideTimeout);
252 }
253
254 $this->read([220]);
255
256 try {
257 $this->write('EHLO ' . Email::getHost());
258 $this->features = \array_map(
259 'strtolower',
260 \explode("\n", StringUtil::unifyNewlines($this->read([250])[1]))
261 );
262 } catch (SystemException $e) {
263 if ($this->starttls == 'encrypt') {
264 throw new PermanentFailure(
265 "Remote SMTP server does not support EHLO, but \$starttls is set to 'encrypt'."
266 );
267 }
268
269 $this->write('HELO ' . Email::getHost());
270 $this->features = [];
271 }
272
273 switch ($this->starttls) {
274 case 'encrypt':
275 if (!\in_array('starttls', $this->features)) {
276 throw new PermanentFailure(
277 "Remote SMTP server does not advertise STARTTLS, but \$starttls is set to 'encrypt'."
278 );
279 }
280
281 $this->starttls();
282
283 $this->write('EHLO ' . Email::getHost());
284 $this->features = \array_map(
285 'strtolower',
286 \explode("\n", StringUtil::unifyNewlines($this->read([250])[1]))
287 );
288 break;
a9229942
TD
289 case 'none':
290 // nothing to do here
291 }
292 }
293
294 /**
295 * Enables STARTTLS on the connection.
296 *
297 * @throws TransientFailure
298 */
299 protected function starttls()
300 {
301 $this->write("STARTTLS");
302 $this->read([220]);
303
304 try {
305 if (!$this->connection->setTLS(true)) {
306 throw new TransientFailure('Enabling TLS failed');
307 }
308 } catch (SystemException $e) {
309 throw new TransientFailure('Enabling TLS failed', 0, $e);
310 }
311 }
312
313 /**
314 * Performs SASL authentication using the credentials provided in the
315 * constructor. Supported mechanisms are LOGIN and PLAIN.
316 */
317 protected function auth()
318 {
319 if (!$this->username || !$this->password) {
320 return;
321 }
322
323 $authException = null;
324 foreach ($this->features as $feature) {
325 $parameters = \explode(" ", $feature);
326
327 if ($parameters[0] == 'auth') {
328 // Try mechanisms in order of preference.
329 foreach (['login', 'plain'] as $method) {
330 if (\in_array($method, $parameters)) {
331 switch ($method) {
332 case 'login':
333 try {
334 $this->write('AUTH LOGIN');
335 $this->read([334]);
336 } catch (SystemException $e) {
337 $authException = $e;
338 // try next authentication method
339 continue 2;
340 }
341 $this->write(\base64_encode($this->username));
342 $this->lastWrite = '*redacted*';
343 $this->read([334]);
344 $this->write(\base64_encode($this->password));
345 $this->lastWrite = '*redacted*';
346 $this->read([235]);
347
348 // Authentication was successful.
349 return;
350 break;
351 case 'plain':
352 // RFC 4616
353 try {
354 $this->write('AUTH PLAIN');
355 $this->read([334]);
356 } catch (SystemException $e) {
357 $authException = $e;
358 // try next authentication method
359 continue 2;
360 }
361 $this->write(\base64_encode("\0" . $this->username . "\0" . $this->password));
362 $this->lastWrite = '*redacted*';
363 $this->read([235]);
364
365 // Authentication was successful.
366 return;
367 }
368 }
369 }
370
371 // No mechanism was accepted.
372 break;
373 }
374 }
375
376 // server does not support auth
377 throw new TransientFailure(
378 "Remote SMTP server does not support AUTH, but SMTP credentials are specified.",
379 0,
380 $authException
381 );
382 }
383
384 /**
385 * Cleanly closes the connection to the server.
386 */
387 protected function disconnect()
388 {
389 if ($this->connection) {
390 try {
391 $this->write("QUIT");
392 $this->connection->close();
393 } catch (SystemException $e) {
394 // quit failed, don't care about it
395 } finally {
396 $this->connection = null;
397 }
398 }
399 }
400
401 /**
402 * Delivers the given email using SMTP.
403 *
404 * @param Email $email
405 * @param Mailbox $envelopeFrom
406 * @param Mailbox $envelopeTo
407 * @throws \Exception
408 * @throws PermanentFailure
409 */
b17d3ee2 410 public function deliver(Email $email, Mailbox $envelopeFrom, Mailbox $envelopeTo): string
a9229942
TD
411 {
412 // delivery is locked
413 if ($this->locked instanceof \Exception) {
414 throw $this->locked;
415 }
416
417 if (!$this->connection || $this->connection->eof()) {
418 try {
419 $this->connect();
420 $this->auth();
421 } catch (PermanentFailure $e) {
422 // lock delivery on permanent failure to avoid spamming the SMTP server
423 $this->locked = $e;
424 $this->disconnect();
425 throw $e;
426 } catch (\Exception $e) {
427 $this->disconnect();
428 throw $e;
429 }
430 }
431
432 $this->write('RSET');
433 $this->read([250]);
434 $this->write('MAIL FROM:<' . $envelopeFrom->getAddress() . '>');
435 $this->read([250]);
436 $this->write('RCPT TO:<' . $envelopeTo->getAddress() . '>');
437 $this->read([250, 251]);
438 $this->write('DATA');
439 $this->read([354]);
440 $this->connection->write(\implode("\r\n", \array_map(static function ($item) {
441 // 4.5.2 Transparency
442 // o Before sending a line of mail text, the SMTP client checks the
443 // first character of the line. If it is a period, one additional
444 // period is inserted at the beginning of the line.
445 if (StringUtil::startsWith($item, '.')) {
446 return '.' . $item;
447 }
448
449 return $item;
450 }, \explode("\r\n", $email->getEmail()))) . "\r\n");
451 $this->write(".");
b17d3ee2
TD
452 [, $message] = $this->read([250]);
453
454 return $message;
a9229942 455 }
da71d47d 456}