Add SmtpEmailTransport
authorTim Düsterhus <duesterhus@woltlab.com>
Fri, 19 Jun 2015 16:45:31 +0000 (18:45 +0200)
committerTim Düsterhus <duesterhus@woltlab.com>
Tue, 23 Jun 2015 22:28:56 +0000 (00:28 +0200)
wcfsetup/install/files/lib/system/background/job/EmailDeliveryBackgroundJob.class.php
wcfsetup/install/files/lib/system/email/transport/SmtpEmailTransport.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/email/transport/exception/PermanentFailure.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/email/transport/exception/TransientFailure.class.php [new file with mode: 0644]

index 93d52a9c6da863c19ecf82e3008ab1d444696b10..eb54fb4e513f9417cedb4db74d285983d4e9f7ba 100644 (file)
@@ -1,5 +1,6 @@
 <?php
 namespace wcf\system\background\job;
+use wcf\system\email\transport\exception\PermanentFailure;
 use wcf\system\email\Email;
 use wcf\system\email\Mailbox;
 
@@ -69,6 +70,12 @@ class EmailDeliveryBackgroundJob extends AbstractBackgroundJob {
                        self::$transport = new $name();
                }
                
-               self::$transport->deliver($this->email, $this->mailbox);
+               try {
+                       self::$transport->deliver($this->email, $this->mailbox);
+               }
+               catch (PermanentFailure $e) {
+                       // no need for retrying. Eat Exception and log the error.
+                       $e->getExceptionID();
+               }
        }
 }
diff --git a/wcfsetup/install/files/lib/system/email/transport/SmtpEmailTransport.class.php b/wcfsetup/install/files/lib/system/email/transport/SmtpEmailTransport.class.php
new file mode 100644 (file)
index 0000000..d47da40
--- /dev/null
@@ -0,0 +1,322 @@
+<?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 ]);
+       }
+}
diff --git a/wcfsetup/install/files/lib/system/email/transport/exception/PermanentFailure.class.php b/wcfsetup/install/files/lib/system/email/transport/exception/PermanentFailure.class.php
new file mode 100644 (file)
index 0000000..1f22eea
--- /dev/null
@@ -0,0 +1,17 @@
+<?php
+namespace wcf\system\email\transport\exception;
+use wcf\system\exception\SystemException;
+
+/**
+ * Denotes a permanent failure during delivery. It should not be retried later.
+ * 
+ * @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.exception
+ * @category   Community Framework
+ */
+class PermanentFailure extends SystemException {
+       
+}
diff --git a/wcfsetup/install/files/lib/system/email/transport/exception/TransientFailure.class.php b/wcfsetup/install/files/lib/system/email/transport/exception/TransientFailure.class.php
new file mode 100644 (file)
index 0000000..d619173
--- /dev/null
@@ -0,0 +1,17 @@
+<?php
+namespace wcf\system\email\transport\exception;
+use wcf\system\exception\SystemException;
+
+/**
+ * Denotes a transient failure during delivery. It may be retried later.
+ * 
+ * @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.exception
+ * @category   Community Framework
+ */
+class TransientFailure extends SystemException {
+       
+}