2 namespace wcf\system\email
;
3 use wcf\system\application\ApplicationHandler
;
4 use wcf\system\background\job\AbstractBackgroundJob
;
5 use wcf\system\background\job\EmailDeliveryBackgroundJob
;
6 use wcf\system\background\BackgroundQueueHandler
;
7 use wcf\system\email\mime\AbstractMimePart
;
8 use wcf\system\email\mime\IRecipientAwareMimePart
;
9 use wcf\system\event\EventHandler
;
10 use wcf\util\DateUtil
;
11 use wcf\util\HeaderUtil
;
12 use wcf\util\StringUtil
;
15 * Represents a RFC 5322 message using the Mime format as defined in RFC 2045.
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
28 protected $sender = null;
34 protected $replyTo = null;
37 * Recipients of this email.
40 protected $recipients = [];
46 protected $messageID = null;
52 protected $references = [];
58 protected $inReplyTo = [];
64 protected $date = null;
70 protected $subject = '';
73 * User specified X-* headers
76 protected $extraHeaders = [];
79 * The body of this Email.
80 * @var AbstractMimePart
82 protected $body = null;
85 * Mail host for use in the Message-Id
88 private static $host = null;
91 * Returns the mail host for use in the Message-Id.
95 public static function getHost() {
96 if (self
::$host === null) {
97 self
::$host = ApplicationHandler
::getInstance()->getApplication('wcf')->domainName
;
104 * Sets the email's 'Subject'.
106 * @param string $subject
108 public function setSubject($subject) {
109 $this->subject
= $subject;
113 * Returns the email's 'Subject'.
117 public function getSubject() {
118 return $this->subject
;
122 * Sets the email's 'Date'.
124 * @param \DateTime $date
126 public function setDate(\DateTime
$date = null) {
131 * Returns the email's 'Date'.
132 * If no header is set yet the current time will automatically be set.
136 public function getDate() {
137 if ($this->date
=== null) {
138 $this->date
= DateUtil
::getDateTimeByTimestamp(TIME_NOW
);
145 * Sets the part left of the at sign (@) in the email's 'Message-Id'.
147 * @param string $messageID
148 * @throws \DomainException
150 public function setMessageID($messageID = null) {
151 if ($messageID === null) {
152 $this->messageID
= null;
156 if (!preg_match('(^'.EmailGrammar
::getGrammar('id-left').'$)', $messageID)) {
157 throw new \
DomainException("The given message id '".$messageID."' is invalid. Note: You must not specify the part right of the at sign (@).");
159 if (strlen($messageID) > 200) {
160 throw new \
DomainException("The given message id '".$messageID."' is not allowed. The maximum allowed length is 200 bytes.");
163 $this->messageID
= $messageID;
167 * Returns the email's full 'Message-Id' including the host.
168 * If no header is set yet a message id will automatically be generated and set.
172 public function getMessageID() {
173 if ($this->messageID
=== null) {
174 $this->messageID
= bin2hex(\random_bytes
(20));
177 return '<'.$this->messageID
.'@'.self
::getHost().'>';
181 * Adds a message id to the email's 'In-Reply-To'.
183 * @param string $messageID
184 * @throws \DomainException
186 public function addInReplyTo($messageID) {
187 if (!preg_match('(^'.EmailGrammar
::getGrammar('msg-id').'$)', $messageID)) {
188 throw new \
DomainException("The given reference '".$messageID."' is invalid.");
191 $this->inReplyTo
[$messageID] = $messageID;
195 * Removes a message id from the email's 'In-Reply-To'.
197 * @param string $messageID
199 public function removeInReplyTo($messageID) {
200 unset($this->inReplyTo
[$messageID]);
204 * Returns the email's 'In-Reply-To' message ids.
208 public function getInReplyTo() {
209 return $this->inReplyTo
;
213 * Adds a message id to the email's 'References'.
215 * @param string $messageID
216 * @throws \DomainException
218 public function addReferences($messageID) {
219 if (!preg_match('(^'.EmailGrammar
::getGrammar('msg-id').'$)', $messageID)) {
220 throw new \
DomainException("The given reference '".$messageID."' is invalid.");
223 $this->references
[$messageID] = $messageID;
227 * Removes a message id from the email's 'References'.
229 * @param string $messageID
231 public function removeReferences($messageID) {
232 unset($this->references
[$messageID]);
236 * Returns the email's 'References' message ids.
240 public function getReferences() {
241 return $this->references
;
245 * Sets the email's 'From'.
247 * @param Mailbox $sender
249 public function setSender(Mailbox
$sender = null) {
250 $this->sender
= $sender;
254 * Returns the email's 'From'.
255 * If no header is set yet the MAIL_FROM_ADDRESS will automatically be set.
259 public function getSender() {
260 if ($this->sender
=== null) {
261 $this->sender
= new Mailbox(MAIL_FROM_ADDRESS
, MAIL_FROM_NAME
);
264 return $this->sender
;
268 * Sets the email's 'Reply-To'.
270 * @param Mailbox $replyTo
272 public function setReplyTo(Mailbox
$replyTo = null) {
273 $this->replyTo
= $replyTo;
277 * Returns the email's 'Reply-To'.
278 * If no header is set yet the MAIL_ADMIN_ADDRESS will automatically be set.
282 public function getReplyTo() {
283 if ($this->replyTo
=== null) {
284 $this->replyTo
= new Mailbox(MAIL_ADMIN_ADDRESS
);
287 return $this->replyTo
;
291 * Adds a recipient to this email.
293 * @param Mailbox $recipient
294 * @param string $type One of 'to', 'cc', 'bcc'
295 * @throws \DomainException
297 public function addRecipient(Mailbox
$recipient, $type = 'to') {
304 throw new \
DomainException("The given type '".$type."' is invalid. Must be one of 'to', 'cc', 'bcc'.");
307 if (isset($this->recipients
[$recipient->getAddress()])) {
308 throw new \
UnexpectedValueException("There already is a recipient with the email address '".$recipient->getAddress()."'. If you want to change the \$type use removeRecipient() first.");
311 $this->recipients
[$recipient->getAddress()] = [
313 'mailbox' => $recipient
318 * Removes a recipient from this email.
320 * @param Mailbox $recipient
322 public function removeRecipient(Mailbox
$recipient) {
323 unset($this->recipients
[$recipient->getAddress()]);
327 * Returns the email's recipients as an array of [ $recipient, $type ] tuples.
331 public function getRecipients() {
332 return $this->recipients
;
336 * Adds a custom X-* header to the email.
338 * @param string $header
339 * @param string $value
340 * @throws \DomainException
342 public function addHeader($header, $value) {
343 $header = mb_strtolower($header);
344 if (!StringUtil
::startsWith($header, 'x-')) {
345 throw new \
DomainException("The header '".$header."' may not be set. You may only set user defined headers (starting with 'X-').");
348 $this->extraHeaders
[] = [$header, EmailGrammar
::encodeQuotedPrintableHeader($value)];
352 * Returns an array of [ name, value ] tuples representing the email's headers.
353 * Note: You must have set a Subject and at least one recipient, otherwise fetching the
357 * @throws \LogicException
359 public function getHeaders() {
363 foreach ($this->getRecipients() as $recipient) {
364 if ($recipient['type'] == 'to') $to[] = $recipient['mailbox'];
365 else if ($recipient['type'] == 'cc') $cc[] = $recipient['mailbox'];
367 $headers[] = ['from', (string) $this->getSender()];
368 if ($this->getReplyTo()->getAddress() !== $this->getSender()->getAddress()) {
369 $headers[] = ['reply-to', (string) $this->getReplyTo()];
373 $headers[] = ['to', implode(",\r\n ", $to)];
376 throw new \
LogicException("Cannot generate message headers, you must specify a recipient.");
380 $headers[] = ['cc', implode(",\r\n ", $cc)];
382 if ($this->getSubject()) {
383 $headers[] = ['subject', EmailGrammar
::encodeQuotedPrintableHeader($this->getSubject())];
386 throw new \
LogicException("Cannot generate message headers, you must specify a subject.");
389 $headers[] = ['date', $this->getDate()->format(\DateTime
::RFC2822
)];
390 $headers[] = ['message-id', $this->getMessageID()];
391 if ($this->getReferences()) {
392 $headers[] = ['references', implode("\r\n ", $this->getReferences())];
394 if ($this->getInReplyTo()) {
395 $headers[] = ['in-reply-to', implode("\r\n ", $this->getInReplyTo())];
397 $headers[] = ['mime-version', '1.0'];
400 throw new \
LogicException("Cannot generate message headers, you must set a body.");
402 $headers[] = ['content-type', $this->body
->getContentType()];
403 if ($this->body
->getContentTransferEncoding()) {
404 $headers[] = ['content-transfer-encoding', $this->body
->getContentTransferEncoding()];
406 $headers = array_merge($headers, $this->body
->getAdditionalHeaders());
408 return array_merge($headers, $this->extraHeaders
);
412 * Returns the email's headers as a string.
413 * Note: This method attempts to convert the header name to the "canonical"
414 * case of the header (e.g. upper case at the start and after the hyphen).
416 * @see \wcf\system\email\Email::getHeaders()
420 public function getHeaderString() {
421 return implode("\r\n", array_map(function ($item) {
422 list($name, $value) = $item;
426 $name = 'Message-ID';
429 $name = 'MIME-Version';
432 $name = preg_replace_callback('/(?:^|-)[a-z]/', function ($matches) {
433 return mb_strtoupper($matches[0]);
437 return $name.': '.$value;
438 }, $this->getHeaders()));
442 * Sets the body of this email.
444 * @param AbstractMimePart $body
446 public function setBody(AbstractMimePart
$body) {
451 * Returns the body of this email.
453 * @return AbstractMimePart
455 public function getBody() {
460 * Returns the email's body as a string.
464 public function getBodyString() {
465 if ($this->body
=== null) {
466 throw new \
LogicException('Cannot generate message body, you must specify a body');
469 switch ($this->body
->getContentTransferEncoding()) {
470 case 'quoted-printable':
471 return quoted_printable_encode(str_replace("\n", "\r\n", StringUtil
::unifyNewlines($this->body
->getContent())));
474 return chunk_split(base64_encode($this->body
->getContent()));
477 return $this->body
->getContent();
480 throw new \
LogicException('Unreachable');
484 * Returns needed AbstractBackgroundJobs to deliver this email to every recipient.
486 * @return AbstractBackgroundJob[]
488 public function getJobs() {
491 // ensure every header is filled in
494 // ensure the body is filled in
495 if ($this->body
=== null) {
496 throw new \
LogicException('Cannot generate message body, you must specify a body');
499 foreach ($this->recipients
as $recipient) {
502 if ($recipient['mailbox'] instanceof UserMailbox
) {
503 $mail->addHeader('X-WoltLab-Suite-Recipient', $recipient['mailbox']->getUser()->username
);
506 if ($this->body
instanceof IRecipientAwareMimePart
) $this->body
->setRecipient($recipient['mailbox']);
510 'recipient' => $recipient,
511 'sender' => $mail->getSender(),
514 EventHandler
::getInstance()->fireAction($this, 'getJobs', $data);
516 // an event decided that this email should be skipped
517 if ($data['skip']) continue;
519 $jobs[] = new EmailDeliveryBackgroundJob($mail, $data['sender'], $data['recipient']['mailbox']);
526 * Queues this email for delivery.
527 * This is equivalent to manually queuing the jobs returned by getJobs().
529 * @see \wcf\system\email\Email::getJobs()
530 * @see \wcf\system\background\BackgroundQueueHandler::enqueueIn()
532 public function send() {
533 $jobs = $this->getJobs();
535 // force synchronous execution, see https://github.com/WoltLab/WCF/issues/2501
536 if (ENABLE_DEBUG_MODE
&& ENABLE_DEVELOPER_TOOLS
) {
537 foreach ($jobs as $job) {
538 BackgroundQueueHandler
::getInstance()->performJob($job, true);
542 BackgroundQueueHandler
::getInstance()->enqueueIn($jobs);
543 BackgroundQueueHandler
::getInstance()->forceCheck();
548 * @see Email::getEmail()
550 public function __toString() {
551 return $this->getEmail();
555 * Returns the email RFC 2822 representation of this email.
559 public function getEmail() {
560 return $this->getHeaderString()."\r\n\r\n".$this->getBodyString();
564 * Dumps this email to STDOUT and stops the script.
568 public function debugDump() {
569 if (ob_get_level()) {
570 // discard any output generated before the email was dumped, prevents email
571 // being hidden inside HTML elements and therefore not visible in browser output
574 // `identity` is the default "encoding" and basically means that the client
575 // must treat the content as if the header did not appear in first place, this
576 // also overrules the gzip header if present
577 @header
('Content-Encoding: identity');
578 HeaderUtil
::exceptionDisableGzip();
581 $dumpBody = function ($body, $depth) use (&$dumpBody) {
583 // @codingStandardsIgnoreStart
584 if ($body instanceof mime\MimePartFacade
) {
585 return $dumpBody($body->getMimePart(), $depth);
587 if ($body instanceof mime\AbstractMultipartMimePart
) {
588 $result .= "<fieldset><legend><h".$depth.">".get_class($body)."</h".$depth."></legend>";
589 foreach ($body->getMimeparts() as $part) {
590 $result .= $dumpBody($part, $depth +
1);
592 $result .= '</fieldset>';
594 else if ($body instanceof mime\RecipientAwareTextMimePart
) {
595 $result .= "<fieldset><legend><h".$depth.">".get_class($body)."</h".$depth."></legend>";
596 if ($body instanceof mime\HtmlTextMimePart
) {
597 $result .= '<iframe src="data:text/html;base64,'.base64_encode($body->getContent()).'" style="width: 100%; height: 500px; border: 0"></iframe>';
600 $result .= "<pre>".StringUtil
::encodeHTML($body->getContent())."</pre>";
602 $result .= '</fieldset>';
604 else if ($body instanceof mime\AttachmentMimePart
) {
605 $result .= "<fieldset><legend><h".$depth.">".get_class($body)."</h".$depth."></legend>";
606 $result .= "<dl>".implode('', array_map(function ($item) {
607 return "<dt>".$item[0]."</dt><dd>".$item[1]."</dd>";
608 }, $body->getAdditionalHeaders()))."</dl>";
609 $result .= "<".strlen($body->getContent())." Bytes>";
610 $result .= '</fieldset>';
613 throw new \
LogicException('Bug');
615 // @codingStandardsIgnoreEnd
619 echo "<h1>Message Headers</h1>
620 <pre>".StringUtil
::encodeHTML($this->getHeaderString())."</pre>
621 <h1>Message Body</h1>".$dumpBody($this->body
, 2);