Commit | Line | Data |
---|---|---|
da71d47d | 1 | <?php |
a9229942 | 2 | |
da71d47d | 3 | namespace wcf\system\email\transport; |
a9229942 | 4 | |
da71d47d TD |
5 | use wcf\system\email\Email; |
6 | use wcf\system\email\Mailbox; | |
a9229942 TD |
7 | use wcf\system\email\transport\exception\PermanentFailure; |
8 | use wcf\system\email\transport\exception\TransientFailure; | |
da71d47d TD |
9 | use wcf\system\exception\SystemException; |
10 | use wcf\system\io\RemoteFile; | |
c6f36059 | 11 | use wcf\system\WCF; |
da71d47d TD |
12 | use 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 | 23 | class 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 | } |