Merge branch '3.1' into 5.2
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / system / email / Email.class.php
1 <?php
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;
13
14 /**
15 * Represents a RFC 5322 message using the Mime format as defined in RFC 2045.
16 *
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
21 * @since 3.0
22 */
23 class Email {
24 /**
25 * From header
26 * @var Mailbox
27 */
28 protected $sender = null;
29
30 /**
31 * Reply-To header
32 * @var Mailbox
33 */
34 protected $replyTo = null;
35
36 /**
37 * Recipients of this email.
38 * @var array
39 */
40 protected $recipients = [];
41
42 /**
43 * Message-Id header
44 * @var string
45 */
46 protected $messageID = null;
47
48 /**
49 * References header
50 * @var string[]
51 */
52 protected $references = [];
53
54 /**
55 * In-Reply-To header
56 * @var string[]
57 */
58 protected $inReplyTo = [];
59
60 /**
61 * Date header
62 * @var \DateTime
63 */
64 protected $date = null;
65
66 /**
67 * Subject header
68 * @var string
69 */
70 protected $subject = '';
71
72 /**
73 * User specified X-* headers
74 * @var array
75 */
76 protected $extraHeaders = [];
77
78 /**
79 * The body of this Email.
80 * @var AbstractMimePart
81 */
82 protected $body = null;
83
84 /**
85 * Mail host for use in the Message-Id
86 * @var string
87 */
88 private static $host = null;
89
90 /**
91 * Returns the mail host for use in the Message-Id.
92 *
93 * @return string
94 */
95 public static function getHost() {
96 if (self::$host === null) {
97 self::$host = ApplicationHandler::getInstance()->getApplication('wcf')->domainName;
98 }
99
100 return self::$host;
101 }
102
103 /**
104 * Sets the email's 'Subject'.
105 *
106 * @param string $subject
107 */
108 public function setSubject($subject) {
109 $this->subject = $subject;
110 }
111
112 /**
113 * Returns the email's 'Subject'.
114 *
115 * @return string
116 */
117 public function getSubject() {
118 return $this->subject;
119 }
120
121 /**
122 * Sets the email's 'Date'.
123 *
124 * @param \DateTime $date
125 */
126 public function setDate(\DateTime $date = null) {
127 $this->date = $date;
128 }
129
130 /**
131 * Returns the email's 'Date'.
132 * If no header is set yet the current time will automatically be set.
133 *
134 * @return \DateTime
135 */
136 public function getDate() {
137 if ($this->date === null) {
138 $this->date = DateUtil::getDateTimeByTimestamp(TIME_NOW);
139 }
140
141 return $this->date;
142 }
143
144 /**
145 * Sets the part left of the at sign (@) in the email's 'Message-Id'.
146 *
147 * @param string $messageID
148 * @throws \DomainException
149 */
150 public function setMessageID($messageID = null) {
151 if ($messageID === null) {
152 $this->messageID = null;
153 return;
154 }
155
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 (@).");
158 }
159 if (strlen($messageID) > 200) {
160 throw new \DomainException("The given message id '".$messageID."' is not allowed. The maximum allowed length is 200 bytes.");
161 }
162
163 $this->messageID = $messageID;
164 }
165
166 /**
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.
169 *
170 * @return string
171 */
172 public function getMessageID() {
173 if ($this->messageID === null) {
174 $this->messageID = bin2hex(\random_bytes(20));
175 }
176
177 return '<'.$this->messageID.'@'.self::getHost().'>';
178 }
179
180 /**
181 * Adds a message id to the email's 'In-Reply-To'.
182 *
183 * @param string $messageID
184 * @throws \DomainException
185 */
186 public function addInReplyTo($messageID) {
187 if (!preg_match('(^'.EmailGrammar::getGrammar('msg-id').'$)', $messageID)) {
188 throw new \DomainException("The given reference '".$messageID."' is invalid.");
189 }
190
191 $this->inReplyTo[$messageID] = $messageID;
192 }
193
194 /**
195 * Removes a message id from the email's 'In-Reply-To'.
196 *
197 * @param string $messageID
198 */
199 public function removeInReplyTo($messageID) {
200 unset($this->inReplyTo[$messageID]);
201 }
202
203 /**
204 * Returns the email's 'In-Reply-To' message ids.
205 *
206 * @return string[]
207 */
208 public function getInReplyTo() {
209 return $this->inReplyTo;
210 }
211
212 /**
213 * Adds a message id to the email's 'References'.
214 *
215 * @param string $messageID
216 * @throws \DomainException
217 */
218 public function addReferences($messageID) {
219 if (!preg_match('(^'.EmailGrammar::getGrammar('msg-id').'$)', $messageID)) {
220 throw new \DomainException("The given reference '".$messageID."' is invalid.");
221 }
222
223 $this->references[$messageID] = $messageID;
224 }
225
226 /**
227 * Removes a message id from the email's 'References'.
228 *
229 * @param string $messageID
230 */
231 public function removeReferences($messageID) {
232 unset($this->references[$messageID]);
233 }
234
235 /**
236 * Returns the email's 'References' message ids.
237 *
238 * @return string[]
239 */
240 public function getReferences() {
241 return $this->references;
242 }
243
244 /**
245 * Sets the email's 'From'.
246 *
247 * @param Mailbox $sender
248 */
249 public function setSender(Mailbox $sender = null) {
250 $this->sender = $sender;
251 }
252
253 /**
254 * Returns the email's 'From'.
255 * If no header is set yet the MAIL_FROM_ADDRESS will automatically be set.
256 *
257 * @return Mailbox
258 */
259 public function getSender() {
260 if ($this->sender === null) {
261 $this->sender = new Mailbox(MAIL_FROM_ADDRESS, MAIL_FROM_NAME);
262 }
263
264 return $this->sender;
265 }
266
267 /**
268 * Sets the email's 'Reply-To'.
269 *
270 * @param Mailbox $replyTo
271 */
272 public function setReplyTo(Mailbox $replyTo = null) {
273 $this->replyTo = $replyTo;
274 }
275
276 /**
277 * Returns the email's 'Reply-To'.
278 * If no header is set yet the MAIL_ADMIN_ADDRESS will automatically be set.
279 *
280 * @return Mailbox
281 */
282 public function getReplyTo() {
283 if ($this->replyTo === null) {
284 $this->replyTo = new Mailbox(MAIL_ADMIN_ADDRESS);
285 }
286
287 return $this->replyTo;
288 }
289
290 /**
291 * Adds a recipient to this email.
292 *
293 * @param Mailbox $recipient
294 * @param string $type One of 'to', 'cc', 'bcc'
295 * @throws \DomainException
296 */
297 public function addRecipient(Mailbox $recipient, $type = 'to') {
298 switch ($type) {
299 case 'to':
300 case 'cc':
301 case 'bcc':
302 break;
303 default:
304 throw new \DomainException("The given type '".$type."' is invalid. Must be one of 'to', 'cc', 'bcc'.");
305 }
306
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.");
309 }
310
311 $this->recipients[$recipient->getAddress()] = [
312 'type' => $type,
313 'mailbox' => $recipient
314 ];
315 }
316
317 /**
318 * Removes a recipient from this email.
319 *
320 * @param Mailbox $recipient
321 */
322 public function removeRecipient(Mailbox $recipient) {
323 unset($this->recipients[$recipient->getAddress()]);
324 }
325
326 /**
327 * Returns the email's recipients as an array of [ $recipient, $type ] tuples.
328 *
329 * @return array
330 */
331 public function getRecipients() {
332 return $this->recipients;
333 }
334
335 /**
336 * Adds a custom X-* header to the email.
337 *
338 * @param string $header
339 * @param string $value
340 * @throws \DomainException
341 */
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-').");
346 }
347
348 $this->extraHeaders[] = [$header, EmailGrammar::encodeQuotedPrintableHeader($value)];
349 }
350
351 /**
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
354 * headers will fail.
355 *
356 * @return array
357 * @throws \LogicException
358 */
359 public function getHeaders() {
360 $headers = [];
361 $to = [];
362 $cc = [];
363 foreach ($this->getRecipients() as $recipient) {
364 if ($recipient['type'] == 'to') $to[] = $recipient['mailbox'];
365 else if ($recipient['type'] == 'cc') $cc[] = $recipient['mailbox'];
366 }
367 $headers[] = ['from', (string) $this->getSender()];
368 if ($this->getReplyTo()->getAddress() !== $this->getSender()->getAddress()) {
369 $headers[] = ['reply-to', (string) $this->getReplyTo()];
370 }
371
372 if ($to) {
373 $headers[] = ['to', implode(",\r\n ", $to)];
374 }
375 else {
376 throw new \LogicException("Cannot generate message headers, you must specify a recipient.");
377 }
378
379 if ($cc) {
380 $headers[] = ['cc', implode(",\r\n ", $cc)];
381 }
382 if ($this->getSubject()) {
383 $headers[] = ['subject', EmailGrammar::encodeQuotedPrintableHeader($this->getSubject())];
384 }
385 else {
386 throw new \LogicException("Cannot generate message headers, you must specify a subject.");
387 }
388
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())];
393 }
394 if ($this->getInReplyTo()) {
395 $headers[] = ['in-reply-to', implode("\r\n ", $this->getInReplyTo())];
396 }
397 $headers[] = ['mime-version', '1.0'];
398
399 if (!$this->body) {
400 throw new \LogicException("Cannot generate message headers, you must set a body.");
401 }
402 $headers[] = ['content-type', $this->body->getContentType()];
403 if ($this->body->getContentTransferEncoding()) {
404 $headers[] = ['content-transfer-encoding', $this->body->getContentTransferEncoding()];
405 }
406 $headers = array_merge($headers, $this->body->getAdditionalHeaders());
407
408 return array_merge($headers, $this->extraHeaders);
409 }
410
411 /**
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).
415 *
416 * @see \wcf\system\email\Email::getHeaders()
417 *
418 * @return string
419 */
420 public function getHeaderString() {
421 return implode("\r\n", array_map(function ($item) {
422 list($name, $value) = $item;
423
424 switch ($name) {
425 case 'message-id':
426 $name = 'Message-ID';
427 break;
428 case 'mime-version':
429 $name = 'MIME-Version';
430 break;
431 default:
432 $name = preg_replace_callback('/(?:^|-)[a-z]/', function ($matches) {
433 return mb_strtoupper($matches[0]);
434 }, $name);
435 }
436
437 return $name.': '.$value;
438 }, $this->getHeaders()));
439 }
440
441 /**
442 * Sets the body of this email.
443 *
444 * @param AbstractMimePart $body
445 */
446 public function setBody(AbstractMimePart $body) {
447 $this->body = $body;
448 }
449
450 /**
451 * Returns the body of this email.
452 *
453 * @return AbstractMimePart
454 */
455 public function getBody() {
456 return $this->body;
457 }
458
459 /**
460 * Returns the email's body as a string.
461 *
462 * @return string
463 */
464 public function getBodyString() {
465 if ($this->body === null) {
466 throw new \LogicException('Cannot generate message body, you must specify a body');
467 }
468
469 switch ($this->body->getContentTransferEncoding()) {
470 case 'quoted-printable':
471 return quoted_printable_encode(str_replace("\n", "\r\n", StringUtil::unifyNewlines($this->body->getContent())));
472 break;
473 case 'base64':
474 return chunk_split(base64_encode($this->body->getContent()));
475 break;
476 case '':
477 return $this->body->getContent();
478 }
479
480 throw new \LogicException('Unreachable');
481 }
482
483 /**
484 * Returns needed AbstractBackgroundJobs to deliver this email to every recipient.
485 *
486 * @return AbstractBackgroundJob[]
487 */
488 public function getJobs() {
489 $jobs = [];
490
491 // ensure every header is filled in
492 $this->getHeaders();
493
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');
497 }
498
499 foreach ($this->recipients as $recipient) {
500 $mail = clone $this;
501
502 if ($recipient['mailbox'] instanceof UserMailbox) {
503 $mail->addHeader('X-WoltLab-Suite-Recipient', $recipient['mailbox']->getUser()->username);
504 }
505
506 if ($this->body instanceof IRecipientAwareMimePart) $this->body->setRecipient($recipient['mailbox']);
507
508 $data = [
509 'mail' => $mail,
510 'recipient' => $recipient,
511 'sender' => $mail->getSender(),
512 'skip' => false
513 ];
514 EventHandler::getInstance()->fireAction($this, 'getJobs', $data);
515
516 // an event decided that this email should be skipped
517 if ($data['skip']) continue;
518
519 $jobs[] = new EmailDeliveryBackgroundJob($mail, $data['sender'], $data['recipient']['mailbox']);
520 }
521
522 return $jobs;
523 }
524
525 /**
526 * Queues this email for delivery.
527 * This is equivalent to manually queuing the jobs returned by getJobs().
528 *
529 * @see \wcf\system\email\Email::getJobs()
530 * @see \wcf\system\background\BackgroundQueueHandler::enqueueIn()
531 */
532 public function send() {
533 $jobs = $this->getJobs();
534
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);
539 }
540 }
541 else {
542 BackgroundQueueHandler::getInstance()->enqueueIn($jobs);
543 BackgroundQueueHandler::getInstance()->forceCheck();
544 }
545 }
546
547 /**
548 * @see Email::getEmail()
549 */
550 public function __toString() {
551 return $this->getEmail();
552 }
553
554 /**
555 * Returns the email RFC 2822 representation of this email.
556 *
557 * @return string
558 */
559 public function getEmail() {
560 return $this->getHeaderString()."\r\n\r\n".$this->getBodyString();
561 }
562
563 /**
564 * Dumps this email to STDOUT and stops the script.
565 *
566 * @return string
567 */
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
572 ob_end_clean();
573
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();
579 }
580
581 $dumpBody = function ($body, $depth) use (&$dumpBody) {
582 $result = '';
583 // @codingStandardsIgnoreStart
584 if ($body instanceof mime\MimePartFacade) {
585 return $dumpBody($body->getMimePart(), $depth);
586 }
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);
591 }
592 $result .= '</fieldset>';
593 }
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>';
598 }
599 else {
600 $result .= "<pre>".StringUtil::encodeHTML($body->getContent())."</pre>";
601 }
602 $result .= '</fieldset>';
603 }
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>';
611 }
612 else {
613 throw new \LogicException('Bug');
614 }
615 // @codingStandardsIgnoreEnd
616
617 return $result;
618 };
619 echo "<h1>Message Headers</h1>
620 <pre>".StringUtil::encodeHTML($this->getHeaderString())."</pre>
621 <h1>Message Body</h1>".$dumpBody($this->body, 2);
622
623 exit;
624 }
625 }