--- /dev/null
+<?php
+namespace wcf\system\email\transport;
+use wcf\system\email\transport\exception\PermanentFailure;
+use wcf\system\email\transport\exception\TransientFailure;
+use wcf\system\email\Email;
+use wcf\system\email\Mailbox;
+use wcf\system\exception\SystemException;
+use wcf\system\io\RemoteFile;
+use wcf\util\StringUtil;
+
+/**
+ * SmtpEmailTransport is an implementation of an email transport which sends emails via SMTP (RFC 5321, 3207 and 4954).
+ *
+ * @author Tim Duesterhus
+ * @copyright 2001-2015 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package com.woltlab.wcf
+ * @subpackage system.email.transport
+ * @category Community Framework
+ */
+class SmtpEmailTransport implements EmailTransport {
+ /**
+ * SMTP connection
+ * @var \wcf\system\io\RemoteFile
+ */
+ protected $connection = null;
+
+ /**
+ * host of the smtp server to use
+ * @var string
+ */
+ protected $host;
+
+ /**
+ * port to use
+ * @var integer
+ */
+ protected $port;
+
+ /**
+ * username to use for authentication
+ * @var string
+ */
+ protected $username;
+
+ /**
+ * password corresponding to the username
+ * @var string
+ */
+ protected $password;
+
+ /**
+ * STARTTLS encryption level
+ * @var string
+ */
+ protected $starttls;
+
+ /**
+ * last value written to the server
+ * @var string
+ */
+ protected $lastWrite = '';
+
+ /**
+ * ESMTP features advertised by the server
+ * @var array<string>
+ */
+ protected $features = [ ];
+
+ /**
+ * Creates a new SmtpEmailTransport using the given host.
+ *
+ * @param string $host host of the smtp server to use
+ * @param integer $port port to use
+ * @param string $username username to use for authentication
+ * @param string $password corresponding password
+ * @param string $starttls one of 'none', 'may' and 'encrypt'
+ */
+ public function __construct($host = MAIL_SMTP_HOST, $port = MAIL_SMTP_PORT, $username = MAIL_SMTP_USER, $password = MAIL_SMTP_PASSWORD, $starttls = 'may') {
+ $this->host = $host;
+ $this->port = $port;
+ $this->username = $username;
+ $this->password = $password;
+
+ switch ($starttls) {
+ case 'none':
+ case 'may':
+ case 'encrypt':
+ $this->starttls = $starttls;
+ break;
+ default:
+ throw new SystemException("Invalid STARTTLS preference '".$starttls."'. Must be one of 'none', 'may' and 'encrypt'.");
+ }
+ }
+
+ /**
+ * @see \wcf\system\email\transport\SmtpTransport::disconnect()
+ */
+ public function __destruct() {
+ $this->disconnect();
+ }
+
+ /**
+ * Reads a server reply and validates it against the given expected status codes.
+ * Returns a tuple [ status code, reply text ].
+ *
+ * @param array<integer> $expectedCodes
+ * @return array
+ */
+ protected function read(array $expectedCodes) {
+ $code = null;
+ $reply = '';
+ do {
+ $data = $this->connection->gets();
+ if (preg_match('/^(\d{3})([- ])(.*)$/', $data, $matches)) {
+ if ($code === null) {
+ $code = intval($matches[1]);
+
+ if (!in_array($code, $expectedCodes)) {
+ // 4xx is a transient failure
+ if (400 <= $code && $code < 500) {
+ throw new TransientFailure("Remote SMTP server reported transient error code: ".$code." in reply to '".$this->lastWrite."'");
+ }
+
+ // 5xx is a permanent failure
+ if (500 <= $code && $code < 600) {
+ throw new PermanentFailure("Remote SMTP server reported permanent error code: ".$code." in reply to '".$this->lastWrite."'");
+ }
+
+ throw new TransientFailure("Remote SMTP server reported not expected code: ".$code." in reply to '".$this->lastWrite."'");
+ }
+ }
+
+ if ($code == $matches[1]) {
+ $reply .= trim($matches[3])."\r\n";
+
+ // no more continuation lines
+ if ($matches[2] === ' ') break;
+ }
+ else {
+ throw new TransientFailure("Unexpected reply '".$data."' from SMTP server. Code does not match previous codes from multiline answer.");
+ }
+ }
+ else {
+ throw new TransientFailure("Unexpected reply '".$data."' from SMTP server.");
+ }
+ }
+ while (true);
+
+ return [ $code, $reply ];
+ }
+
+ /**
+ * Writes the given line to the server.
+ *
+ * @param string $data
+ */
+ protected function write($data) {
+ $this->lastWrite = $data;
+ $this->connection->write($data."\r\n");
+ }
+
+ /**
+ * Connects to the server and enables STARTTLS if available. Bails
+ * out if STARTTLS is not available and connection is set to 'encrypt'.
+ */
+ protected function connect() {
+ $this->connection = new RemoteFile($this->host, $this->port);
+ $this->read([ 220 ]);
+
+ try {
+ $this->write('EHLO '.Email::getHost());
+ $this->features = array_map('strtolower', explode("\n", StringUtil::unifyNewlines($this->read([ 250 ])[1])));
+ }
+ catch (SystemException $e) {
+ if ($this->starttls == 'encrypt') {
+ throw new PermanentFailure("Remote SMTP server does not support EHLO, but \$starttls is set to 'encrypt'.");
+ }
+
+ $this->write('HELO '.Email::getHost());
+ $this->features = [ ];
+ }
+
+ switch ($this->starttls) {
+ case 'encrypt':
+ if (!in_array('starttls', $this->features)) {
+ throw new PermanentFailure("Remote SMTP server does not advertise STARTTLS, but \$starttls is set to 'encrypt'.");
+ }
+
+ $this->starttls();
+
+ $this->write('EHLO '.Email::getHost());
+ $this->features = array_map('strtolower', explode("\n", StringUtil::unifyNewlines($this->read([ 250 ])[1])));
+ break;
+ case 'may':
+ if (in_array('starttls', $this->features)) {
+ try {
+ $this->starttls();
+ }
+ catch (SystemException $e) { }
+
+ $this->write('EHLO '.Email::getHost());
+ $this->features = array_map('strtolower', explode("\n", StringUtil::unifyNewlines($this->read([ 250 ])[1])));
+ }
+ break;
+ case 'none':
+ // nothing to do here
+ }
+ }
+
+ /**
+ * Enables STARTTLS on the connection.
+ */
+ protected function starttls() {
+ $this->write("STARTTLS");
+ $this->read([ 220 ]);
+
+ if (!$this->connection->setTLS(true)) {
+ throw new TransientFailure('enabling TLS failed');
+ }
+ }
+
+ /**
+ * Performs SASL authentication using the credentials provided in the
+ * constructor. Supported mechanisms are LOGIN and PLAIN.
+ */
+ protected function auth() {
+ if (!$this->username || !$this->password) return;
+
+ foreach ($this->features as $feature) {
+ $parameters = explode(" ", $feature);
+
+ if ($parameters[0] == 'auth') {
+ // try mechanisms in order of preference
+ foreach ([ 'login', 'plain' ] as $method) {
+ try {
+ if (in_array($method, $parameters)) {
+ switch ($method) {
+ case 'login':
+ $this->write('AUTH LOGIN');
+ $this->read([ 334 ]);
+ $this->write(base64_encode($this->username));
+ $this->lastWrite = '*redacted*';
+ $this->read([ 334 ]);
+ $this->write(base64_encode($this->password));
+ $this->lastWrite = '*redacted*';
+ $this->read([ 235 ]);
+ return;
+ break;
+ case 'plain':
+ // RFC 4616
+ $this->write('AUTH PLAIN');
+ $this->read([ 334 ]);
+ $this->write(base64_encode("\0".$this->username."\0".$this->password));
+ $this->lastWrite = '*redacted*';
+ $this->read([ 235 ]);
+ return;
+ }
+ }
+ }
+ catch (SystemException $e) {
+ // try next authentication method
+ }
+ }
+
+ return;
+ }
+ }
+
+ // server does not support auth
+ }
+
+ /**
+ * Cleanly closes the connection to the server.
+ */
+ protected function disconnect() {
+ if ($this->connection) {
+ try {
+ $this->write("QUIT");
+ $this->connection->close();
+ }
+ catch (SystemException $e) {
+ // quit failed, don't care about it
+ }
+ finally {
+ $this->connection = null;
+ }
+ }
+ }
+
+ /**
+ * Delivers the given email using SMTP.
+ *
+ * @param \wcf\system\email\Email $email
+ * @param \wcf\system\email\Mailbox $envelopeTo
+ */
+ public function deliver(Email $email, Mailbox $envelopeTo) {
+ if (!$this->connection || $this->connection->eof()) {
+ $this->connect();
+ $this->auth();
+ }
+ $this->write('RSET');
+ $this->read([ 250 ]);
+ $this->write('MAIL FROM:<'.$email->getSender()->getAddress().'>');
+ $this->read([ 250 ]);
+ $this->write('RCPT TO:<'.$envelopeTo->getAddress().'>');
+ $this->read([ 250, 251 ]);
+ $this->write('DATA');
+ $this->read([ 354 ]);
+ $this->connection->write(implode("\r\n", array_map(function ($item) {
+ // 4.5.2 Transparency
+ // o Before sending a line of mail text, the SMTP client checks the
+ // first character of the line. If it is a period, one additional
+ // period is inserted at the beginning of the line.
+ if (StringUtil::startsWith($item, '.')) return '.'.$item;
+
+ return $item;
+ }, explode("\r\n", $email))));
+ $this->write(".");
+ $this->read([ 250 ]);
+ }
+}