From f8419d80c08e4f61b105c086e709b1a3867c1f9e Mon Sep 17 00:00:00 2001 From: =?utf8?q?Tim=20D=C3=BCsterhus?= Date: Mon, 30 May 2016 18:11:48 +0200 Subject: [PATCH] Streamline handling of mime parts --- .../files/lib/system/email/Email.class.php | 226 ++++-------------- .../email/mime/AbstractMimePart.class.php | 10 +- .../mime/AbstractMultipartMimePart.class.php | 161 +++++++++++++ .../MultipartAlternativeMimePart.class.php | 53 ++++ .../mime/MultipartMixedMimePart.class.php | 37 +++ .../mime/RecipientAwareTextMimePart.class.php | 5 +- .../transport/DebugEmailTransport.class.php | 2 +- .../transport/SmtpEmailTransport.class.php | 2 +- 8 files changed, 310 insertions(+), 186 deletions(-) create mode 100644 wcfsetup/install/files/lib/system/email/mime/AbstractMultipartMimePart.class.php create mode 100644 wcfsetup/install/files/lib/system/email/mime/MultipartAlternativeMimePart.class.php create mode 100644 wcfsetup/install/files/lib/system/email/mime/MultipartMixedMimePart.class.php diff --git a/wcfsetup/install/files/lib/system/email/Email.class.php b/wcfsetup/install/files/lib/system/email/Email.class.php index 4bc1dedf3d..492fe40354 100644 --- a/wcfsetup/install/files/lib/system/email/Email.class.php +++ b/wcfsetup/install/files/lib/system/email/Email.class.php @@ -5,9 +5,7 @@ use wcf\system\background\job\EmailDeliveryBackgroundJob; use wcf\system\background\BackgroundQueueHandler; use wcf\system\email\mime\AbstractMimePart; use wcf\system\email\mime\IRecipientAwareMimePart; -use wcf\system\email\mime\TextMimePart; use wcf\system\event\EventHandler; -use wcf\system\exception\SystemException; use wcf\util\DateUtil; use wcf\util\StringUtil; @@ -78,28 +76,10 @@ class Email { protected $extraHeaders = []; /** - * Text parts of this email - * @var array - */ - protected $text = []; - - /** - * Attachments of this email - * @var array + * The body of this Email. + * @var AbstractMimePart */ - 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; + protected $content = null; /** * Mail host for use in the Message-Id @@ -107,14 +87,6 @@ class Email { */ 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. * @@ -338,7 +310,10 @@ class Email { throw new \DomainException("The given type '".$type."' is invalid. Must be one of 'to', 'cc', 'bcc'."); } - $this->recipients[$recipient->getAddress()] = [$type, $recipient]; + $this->recipients[$recipient->getAddress()] = [ + 'type' => $type, + 'mailbox' => $recipient + ]; } /** @@ -376,60 +351,12 @@ class Email { } /** - * 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 AbstractMimePart $part - * @param integer $priority - * @throws \InvalidArgumentException - * @throws \DomainException - */ - 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 \InvalidArgumentException("The header '".$header[0]."' may not be set. Use the proper methods."); - } - - if (!StringUtil::startsWith($header[0], 'x-') && !StringUtil::startsWith($header[0], 'content-')) { - throw new \DomainException("The header '".$header[0]."' 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 \DomainException("The Content-Transfer-Encoding '".$part->getContentTransferEncoding()."' 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 the text mime parts of this email. - * - * @return array - */ - public function getText() { - return $this->text; - } - - /** - * Returns the attachments (i.e. the mime parts that are not a TextMimePart) of this email. + * Sets the body of this email. * - * @return array + * @param AbstractMimePart $body */ - public function getAttachments() { - return $this->attachments; + public function setBody(AbstractMimePart $body) { + $this->body = $body; } /** @@ -445,8 +372,8 @@ class Email { $to = []; $cc = []; foreach ($this->getRecipients() as $recipient) { - if ($recipient[0] == 'to') $to[] = $recipient[1]; - else if ($recipient[0] == 'cc') $cc[] = $recipient[1]; + if ($recipient['type'] == 'to') $to[] = $recipient['mailbox']; + else if ($recipient['type'] == 'cc') $cc[] = $recipient['mailbox']; } $headers[] = ['from', (string) $this->getSender()]; if ($this->getReplyTo()->getAddress() !== $this->getSender()->getAddress()) { @@ -480,22 +407,14 @@ class Email { } $headers[] = ['mime-version', '1.0']; - if (!$this->text) { - throw new \LogicException("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."\""]; + if (!$this->body) { + throw new \LogicException("Cannot generate message headers, you must set a body."); } - 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()); - } + $headers[] = ['content-type', $this->body->getContentType()]; + if ($this->body->getContentTransferEncoding()) { + $headers[] = ['content-transfer-encoding', $this->body->getContentTransferEncoding()]; } + $headers = array_merge($headers, $this->body->getAdditionalHeaders()); return array_merge($headers, $this->extraHeaders); } @@ -518,80 +437,18 @@ class Email { * @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; + switch ($this->body->getContentTransferEncoding()) { + case 'quoted-printable': + return quoted_printable_encode($this->body->getContent()); + break; + case 'base64': + return chunk_split(base64_encode($this->body->getContent())); + break; + case '': + return $this->body->getContent(); } - return $body; + throw new \LogicException('Unreachable'); } /** @@ -608,21 +465,23 @@ class Email { foreach ($this->recipients as $recipient) { $mail = clone $this; - if ($recipient[1] instanceof UserMailbox) { - $mail->addHeader('X-Community-Framework-Recipient', $recipient[1]->getUser()->username); + if ($recipient['mailbox'] instanceof UserMailbox) { + $mail->addHeader('X-Community-Framework-Recipient', $recipient['mailbox']->getUser()->username); } - foreach (array_merge($mail->getText(), $mail->getAttachments()) as $mimePart) { - if ($mimePart[1] instanceof IRecipientAwareMimePart) $mimePart[1]->setRecipient($recipient[1]); - } + if ($this->body instanceof IRecipientAwareMimePart) $this->body->setRecipient($recipient['mailbox']); - $data = ['mail' => $mail, 'recipient' => $recipient, 'skip' => false]; + $data = [ + 'mail' => $mail, + 'recipient' => $recipient, + 'skip' => false + ]; EventHandler::getInstance()->fireAction($this, 'getJobs', $data); // an event decided that this email should be skipped if ($data['skip']) continue; - $jobs[] = new EmailDeliveryBackgroundJob($mail, $recipient[1]); + $jobs[] = new EmailDeliveryBackgroundJob($mail, $recipient['mailbox']); } return $jobs; @@ -641,12 +500,19 @@ class Email { BackgroundQueueHandler::getInstance()->forceCheck(); } + /** + * @see Email::getEmail() + */ + public function __toString() { + return $this->getEmail(); + } + /** * Returns the email RFC 2822 representation of this email. * * @return string */ - public function __toString() { + public function getEmail() { 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 index 4ff62f0007..330fb8203b 100644 --- a/wcfsetup/install/files/lib/system/email/mime/AbstractMimePart.class.php +++ b/wcfsetup/install/files/lib/system/email/mime/AbstractMimePart.class.php @@ -16,6 +16,8 @@ abstract class AbstractMimePart { /** * Returns the Content-Type header value. * + * This method must be idempotent. + * * @return string */ abstract public function getContentType(); @@ -24,17 +26,21 @@ abstract class AbstractMimePart { * Returns the transfer encoding to use. Must either be * 'quoted-printable' or 'base64'. * + * This method must be idempotent. + * * @return string either 'quoted-printable' or 'base64' */ abstract public function getContentTransferEncoding(); /** - * Extra headers as an array of [ name, value] tuple for this mime part. + * 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. - * + * + * This method must be idempotent. + * * @return array */ public function getAdditionalHeaders() { diff --git a/wcfsetup/install/files/lib/system/email/mime/AbstractMultipartMimePart.class.php b/wcfsetup/install/files/lib/system/email/mime/AbstractMultipartMimePart.class.php new file mode 100644 index 0000000000..9616f9b17e --- /dev/null +++ b/wcfsetup/install/files/lib/system/email/mime/AbstractMultipartMimePart.class.php @@ -0,0 +1,161 @@ + + * @package com.woltlab.wcf + * @subpackage system.email.mime + * @category Community Framework + * @since 2.2 + */ +abstract class AbstractMultipartMimePart extends AbstractMimePart implements IRecipientAwareMimePart { + /** + * The boundary between the distinct parts. + * @var string + */ + protected $boundary; + + /** + * The parts. + * @var \SplObjectStorage + */ + protected $parts; + + /** + * Sets the multipart boundary. + */ + public function __construct() { + $this->boundary = "WoltLab_Community_Framework=_".StringUtil::getRandomID(); + $this->parts = new \SplObjectStorage(); + } + + /** + * @inheritDoc + */ + public function getContentTransferEncoding() { + return ''; + } + + /** + * @inheritDoc + */ + public function setRecipient(Mailbox $mailbox = null) { + foreach ($this->parts as $part) { + if ($part instanceof IRecipientAwareMimePart) { + $part->setRecipient($mailbox); + } + } + } + + /** + * Concatenates the given mime parts. + * + * @param \Traversable $parts + */ + protected function getConcatenatedParts($parts) { + $content = ""; + foreach ($parts as $part) { + $content .= "--".$this->boundary."\r\n"; + $content .= "content-type: ".$part->getContentType()."\r\n"; + if ($part->getContentTransferEncoding()) { + $content .= "content-transfer-encoding: ".$part->getContentTransferEncoding()."\r\n"; + } + + if ($part->getAdditionalHeaders()) { + $content .= implode("\r\n", array_map(function ($item) { + return implode(': ', $item); + }, $part->getAdditionalHeaders()))."\r\n"; + } + $content .= "\r\n"; + switch ($part->getContentTransferEncoding()) { + case 'quoted-printable': + $content .= quoted_printable_encode($part->getContent()); + break; + case 'base64': + $content .= chunk_split(base64_encode($part->getContent())); + break; + case '': + $content .= $part->getContent(); + break; + default: + throw new \LogicException('Unreachable'); + } + $content .= "\r\n"; + } + $content .= "--".$this->boundary."--\r\n"; + + return $content; + } + + /** + * @inheritDoc + */ + public function getContent() { + $content = ""; + $content .= StringUtil::wordwrap("This is a MIME encoded email. As you are seeing this your user agent does not support these."); + $content .= "\r\n\r\n"; + + $content .= $this->getConcatenatedParts($this->parts); + + return $content; + } + + /** + * Adds a mime part to this email. Should be either \wcf\system\email\mime\TextMimePart + * or \wcf\system\email\mime\AttachmentMimePart. + * + * @param AbstractMimePart $part + * @param mixed $data Additional data, to be defined by child classes + * @throws \InvalidArgumentException + * @throws \DomainException + */ + public function addMimePart(AbstractMimePart $part, $data = null) { + foreach ($part->getAdditionalHeaders() as $header) { + $header[0] = mb_strtolower($header[0]); + if ($header[0] == 'content-type' || $header[0] == 'content-transfer-encoding') { + throw new \InvalidArgumentException("The header '".$header[0]."' may not be set. Use the proper methods."); + } + + if (!StringUtil::startsWith($header[0], 'x-') && !StringUtil::startsWith($header[0], 'content-')) { + throw new \DomainException("The header '".$header[0]."' may not be set. You may only set headers starting with 'X-' or 'Content-'."); + } + } + + switch ($part->getContentTransferEncoding()) { + case 'base64': + case 'quoted-printable': + case '': + break; + default: + throw new \DomainException("The Content-Transfer-Encoding '".$part->getContentTransferEncoding()."' may not be set. You may only use 'quoted-printable' or 'base64' or ''."); + } + + $this->parts[$part] = $data; + } + + /** + * Removes a mime part from this multipart part. + * + * @param AbstractMimePart $part + */ + public function removeMimePart(AbstractMimePart $part) { + $this->parts->detach($part); + } + + /** + * Returns the stored mime parts of this multipart part. + * Note: The returned \SplObjectStorage is a clone of the internal one. + * Modifications will not reflect on this object. + * + * @return \SplObjectStorage + */ + public function getMimeParts() { + return clone $this->parts; + } +} diff --git a/wcfsetup/install/files/lib/system/email/mime/MultipartAlternativeMimePart.class.php b/wcfsetup/install/files/lib/system/email/mime/MultipartAlternativeMimePart.class.php new file mode 100644 index 0000000000..8680d93e77 --- /dev/null +++ b/wcfsetup/install/files/lib/system/email/mime/MultipartAlternativeMimePart.class.php @@ -0,0 +1,53 @@ + + * @package com.woltlab.wcf + * @subpackage system.email.mime + * @category Community Framework + * @since 2.2 + */ +class MultipartAlternativeMimePart extends AbstractMultipartMimePart { + /** + * @inheritDoc + */ + public function getContentType() { + return "multipart/alternative;\r\n boundary=\"".$this->boundary."\""; + } + + /** + * @inheritDoc + */ + protected function getConcatenatedParts($parts) { + $sortedParts = new \SplPriorityQueue(); + + $parts->rewind(); + while ($parts->valid()) { + $sortedParts->insert($parts->current(), PHP_INT_MAX - $parts->getInfo()); + $parts->next(); + } + + return parent::getConcatenatedParts($sortedParts); + } + + /** + * Adds a mime part to this multipart container. + * + * 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 AbstractMimePart $part + * @param integer $data The priority. + * @throws \InvalidArgumentException + * @throws \DomainException + */ + public function addMimePart(AbstractMimePart $part, $data = 1000) { + return parent::addMimePart($part, $data); + } +} diff --git a/wcfsetup/install/files/lib/system/email/mime/MultipartMixedMimePart.class.php b/wcfsetup/install/files/lib/system/email/mime/MultipartMixedMimePart.class.php new file mode 100644 index 0000000000..67c94e14b8 --- /dev/null +++ b/wcfsetup/install/files/lib/system/email/mime/MultipartMixedMimePart.class.php @@ -0,0 +1,37 @@ + + * @package com.woltlab.wcf + * @subpackage system.email.mime + * @category Community Framework + * @since 2.2 + */ +class MultipartMixedMimePart extends AbstractMultipartMimePart { + /** + * @inheritDoc + */ + public function getContentType() { + return "multipart/mixed;\r\n boundary=\"".$this->boundary."\""; + } + + /** + * Adds a mime part to this multipart container. + * + * The given $data is ignored. + * + * @param AbstractMimePart $part + * @param mixed $data Ignored. + * @throws \InvalidArgumentException + * @throws \DomainException + */ + public function addMimePart(AbstractMimePart $part, $data = null) { + return parent::addMimePart($part, $data); + } +} diff --git a/wcfsetup/install/files/lib/system/email/mime/RecipientAwareTextMimePart.class.php b/wcfsetup/install/files/lib/system/email/mime/RecipientAwareTextMimePart.class.php index d0b831394c..31076ce171 100644 --- a/wcfsetup/install/files/lib/system/email/mime/RecipientAwareTextMimePart.class.php +++ b/wcfsetup/install/files/lib/system/email/mime/RecipientAwareTextMimePart.class.php @@ -1,6 +1,7 @@ mailbox->getLanguage()->languageID); + if ($this->mailbox) WCF::setLanguage($this->mailbox->getLanguage()->languageID); - return WCF::getTPL()->fetch($this->template, $this->application, [ + return EmailTemplateEngine::getInstance()->fetch($this->template, $this->application, [ 'content' => $this->content, 'mimeType' => $this->mimeType, 'mailbox' => $this->mailbox diff --git a/wcfsetup/install/files/lib/system/email/transport/DebugEmailTransport.class.php b/wcfsetup/install/files/lib/system/email/transport/DebugEmailTransport.class.php index c46342a2ba..fd19a4b980 100644 --- a/wcfsetup/install/files/lib/system/email/transport/DebugEmailTransport.class.php +++ b/wcfsetup/install/files/lib/system/email/transport/DebugEmailTransport.class.php @@ -44,7 +44,7 @@ class DebugEmailTransport implements EmailTransport { public function deliver(Email $email, Mailbox $envelopeTo) { $this->mbox->write("From ".$email->getSender()->getAddress()." ".DateUtil::getDateTimeByTimestamp(TIME_NOW)->format('D M d H:i:s Y')."\r\n"); $this->mbox->write("Delivered-To: ".$envelopeTo->getAddress()."\r\n"); - $this->mbox->write($email); + $this->mbox->write($email->getEmail()); $this->mbox->write("\r\n"); } } diff --git a/wcfsetup/install/files/lib/system/email/transport/SmtpEmailTransport.class.php b/wcfsetup/install/files/lib/system/email/transport/SmtpEmailTransport.class.php index fcdb9efe17..87dff5a035 100644 --- a/wcfsetup/install/files/lib/system/email/transport/SmtpEmailTransport.class.php +++ b/wcfsetup/install/files/lib/system/email/transport/SmtpEmailTransport.class.php @@ -359,7 +359,7 @@ class SmtpEmailTransport implements EmailTransport { if (StringUtil::startsWith($item, '.')) return '.'.$item; return $item; - }, explode("\r\n", $email)))); + }, explode("\r\n", $email->getEmail())))); $this->write("."); $this->read([250]); } -- 2.20.1