Add support for email body
authorTim Düsterhus <duesterhus@woltlab.com>
Mon, 15 Jun 2015 15:06:42 +0000 (17:06 +0200)
committerTim Düsterhus <duesterhus@woltlab.com>
Tue, 23 Jun 2015 22:28:24 +0000 (00:28 +0200)
wcfsetup/install/files/lib/system/email/Email.class.php
wcfsetup/install/files/lib/system/email/mime/AbstractMimePart.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/email/mime/AttachmentMimePart.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/email/mime/TextMimePart.class.php [new file with mode: 0644]

index fe2e74d7a625c986fe6a77a449177d70a15735dd..b79e440816f96871834164c1d3ecc6bb5b0b9516 100644 (file)
@@ -1,5 +1,7 @@
 <?php
 namespace wcf\system\email;
+use wcf\system\email\mime\AbstractMimePart;
+use wcf\system\email\mime\TextMimePart;
 use wcf\system\exception\SystemException;
 use wcf\util\DateUtil;
 use wcf\util\StringUtil;
@@ -69,12 +71,44 @@ class Email {
         */
        protected $extraHeaders = [ ];
        
+       /**
+        * Text parts of this email
+        * @var array
+        */
+       protected $text = [ ];
+       
+       /**
+        * Attachments of this email
+        * @var array
+        */
+       protected $attachments = [ ];
+       
+       /**
+        * Boundary between the 'Text' parts of this email
+        * @var string
+        */
+       private $textBoundary;
+       
+       /**
+        * Boundary between the mime parts of this email
+        * @var string
+        */
+       private $mimeBoundary;
+       
        /**
         * Mail host for use in the Message-Id
         * @var string
         */
        private static $host = null;
        
+       /**
+        * Generates boundaries for the mime parts.
+        */
+       public function __construct() {
+               $this->textBoundary = "WoltLab_Community_Framework=_".StringUtil::getRandomID();
+               $this->mimeBoundary = "WoltLab_Community_Framework=_".StringUtil::getRandomID();
+       }
+       
        /**
         * Returns the mail host for use in the Message-Id.
         * 
@@ -330,6 +364,43 @@ class Email {
                $this->extraHeaders[] = [ $header, EmailGrammar::encodeMimeHeader($value) ];
        }
        
+       /**
+        * Adds a mime part to this email. Should be either \wcf\system\email\mime\TextMimePart
+        * or \wcf\system\email\mime\AttachmentMimePart.
+        * The given priority determines the ordering within the Email. A higher priority
+        * mime part will be further down the email (see RFC 2046, 5.1.4).
+        * 
+        * @param       \wcf\system\email\mime\AbstractMimePart $part
+        * @param       integer                                 $priority
+        */
+       public function addMimePart(AbstractMimePart $part, $priority = 1000) {
+               foreach ($part->getAdditionalHeaders() as $header) {
+                       $header[0] = mb_strtolower($header[0]);
+                       if ($header[0] == 'content-type' || $header[0] == 'content-transfer-encoding') {
+                               throw new SystemException("The header '".$header."' may not be set. Use the proper methods.");
+                       }
+                       
+                       if (!StringUtil::startsWith($header[0], 'x-') && !StringUtil::startsWith($header[0], 'content-')) {
+                               throw new SystemException("The header '".$header."' may not be set. You may only set headers starting with 'X-' or 'Content-'.");
+                       }
+               }
+               
+               switch ($part->getContentTransferEncoding()) {
+                       case 'base64':
+                       case 'quoted-printable':
+                       break;
+                       default:
+                               throw new SystemException("The Content-Transfer-Encoding '".$header."' may not be set. You may only use 'quoted-printable' or 'base64'.");
+               }
+               
+               if ($part instanceof TextMimePart) {
+                       $this->text[] = [ $priority, $part ];
+               }
+               else {
+                       $this->attachments[] = [ $priority, $part ];
+               }
+       }
+       
        /**
         * Returns an array of [ name, value ] tuples representing the email's headers.
         * Note: You must have set a Subject and at least one recipient, otherwise fetching the
@@ -361,7 +432,7 @@ class Email {
                        $headers[] = [ 'cc', implode(",\r\n   ", $cc) ];
                }
                if ($this->getSubject()) {
-                       $headers[] = [ 'subject', EmailGrammar::encodeMimeHeader($this->getSubject()) ];
+                       $headers[] = [ 'subject', EmailGrammar::encodeQuotedPrintableHeader($this->getSubject()) ];
                }
                else {
                        throw new SystemException("Cannot generate message headers, you must specify a subject.");
@@ -377,6 +448,23 @@ class Email {
                }
                $headers[] = [ 'mime-version', '1.0' ];
                
+               if (!$this->text) {
+                       throw new SystemException("Cannot generate message headers, you must specify at least one 'Text' part.");
+               }
+               if ($this->attachments) {
+                       $headers[] = [ 'content-type', "multipart/mixed;\r\n   boundary=\"".$this->mimeBoundary."\"" ];
+               }
+               else {
+                       if (count($this->text) > 1) {
+                               $headers[] = [ 'content-type', "multipart/alternative;\r\n   boundary=\"".$this->textBoundary."\"" ];
+                       }
+                       else {
+                               $headers[] = [ 'content-type', $this->text[0][1]->getContentType() ];
+                               $headers[] = [ 'content-transfer-encoding', $this->text[0][1]->getContentTransferEncoding() ];
+                               $headers = array_merge($headers, $this->text[0][1]->getAdditionalHeaders());
+                       }
+               }
+               
                return array_merge($headers, $this->extraHeaders);
        }
        
@@ -391,4 +479,95 @@ class Email {
                        return implode(': ', $item);
                }, $this->getHeaders()));
        }
+       
+       /**
+        * Returns the email's body as a string.
+        * 
+        * @return      string
+        */
+       public function getBodyString() {
+               $text = "";
+               $body = "";
+               
+               if (count($this->text) > 1 || $this->attachments) {
+                       $body .= StringUtil::wordWrap("This is a MIME encoded email. As you are seeing this your user agent does not support these.");
+                       $body .= "\r\n\r\n";
+               }
+               
+               usort($this->text, function ($a, $b) {
+                       return $a[0] - $b[0];
+               });
+               foreach ($this->text as $part) {
+                       if (count($this->text) > 1) {
+                               $text .= "--".$this->textBoundary."\r\n";
+                       }
+                       if (count($this->text) > 1 || $this->attachments) {
+                               $text .= "content-type: ".$part[1]->getContentType()."\r\n";
+                               $text .= "content-transfer-encoding: ".$part[1]->getContentTransferEncoding()."\r\n";
+                               if ($part[1]->getAdditionalHeaders()) {
+                                       $text .= implode("\r\n", array_map(function ($item) {
+                                               return implode(': ', $item);
+                                       }, $part[1]->getAdditionalHeaders()))."\r\n";
+                               }
+                               $text .= "\r\n";
+                       }
+                       switch ($part[1]->getContentTransferEncoding()) {
+                               case 'quoted-printable':
+                                       $text .= quoted_printable_encode($part[1]->getContent());
+                               break;
+                               case 'base64':
+                                       $text .= chunk_split(base64_encode($part[1]->getContent()));
+                               break;
+                       }
+                       $text .= "\r\n";
+               }
+               if (count($this->text) > 1) {
+                       $text .= "--".$this->textBoundary."--\r\n";
+               }
+               
+               if ($this->attachments) {
+                       $body .= "--".$this->mimeBoundary."\r\n";
+                       if (count($this->text) > 1) {
+                               $body .= "Content-Type: multipart/alternative;\r\n   boundary=\"".$this->textBoundary."\"\r\n";
+                               $body .= "\r\n";
+                       }
+                       $body .= $text;
+                       
+                       foreach ($this->attachments as $part) {
+                               $body .= "\r\n--".$this->mimeBoundary."\r\n";
+                               $body .= "content-type: ".$part[1]->getContentType()."\r\n";
+                               $body .= "content-transfer-encoding: ".$part[1]->getContentTransferEncoding()."\r\n";
+                               if ($part[1]->getAdditionalHeaders()) {
+                                       $body .= implode("\r\n", array_map(function ($item) {
+                                               return implode(': ', $item);
+                                       }, $part[1]->getAdditionalHeaders()))."\r\n";
+                               }
+                               $body .= "\r\n";
+                               switch ($part[1]->getContentTransferEncoding()) {
+                                       case 'quoted-printable':
+                                               $body .= quoted_printable_encode($part[1]->getContent());
+                                       break;
+                                       case 'base64':
+                                               $body .= chunk_split(base64_encode($part[1]->getContent()));
+                                       break;
+                               }
+                               $body .= "\r\n";
+                       }
+                       $body .= "--".$this->mimeBoundary."--\r\n";
+               }
+               else {
+                       $body .= $text;
+               }
+               
+               return $body;
+       }
+       
+       /**
+        * Returns the email RFC 2822 representation of this email.
+        * 
+        * @return      string
+        */
+       public function __toString() {
+               return $this->getHeaderString()."\r\n\r\n".$this->getBodyString();
+       }
 }
diff --git a/wcfsetup/install/files/lib/system/email/mime/AbstractMimePart.class.php b/wcfsetup/install/files/lib/system/email/mime/AbstractMimePart.class.php
new file mode 100644 (file)
index 0000000..18851a7
--- /dev/null
@@ -0,0 +1,49 @@
+<?php
+namespace wcf\system\email\mime;
+
+/**
+ * Represents a RFC 2045 / 2046 mime part of an email.
+ * 
+ * @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.mime
+ * @category   Community Framework
+ */
+abstract class AbstractMimePart {
+       /**
+        * Returns the Content-Type header value.
+        * 
+        * @return      string
+        */
+       abstract public function getContentType();
+       
+       /**
+        * Returns the transfer encoding to use. Must either be
+        * 'quoted-printable' or 'base64'.
+        * 
+        * @return      Either 'quoted-printable' or 'base64'
+        */
+       abstract public function getContentTransferEncoding();
+       
+       /**
+        * Extra headers as an array of [ name, value] tuple for this mime part.
+        * As per RFC 2046 they may only start with X-* or Content-*. Content-Type
+        * and Content-Transfer-Encoding are blacklisted.
+        * 
+        * Returns an empty array by default.
+        *
+        * @return      array
+        */
+       public function getAdditionalHeaders() {
+               return [ ];
+       }
+       
+       /**
+        * The body of this mime part.
+        * 
+        * @return      string
+        */
+       abstract public function getContent();
+}
diff --git a/wcfsetup/install/files/lib/system/email/mime/AttachmentMimePart.class.php b/wcfsetup/install/files/lib/system/email/mime/AttachmentMimePart.class.php
new file mode 100644 (file)
index 0000000..ee1a169
--- /dev/null
@@ -0,0 +1,83 @@
+<?php
+namespace wcf\system\email\mime;
+use wcf\system\email\EmailGrammar;
+use wcf\util\FileUtil;
+
+/**
+ * Represents an email attachment.
+ * 
+ * @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.mime
+ * @category   Community Framework
+ */
+class AttachmentMimePart extends AbstractMimePart {
+       /**
+        * the path the attachment is read from
+        * @var string
+        */
+       protected $path;
+       
+       /**
+        * the filename to provide in the email
+        * @var string
+        */
+       protected $filename;
+       
+       /**
+        * the mime type to provide in the email
+        * @var string
+        */
+       protected $mimeType;
+       
+       /**
+        * Creates a new Attachment.
+        * 
+        * @param       string  $path           Path to read the file from.
+        * @param       string  $filename       Filename to provide in the email or null to use the $path's basename.
+        * @param       string  $mimeType       Mime type to provide in the email or null to guess the mime type.
+        */
+       public function __construct($path, $filename = null, $mimeType = null) {
+               if (!is_file($path) || !is_readable($path)) {
+                       throw new SystemException("Cannot attach file '".$path."'. It either does not exist or is not readable.");
+               }
+               
+               $this->mimeType = $mimeType ?: (FileUtil::getMimeType($path) ?: 'application/octet-stream');
+               $this->path = $path;
+               $this->filename = $filename ?: basename($path);
+       }
+       
+       /**
+        * @see \wcf\system\email\mime\AbstractMimePart::getContentType()
+        */
+       public function getContentType() {
+               return $this->mimeType;
+       }
+       
+       /**
+        * @see \wcf\system\email\mime\AbstractMimePart::getContentTransferEncoding()
+        */
+       public function getContentTransferEncoding() {
+               return 'base64';
+       }
+       
+       /**
+        * Adds the Content-Disposition header.
+        * 
+        * @see \wcf\system\email\mime\AbstractMimePart::getAdditionalHeaders()
+        */
+       public function getAdditionalHeaders() {
+               return [ 
+                       [ 'Content-Disposition', 'attachment; filename='.EmailGrammar::encodeHeader($this->filename) ]
+               ];
+       }
+       
+       /**
+        * @see \wcf\system\email\mime\AbstractMimePart::getContent()
+        */
+       public function getContent() {
+               return file_get_contents($this->path);
+       }
+}
diff --git a/wcfsetup/install/files/lib/system/email/mime/TextMimePart.class.php b/wcfsetup/install/files/lib/system/email/mime/TextMimePart.class.php
new file mode 100644 (file)
index 0000000..30965ac
--- /dev/null
@@ -0,0 +1,59 @@
+<?php
+namespace wcf\system\email\mime;
+
+/**
+ * Represents the visible text of an email.
+ * The content type usually is either text/plain or text/html.
+ * 
+ * @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.mime
+ * @category   Community Framework
+ */
+class TextMimePart extends AbstractMimePart {
+       /**
+        * the content of this text part
+        * @var string
+        */
+       protected $content;
+       
+       /**
+        * the mime type to provide in the email
+        * @var string
+        */
+       protected $mimeType;
+       
+       /**
+        * Creates a new Text.
+        * 
+        * @param       string  $content        Content of this text part.
+        * @param       string  $mimeType       Mime type to provide in the email. You *must* not provide a charset. UTF-8 will be used automatically.
+        */
+       public function __construct($content, $mimeType) {
+               $this->mimeType = $mimeType;
+               $this->content = $content;
+       }
+       
+       /**
+        * @see \wcf\system\email\mime\AbstractMimePart::getContentType()
+        */
+       public function getContentType() {
+               return $this->mimeType."; charset=UTF-8";
+       }
+       
+       /**
+        * @see \wcf\system\email\mime\AbstractMimePart::getContentTransferEncoding()
+        */
+       public function getContentTransferEncoding() {
+               return 'quoted-printable';
+       }
+       
+       /**
+        * @see \wcf\system\email\mime\AbstractMimePart::getContent()
+        */
+       public function getContent() {
+               return $this->content;
+       }
+}