Remove product literal strings in "pht()", part 6
[phabricator.git] / src / applications / metamta / engine / PhabricatorMailEmailEngine.php
blob6c9cf1b3563ec23281fdf08c2747310f5b716b31
1 <?php
3 final class PhabricatorMailEmailEngine
4 extends PhabricatorMailMessageEngine {
6 public function newMessage() {
7 $mailer = $this->getMailer();
8 $mail = $this->getMail();
10 $message = new PhabricatorMailEmailMessage();
12 $from_address = $this->newFromEmailAddress();
13 $message->setFromAddress($from_address);
15 $reply_address = $this->newReplyToEmailAddress();
16 if ($reply_address) {
17 $message->setReplyToAddress($reply_address);
20 $to_addresses = $this->newToEmailAddresses();
21 $cc_addresses = $this->newCCEmailAddresses();
23 if (!$to_addresses && !$cc_addresses) {
24 $mail->setMessage(
25 pht(
26 'Message has no valid recipients: all To/CC are disabled, '.
27 'invalid, or configured not to receive this mail.'));
28 return null;
31 // If this email describes a mail processing error, we rate limit outbound
32 // messages to each individual address. This prevents messes where
33 // something is stuck in a loop or dumps a ton of messages on us suddenly.
34 if ($mail->getIsErrorEmail()) {
35 $all_recipients = array();
36 foreach ($to_addresses as $to_address) {
37 $all_recipients[] = $to_address->getAddress();
39 foreach ($cc_addresses as $cc_address) {
40 $all_recipients[] = $cc_address->getAddress();
42 if ($this->shouldRateLimitMail($all_recipients)) {
43 $mail->setMessage(
44 pht(
45 'This is an error email, but one or more recipients have '.
46 'exceeded the error email rate limit. Declining to deliver '.
47 'message.'));
48 return null;
52 // Some mailers require a valid "To:" in order to deliver mail. If we
53 // don't have any "To:", try to fill it in with a placeholder "To:".
54 // If that also fails, move the "Cc:" line to "To:".
55 if (!$to_addresses) {
56 $void_address = $this->newVoidEmailAddress();
57 $to_addresses = array($void_address);
60 $to_addresses = $this->getUniqueEmailAddresses($to_addresses);
61 $cc_addresses = $this->getUniqueEmailAddresses(
62 $cc_addresses,
63 $to_addresses);
65 $message->setToAddresses($to_addresses);
66 $message->setCCAddresses($cc_addresses);
68 $attachments = $this->newEmailAttachments();
69 $message->setAttachments($attachments);
71 $subject = $this->newEmailSubject();
72 $message->setSubject($subject);
74 $headers = $this->newEmailHeaders();
75 foreach ($this->newEmailThreadingHeaders($mailer) as $threading_header) {
76 $headers[] = $threading_header;
79 $stamps = $mail->getMailStamps();
80 if ($stamps) {
81 $headers[] = $this->newEmailHeader(
82 'X-Phabricator-Stamps',
83 implode(' ', $stamps));
86 $must_encrypt = $mail->getMustEncrypt();
88 $raw_body = $mail->getBody();
89 $body = $raw_body;
90 if ($must_encrypt) {
91 $parts = array();
93 $encrypt_uri = $mail->getMustEncryptURI();
94 if (!strlen($encrypt_uri)) {
95 $encrypt_phid = $mail->getRelatedPHID();
96 if ($encrypt_phid) {
97 $encrypt_uri = urisprintf(
98 '/object/%s/',
99 $encrypt_phid);
103 if (strlen($encrypt_uri)) {
104 $parts[] = pht(
105 'This secure message is notifying you of a change to this object:');
106 $parts[] = PhabricatorEnv::getProductionURI($encrypt_uri);
109 $parts[] = pht(
110 'The content for this message can only be transmitted over a '.
111 'secure channel. To view the message content, follow this '.
112 'link:');
114 $parts[] = PhabricatorEnv::getProductionURI($mail->getURI());
116 $body = implode("\n\n", $parts);
117 } else {
118 $body = $raw_body;
121 $body_limit = PhabricatorEnv::getEnvConfig('metamta.email-body-limit');
123 $body = phutil_string_cast($body);
124 if (strlen($body) > $body_limit) {
125 $body = id(new PhutilUTF8StringTruncator())
126 ->setMaximumBytes($body_limit)
127 ->truncateString($body);
128 $body .= "\n";
129 $body .= pht('(This email was truncated at %d bytes.)', $body_limit);
131 $message->setTextBody($body);
132 $body_limit -= strlen($body);
134 // If we sent a different message body than we were asked to, record
135 // what we actually sent to make debugging and diagnostics easier.
136 if ($body !== $raw_body) {
137 $mail->setDeliveredBody($body);
140 if ($must_encrypt) {
141 $send_html = false;
142 } else {
143 $send_html = $this->shouldSendHTML();
146 if ($send_html) {
147 $html_body = $mail->getHTMLBody();
148 if (phutil_nonempty_string($html_body)) {
149 // NOTE: We just drop the entire HTML body if it won't fit. Safely
150 // truncating HTML is hard, and we already have the text body to fall
151 // back to.
152 if (strlen($html_body) <= $body_limit) {
153 $message->setHTMLBody($html_body);
154 $body_limit -= strlen($html_body);
159 // Pass the headers to the mailer, then save the state so we can show
160 // them in the web UI. If the mail must be encrypted, we remove headers
161 // which are not on a strict whitelist to avoid disclosing information.
162 $filtered_headers = $this->filterHeaders($headers, $must_encrypt);
163 $message->setHeaders($filtered_headers);
165 $mail->setUnfilteredHeaders($headers);
166 $mail->setDeliveredHeaders($headers);
168 if (PhabricatorEnv::getEnvConfig('phabricator.silent')) {
169 $mail->setMessage(
170 pht(
171 'This software is running in silent mode. See `%s` '.
172 'in the configuration to change this setting.',
173 'phabricator.silent'));
175 return null;
178 return $message;
181 /* -( Message Components )------------------------------------------------- */
183 private function newFromEmailAddress() {
184 $from_address = $this->newDefaultEmailAddress();
185 $mail = $this->getMail();
187 // If the mail content must be encrypted, always disguise the sender.
188 $must_encrypt = $mail->getMustEncrypt();
189 if ($must_encrypt) {
190 return $from_address;
193 // If we have a raw "From" address, use that.
194 $raw_from = $mail->getRawFrom();
195 if ($raw_from) {
196 list($from_email, $from_name) = $raw_from;
197 return $this->newEmailAddress($from_email, $from_name);
200 // Otherwise, use as much of the information for any sending entity as
201 // we can.
202 $from_phid = $mail->getFrom();
204 $actor = $this->getActor($from_phid);
205 if ($actor) {
206 $actor_email = $actor->getEmailAddress();
207 $actor_name = $actor->getName();
208 } else {
209 $actor_email = null;
210 $actor_name = null;
213 $send_as_user = PhabricatorEnv::getEnvConfig('metamta.can-send-as-user');
214 if ($send_as_user) {
215 if ($actor_email !== null) {
216 $from_address->setAddress($actor_email);
220 if ($actor_name !== null) {
221 $from_address->setDisplayName($actor_name);
224 return $from_address;
227 private function newReplyToEmailAddress() {
228 $mail = $this->getMail();
230 $reply_raw = $mail->getReplyTo();
231 if (!phutil_nonempty_string($reply_raw)) {
232 return null;
235 $reply_address = new PhutilEmailAddress($reply_raw);
237 // If we have a sending object, change the display name.
238 $from_phid = $mail->getFrom();
239 $actor = $this->getActor($from_phid);
240 if ($actor) {
241 $reply_address->setDisplayName($actor->getName());
244 // If we don't have a display name, fill in a default.
245 if (!strlen($reply_address->getDisplayName())) {
246 $reply_address->setDisplayName(PlatformSymbols::getPlatformServerName());
249 return $reply_address;
252 private function newToEmailAddresses() {
253 $mail = $this->getMail();
255 $phids = $mail->getToPHIDs();
256 $addresses = $this->newEmailAddressesFromActorPHIDs($phids);
258 foreach ($mail->getRawToAddresses() as $raw_address) {
259 $addresses[] = new PhutilEmailAddress($raw_address);
262 return $addresses;
265 private function newCCEmailAddresses() {
266 $mail = $this->getMail();
267 $phids = $mail->getCcPHIDs();
268 return $this->newEmailAddressesFromActorPHIDs($phids);
271 private function newEmailAddressesFromActorPHIDs(array $phids) {
272 $mail = $this->getMail();
273 $phids = $mail->expandRecipients($phids);
275 $addresses = array();
276 foreach ($phids as $phid) {
277 $actor = $this->getActor($phid);
278 if (!$actor) {
279 continue;
282 if (!$actor->isDeliverable()) {
283 continue;
286 $addresses[] = new PhutilEmailAddress($actor->getEmailAddress());
289 return $addresses;
292 private function newEmailSubject() {
293 $mail = $this->getMail();
295 $is_threaded = (bool)$mail->getThreadID();
296 $must_encrypt = $mail->getMustEncrypt();
298 $subject = array();
300 if ($is_threaded) {
301 if ($this->shouldAddRePrefix()) {
302 $subject[] = 'Re:';
306 $subject_prefix = $mail->getSubjectPrefix();
307 $subject_prefix = phutil_string_cast($subject_prefix);
308 $subject_prefix = trim($subject_prefix);
310 $subject[] = $subject_prefix;
312 // If mail content must be encrypted, we replace the subject with
313 // a generic one.
314 if ($must_encrypt) {
315 $encrypt_subject = $mail->getMustEncryptSubject();
316 if (!strlen($encrypt_subject)) {
317 $encrypt_subject = pht('Object Updated');
319 $subject[] = $encrypt_subject;
320 } else {
321 $vary_prefix = $mail->getVarySubjectPrefix();
322 if (phutil_nonempty_string($vary_prefix)) {
323 if ($this->shouldVarySubject()) {
324 $subject[] = $vary_prefix;
328 $subject[] = $mail->getSubject();
331 foreach ($subject as $key => $part) {
332 if (!phutil_nonempty_string($part)) {
333 unset($subject[$key]);
337 $subject = implode(' ', $subject);
338 return $subject;
341 private function newEmailHeaders() {
342 $mail = $this->getMail();
344 $headers = array();
346 $headers[] = $this->newEmailHeader(
347 'X-Phabricator-Sent-This-Message',
348 'Yes');
349 $headers[] = $this->newEmailHeader(
350 'X-Mail-Transport-Agent',
351 'MetaMTA');
353 // Some clients respect this to suppress OOF and other auto-responses.
354 $headers[] = $this->newEmailHeader(
355 'X-Auto-Response-Suppress',
356 'All');
358 $mailtags = $mail->getMailTags();
359 if ($mailtags) {
360 $tag_header = array();
361 foreach ($mailtags as $mailtag) {
362 $tag_header[] = '<'.$mailtag.'>';
364 $tag_header = implode(', ', $tag_header);
365 $headers[] = $this->newEmailHeader(
366 'X-Phabricator-Mail-Tags',
367 $tag_header);
370 $value = $mail->getHeaders();
371 foreach ($value as $pair) {
372 list($header_key, $header_value) = $pair;
374 // NOTE: If we have \n in a header, SES rejects the email.
375 $header_value = str_replace("\n", ' ', $header_value);
376 $headers[] = $this->newEmailHeader($header_key, $header_value);
379 $is_bulk = $mail->getIsBulk();
380 if ($is_bulk) {
381 $headers[] = $this->newEmailHeader('Precedence', 'bulk');
384 if ($mail->getMustEncrypt()) {
385 $headers[] = $this->newEmailHeader('X-Phabricator-Must-Encrypt', 'Yes');
388 $related_phid = $mail->getRelatedPHID();
389 if ($related_phid) {
390 $headers[] = $this->newEmailHeader('Thread-Topic', $related_phid);
393 $headers[] = $this->newEmailHeader(
394 'X-Phabricator-Mail-ID',
395 $mail->getID());
397 $unique = Filesystem::readRandomCharacters(16);
398 $headers[] = $this->newEmailHeader(
399 'X-Phabricator-Send-Attempt',
400 $unique);
402 return $headers;
405 private function newEmailThreadingHeaders() {
406 $mailer = $this->getMailer();
407 $mail = $this->getMail();
409 $headers = array();
411 $thread_id = $mail->getThreadID();
412 if (!phutil_nonempty_string($thread_id)) {
413 return $headers;
416 $is_first = $mail->getIsFirstMessage();
418 // NOTE: Gmail freaks out about In-Reply-To and References which aren't in
419 // the form "<string@domain.tld>"; this is also required by RFC 2822,
420 // although some clients are more liberal in what they accept.
421 $domain = $this->newMailDomain();
422 $thread_id = '<'.$thread_id.'@'.$domain.'>';
424 if ($is_first && $mailer->supportsMessageIDHeader()) {
425 $headers[] = $this->newEmailHeader('Message-ID', $thread_id);
426 } else {
427 $in_reply_to = $thread_id;
428 $references = array($thread_id);
429 $parent_id = $mail->getParentMessageID();
430 if ($parent_id) {
431 $in_reply_to = $parent_id;
432 // By RFC 2822, the most immediate parent should appear last
433 // in the "References" header, so this order is intentional.
434 $references[] = $parent_id;
436 $references = implode(' ', $references);
437 $headers[] = $this->newEmailHeader('In-Reply-To', $in_reply_to);
438 $headers[] = $this->newEmailHeader('References', $references);
440 $thread_index = $this->generateThreadIndex($thread_id, $is_first);
441 $headers[] = $this->newEmailHeader('Thread-Index', $thread_index);
443 return $headers;
446 private function newEmailAttachments() {
447 $mail = $this->getMail();
449 // If the mail content must be encrypted, don't add attachments.
450 $must_encrypt = $mail->getMustEncrypt();
451 if ($must_encrypt) {
452 return array();
455 return $mail->getAttachments();
458 /* -( Preferences )-------------------------------------------------------- */
460 private function shouldAddRePrefix() {
461 $preferences = $this->getPreferences();
463 $value = $preferences->getSettingValue(
464 PhabricatorEmailRePrefixSetting::SETTINGKEY);
466 return ($value == PhabricatorEmailRePrefixSetting::VALUE_RE_PREFIX);
469 private function shouldVarySubject() {
470 $preferences = $this->getPreferences();
472 $value = $preferences->getSettingValue(
473 PhabricatorEmailVarySubjectsSetting::SETTINGKEY);
475 return ($value == PhabricatorEmailVarySubjectsSetting::VALUE_VARY_SUBJECTS);
478 private function shouldSendHTML() {
479 $preferences = $this->getPreferences();
481 $value = $preferences->getSettingValue(
482 PhabricatorEmailFormatSetting::SETTINGKEY);
484 return ($value == PhabricatorEmailFormatSetting::VALUE_HTML_EMAIL);
488 /* -( Utilities )---------------------------------------------------------- */
490 private function newEmailHeader($name, $value) {
491 return id(new PhabricatorMailHeader())
492 ->setName($name)
493 ->setValue($value);
496 private function newEmailAddress($address, $name = null) {
497 $object = id(new PhutilEmailAddress())
498 ->setAddress($address);
500 if (strlen($name)) {
501 $object->setDisplayName($name);
504 return $object;
507 public function newDefaultEmailAddress() {
508 $raw_address = PhabricatorEnv::getEnvConfig('metamta.default-address');
510 if (!strlen($raw_address)) {
511 $domain = $this->newMailDomain();
512 $raw_address = "noreply@{$domain}";
515 $address = new PhutilEmailAddress($raw_address);
517 if (!phutil_nonempty_string($address->getDisplayName())) {
518 $address->setDisplayName(PlatformSymbols::getPlatformServerName());
521 return $address;
524 public function newVoidEmailAddress() {
525 return $this->newDefaultEmailAddress();
528 private function newMailDomain() {
529 $domain = PhabricatorEnv::getEnvConfig('metamta.reply-handler-domain');
530 if (strlen($domain)) {
531 return $domain;
534 $install_uri = PhabricatorEnv::getURI('/');
535 $install_uri = new PhutilURI($install_uri);
537 return $install_uri->getDomain();
540 private function filterHeaders(array $headers, $must_encrypt) {
541 assert_instances_of($headers, 'PhabricatorMailHeader');
543 if (!$must_encrypt) {
544 return $headers;
547 $whitelist = array(
548 'In-Reply-To',
549 'Message-ID',
550 'Precedence',
551 'References',
552 'Thread-Index',
553 'Thread-Topic',
555 'X-Mail-Transport-Agent',
556 'X-Auto-Response-Suppress',
558 'X-Phabricator-Sent-This-Message',
559 'X-Phabricator-Must-Encrypt',
560 'X-Phabricator-Mail-ID',
561 'X-Phabricator-Send-Attempt',
564 // NOTE: The major header we want to drop is "X-Phabricator-Mail-Tags".
565 // This header contains a significant amount of meaningful information
566 // about the object.
568 $whitelist_map = array();
569 foreach ($whitelist as $term) {
570 $whitelist_map[phutil_utf8_strtolower($term)] = true;
573 foreach ($headers as $key => $header) {
574 $name = $header->getName();
575 $name = phutil_utf8_strtolower($name);
577 if (!isset($whitelist_map[$name])) {
578 unset($headers[$key]);
582 return $headers;
585 private function getUniqueEmailAddresses(
586 array $addresses,
587 array $exclude = array()) {
588 assert_instances_of($addresses, 'PhutilEmailAddress');
589 assert_instances_of($exclude, 'PhutilEmailAddress');
591 $seen = array();
593 foreach ($exclude as $address) {
594 $seen[$address->getAddress()] = true;
597 foreach ($addresses as $key => $address) {
598 $raw_address = $address->getAddress();
600 if (isset($seen[$raw_address])) {
601 unset($addresses[$key]);
602 continue;
605 $seen[$raw_address] = true;
608 return array_values($addresses);
611 private function generateThreadIndex($seed, $is_first_mail) {
612 // When threading, Outlook ignores the 'References' and 'In-Reply-To'
613 // headers that most clients use. Instead, it uses a custom 'Thread-Index'
614 // header. The format of this header is something like this (from
615 // camel-exchange-folder.c in Evolution Exchange):
617 /* A new post to a folder gets a 27-byte-long thread index. (The value
618 * is apparently unique but meaningless.) Each reply to a post gets a
619 * 32-byte-long thread index whose first 27 bytes are the same as the
620 * parent's thread index. Each reply to any of those gets a
621 * 37-byte-long thread index, etc. The Thread-Index header contains a
622 * base64 representation of this value.
625 // The specific implementation uses a 27-byte header for the first email
626 // a recipient receives, and a random 5-byte suffix (32 bytes total)
627 // thereafter. This means that all the replies are (incorrectly) siblings,
628 // but it would be very difficult to keep track of the entire tree and this
629 // gets us reasonable client behavior.
631 $base = substr(md5($seed), 0, 27);
632 if (!$is_first_mail) {
633 // Not totally sure, but it seems like outlook orders replies by
634 // thread-index rather than timestamp, so to get these to show up in the
635 // right order we use the time as the last 4 bytes.
636 $base .= ' '.pack('N', time());
639 return base64_encode($base);
642 private function shouldRateLimitMail(array $all_recipients) {
643 try {
644 PhabricatorSystemActionEngine::willTakeAction(
645 $all_recipients,
646 new PhabricatorMetaMTAErrorMailAction(),
648 return false;
649 } catch (PhabricatorSystemActionRateLimitException $ex) {
650 return true;