Remove product literal strings in "pht()", part 6
[phabricator.git] / src / applications / metamta / storage / PhabricatorMetaMTAReceivedMail.php
blobd3289bbc691cf0fe16f87d590142de539037b302
1 <?php
3 final class PhabricatorMetaMTAReceivedMail extends PhabricatorMetaMTADAO {
5 protected $headers = array();
6 protected $bodies = array();
7 protected $attachments = array();
8 protected $status = '';
10 protected $relatedPHID;
11 protected $authorPHID;
12 protected $message;
13 protected $messageIDHash = '';
15 protected function getConfiguration() {
16 return array(
17 self::CONFIG_SERIALIZATION => array(
18 'headers' => self::SERIALIZATION_JSON,
19 'bodies' => self::SERIALIZATION_JSON,
20 'attachments' => self::SERIALIZATION_JSON,
22 self::CONFIG_COLUMN_SCHEMA => array(
23 'relatedPHID' => 'phid?',
24 'authorPHID' => 'phid?',
25 'message' => 'text?',
26 'messageIDHash' => 'bytes12',
27 'status' => 'text32',
29 self::CONFIG_KEY_SCHEMA => array(
30 'relatedPHID' => array(
31 'columns' => array('relatedPHID'),
33 'authorPHID' => array(
34 'columns' => array('authorPHID'),
36 'key_messageIDHash' => array(
37 'columns' => array('messageIDHash'),
39 'key_created' => array(
40 'columns' => array('dateCreated'),
43 ) + parent::getConfiguration();
46 public function setHeaders(array $headers) {
47 // Normalize headers to lowercase.
48 $normalized = array();
49 foreach ($headers as $name => $value) {
50 $name = $this->normalizeMailHeaderName($name);
51 if ($name == 'message-id') {
52 $this->setMessageIDHash(PhabricatorHash::digestForIndex($value));
54 $normalized[$name] = $value;
56 $this->headers = $normalized;
57 return $this;
60 public function getHeader($key, $default = null) {
61 $key = $this->normalizeMailHeaderName($key);
62 return idx($this->headers, $key, $default);
65 private function normalizeMailHeaderName($name) {
66 return strtolower($name);
69 public function getMessageID() {
70 return $this->getHeader('Message-ID');
73 public function getSubject() {
74 return $this->getHeader('Subject');
77 public function getCCAddresses() {
78 return $this->getRawEmailAddresses(idx($this->headers, 'cc'));
81 public function getToAddresses() {
82 return $this->getRawEmailAddresses(idx($this->headers, 'to'));
85 public function newTargetAddresses() {
86 $raw_addresses = array();
88 foreach ($this->getToAddresses() as $raw_address) {
89 $raw_addresses[] = $raw_address;
92 foreach ($this->getCCAddresses() as $raw_address) {
93 $raw_addresses[] = $raw_address;
96 $raw_addresses = array_unique($raw_addresses);
98 $addresses = array();
99 foreach ($raw_addresses as $raw_address) {
100 $addresses[] = new PhutilEmailAddress($raw_address);
103 return $addresses;
106 public function loadAllRecipientPHIDs() {
107 $addresses = $this->newTargetAddresses();
109 // See T13317. Don't allow reserved addresses (like "noreply@...") to
110 // match user PHIDs.
111 foreach ($addresses as $key => $address) {
112 if (PhabricatorMailUtil::isReservedAddress($address)) {
113 unset($addresses[$key]);
117 if (!$addresses) {
118 return array();
121 $address_strings = array();
122 foreach ($addresses as $address) {
123 $address_strings[] = phutil_string_cast($address->getAddress());
126 // See T13317. If a verified email address is in the "To" or "Cc" line,
127 // we'll count the user who owns that address as a recipient.
129 // We require the address be verified because we'll trigger behavior (like
130 // adding subscribers) based on the recipient list, and don't want to add
131 // Alice as a subscriber if she adds an unverified "internal-bounces@"
132 // address to her account and this address gets caught in the crossfire.
133 // In the best case this is confusing; in the worst case it could
134 // some day give her access to objects she can't see.
136 $recipients = id(new PhabricatorUserEmail())
137 ->loadAllWhere(
138 'address IN (%Ls) AND isVerified = 1',
139 $address_strings);
141 $recipient_phids = mpull($recipients, 'getUserPHID');
143 return $recipient_phids;
146 public function processReceivedMail() {
147 $viewer = $this->getViewer();
149 $sender = null;
150 try {
151 $this->dropMailFromPhabricator();
152 $this->dropMailAlreadyReceived();
153 $this->dropEmptyMail();
155 $sender = $this->loadSender();
156 if ($sender) {
157 $this->setAuthorPHID($sender->getPHID());
159 // If we've identified the sender, mark them as the author of any
160 // attached files. We do this before we validate them (below), since
161 // they still authored these files even if their account is not allowed
162 // to interact via email.
164 $attachments = $this->getAttachments();
165 if ($attachments) {
166 $files = id(new PhabricatorFileQuery())
167 ->setViewer($viewer)
168 ->withPHIDs($attachments)
169 ->execute();
170 foreach ($files as $file) {
171 $file->setAuthorPHID($sender->getPHID())->save();
175 $this->validateSender($sender);
178 $receivers = id(new PhutilClassMapQuery())
179 ->setAncestorClass('PhabricatorMailReceiver')
180 ->setFilterMethod('isEnabled')
181 ->execute();
183 $reserved_recipient = null;
184 $targets = $this->newTargetAddresses();
185 foreach ($targets as $key => $target) {
186 // Never accept any reserved address as a mail target. This prevents
187 // security issues around "hostmaster@" and bad behavior with
188 // "noreply@".
189 if (PhabricatorMailUtil::isReservedAddress($target)) {
190 if (!$reserved_recipient) {
191 $reserved_recipient = $target;
193 unset($targets[$key]);
194 continue;
197 // See T13234. Don't process mail if a user has attached this address
198 // to their account.
199 if (PhabricatorMailUtil::isUserAddress($target)) {
200 unset($targets[$key]);
201 continue;
205 $any_accepted = false;
206 $receiver_exception = null;
207 foreach ($receivers as $receiver) {
208 $receiver = id(clone $receiver)
209 ->setViewer($viewer);
211 if ($sender) {
212 $receiver->setSender($sender);
215 foreach ($targets as $target) {
216 try {
217 if (!$receiver->canAcceptMail($this, $target)) {
218 continue;
221 $any_accepted = true;
223 $receiver->receiveMail($this, $target);
224 } catch (Exception $ex) {
225 // If receivers raise exceptions, we'll keep the first one in hope
226 // that it points at a root cause.
227 if (!$receiver_exception) {
228 $receiver_exception = $ex;
234 if ($receiver_exception) {
235 throw $receiver_exception;
239 if (!$any_accepted) {
240 if ($reserved_recipient) {
241 // If nothing accepted the mail, we normally raise an error to help
242 // users who mistakenly send mail to "barges@" instead of "bugs@".
244 // However, if the recipient list included a reserved recipient, we
245 // don't bounce the mail with an error.
247 // The intent here is that if a user does a "Reply All" and includes
248 // "From: noreply@phabricator" in the receipient list, we just want
249 // to drop the mail rather than send them an unhelpful bounce message.
251 throw new PhabricatorMetaMTAReceivedMailProcessingException(
252 MetaMTAReceivedMailStatus::STATUS_RESERVED,
253 pht(
254 'No application handled this mail. This mail was sent to a '.
255 'reserved recipient ("%s") so bounces are suppressed.',
256 (string)$reserved_recipient));
257 } else if (!$sender) {
258 // NOTE: Currently, we'll always drop this mail (since it's headed to
259 // an unverified recipient). See T12237. These details are still
260 // useful because they'll appear in the mail logs and Mail web UI.
262 throw new PhabricatorMetaMTAReceivedMailProcessingException(
263 MetaMTAReceivedMailStatus::STATUS_UNKNOWN_SENDER,
264 pht(
265 'This email was sent from an email address ("%s") that is not '.
266 'associated with a registered user account. To interact via '.
267 'email, add this address to your account.',
268 (string)$this->newFromAddress()));
269 } else {
270 throw new PhabricatorMetaMTAReceivedMailProcessingException(
271 MetaMTAReceivedMailStatus::STATUS_NO_RECEIVERS,
272 pht(
273 'This mail can not be processed because no application '.
274 'knows how to handle it. Check that the address you sent it to '.
275 'is correct.'));
278 } catch (PhabricatorMetaMTAReceivedMailProcessingException $ex) {
279 switch ($ex->getStatusCode()) {
280 case MetaMTAReceivedMailStatus::STATUS_DUPLICATE:
281 case MetaMTAReceivedMailStatus::STATUS_FROM_PHABRICATOR:
282 // Don't send an error email back in these cases, since they're
283 // very unlikely to be the sender's fault.
284 break;
285 case MetaMTAReceivedMailStatus::STATUS_RESERVED:
286 // This probably is the sender's fault, but it's likely an accident
287 // that we received the mail at all.
288 break;
289 case MetaMTAReceivedMailStatus::STATUS_EMPTY_IGNORED:
290 // This error is explicitly ignored.
291 break;
292 default:
293 $this->sendExceptionMail($ex, $sender);
294 break;
297 $this
298 ->setStatus($ex->getStatusCode())
299 ->setMessage($ex->getMessage())
300 ->save();
301 return $this;
302 } catch (Exception $ex) {
303 $this->sendExceptionMail($ex, $sender);
305 $this
306 ->setStatus(MetaMTAReceivedMailStatus::STATUS_UNHANDLED_EXCEPTION)
307 ->setMessage(pht('Unhandled Exception: %s', $ex->getMessage()))
308 ->save();
310 throw $ex;
313 return $this->setMessage('OK')->save();
316 public function getCleanTextBody() {
317 $body = $this->getRawTextBody();
318 $parser = new PhabricatorMetaMTAEmailBodyParser();
319 return $parser->stripTextBody($body);
322 public function parseBody() {
323 $body = $this->getRawTextBody();
324 $parser = new PhabricatorMetaMTAEmailBodyParser();
325 return $parser->parseBody($body);
328 public function getRawTextBody() {
329 return idx($this->bodies, 'text');
333 * Strip an email address down to the actual user@domain.tld part if
334 * necessary, since sometimes it will have formatting like
335 * '"Abraham Lincoln" <alincoln@logcab.in>'.
337 private function getRawEmailAddress($address) {
338 $matches = null;
339 $ok = preg_match('/<(.*)>/', $address, $matches);
340 if ($ok) {
341 $address = $matches[1];
343 return $address;
346 private function getRawEmailAddresses($addresses) {
347 $raw_addresses = array();
349 if (phutil_nonempty_string($addresses)) {
350 foreach (explode(',', $addresses) as $address) {
351 $raw_addresses[] = $this->getRawEmailAddress($address);
355 return array_filter($raw_addresses);
359 * If Phabricator sent the mail, always drop it immediately. This prevents
360 * loops where, e.g., the public bug address is also a user email address
361 * and creating a bug sends them an email, which loops.
363 private function dropMailFromPhabricator() {
364 if (!$this->getHeader('x-phabricator-sent-this-message')) {
365 return;
368 throw new PhabricatorMetaMTAReceivedMailProcessingException(
369 MetaMTAReceivedMailStatus::STATUS_FROM_PHABRICATOR,
370 pht(
371 "Ignoring email with '%s' header to avoid loops.",
372 'X-Phabricator-Sent-This-Message'));
376 * If this mail has the same message ID as some other mail, and isn't the
377 * first mail we we received with that message ID, we drop it as a duplicate.
379 private function dropMailAlreadyReceived() {
380 $message_id_hash = $this->getMessageIDHash();
381 if (!$message_id_hash) {
382 // No message ID hash, so we can't detect duplicates. This should only
383 // happen with very old messages.
384 return;
387 $messages = $this->loadAllWhere(
388 'messageIDHash = %s ORDER BY id ASC LIMIT 2',
389 $message_id_hash);
390 $messages_count = count($messages);
391 if ($messages_count <= 1) {
392 // If we only have one copy of this message, we're good to process it.
393 return;
396 $first_message = reset($messages);
397 if ($first_message->getID() == $this->getID()) {
398 // If this is the first copy of the message, it is okay to process it.
399 // We may not have been able to to process it immediately when we received
400 // it, and could may have received several copies without processing any
401 // yet.
402 return;
405 $message = pht(
406 'Ignoring email with "Message-ID" hash "%s" that has been seen %d '.
407 'times, including this message.',
408 $message_id_hash,
409 $messages_count);
411 throw new PhabricatorMetaMTAReceivedMailProcessingException(
412 MetaMTAReceivedMailStatus::STATUS_DUPLICATE,
413 $message);
416 private function dropEmptyMail() {
417 $body = $this->getCleanTextBody();
418 $attachments = $this->getAttachments();
420 if (strlen($body) || $attachments) {
421 return;
424 // Only send an error email if the user is talking to just Phabricator.
425 // We can assume if there is only one "To" address it is a Phabricator
426 // address since this code is running and everything.
427 $is_direct_mail = (count($this->getToAddresses()) == 1) &&
428 (count($this->getCCAddresses()) == 0);
430 if ($is_direct_mail) {
431 $status_code = MetaMTAReceivedMailStatus::STATUS_EMPTY;
432 } else {
433 $status_code = MetaMTAReceivedMailStatus::STATUS_EMPTY_IGNORED;
436 throw new PhabricatorMetaMTAReceivedMailProcessingException(
437 $status_code,
438 pht(
439 'Your message does not contain any body text or attachments, so '.
440 'this server can not do anything useful with it. Make sure comment '.
441 'text appears at the top of your message: quoted replies, inline '.
442 'text, and signatures are discarded and ignored.'));
445 private function sendExceptionMail(
446 Exception $ex,
447 PhabricatorUser $viewer = null) {
449 // If we've failed to identify a legitimate sender, we don't send them
450 // an error message back. We want to avoid sending mail to unverified
451 // addresses. See T12491.
452 if (!$viewer) {
453 return;
456 if ($ex instanceof PhabricatorMetaMTAReceivedMailProcessingException) {
457 $status_code = $ex->getStatusCode();
458 $status_name = MetaMTAReceivedMailStatus::getHumanReadableName(
459 $status_code);
461 $title = pht('Error Processing Mail (%s)', $status_name);
462 $description = $ex->getMessage();
463 } else {
464 $title = pht('Error Processing Mail (%s)', get_class($ex));
465 $description = pht('%s: %s', get_class($ex), $ex->getMessage());
468 // TODO: Since headers don't necessarily have unique names, this may not
469 // really be all the headers. It would be nice to pass the raw headers
470 // through from the upper layers where possible.
472 // On the MimeMailParser pathway, we arrive here with a list value for
473 // headers that appeared multiple times in the original mail. Be
474 // accommodating until header handling gets straightened out.
476 $headers = array();
477 foreach ($this->headers as $key => $values) {
478 if (!is_array($values)) {
479 $values = array($values);
481 foreach ($values as $value) {
482 $headers[] = pht('%s: %s', $key, $value);
485 $headers = implode("\n", $headers);
487 $body = pht(<<<EOBODY
488 Your email to %s was not processed, because an error occurred while
489 trying to handle it:
493 -- Original Message Body -----------------------------------------------------
497 -- Original Message Headers --------------------------------------------------
501 EOBODY
503 PlatformSymbols::getPlatformServerName(),
504 wordwrap($description, 78),
505 $this->getRawTextBody(),
506 $headers);
508 $mail = id(new PhabricatorMetaMTAMail())
509 ->setIsErrorEmail(true)
510 ->setSubject($title)
511 ->addTos(array($viewer->getPHID()))
512 ->setBody($body)
513 ->saveAndSend();
516 public function newContentSource() {
517 return PhabricatorContentSource::newForSource(
518 PhabricatorEmailContentSource::SOURCECONST,
519 array(
520 'id' => $this->getID(),
524 public function newFromAddress() {
525 $raw_from = $this->getHeader('From');
527 if (strlen($raw_from)) {
528 return new PhutilEmailAddress($raw_from);
531 return null;
534 private function getViewer() {
535 return PhabricatorUser::getOmnipotentUser();
539 * Identify the sender's user account for a piece of received mail.
541 * Note that this method does not validate that the sender is who they say
542 * they are, just that they've presented some credential which corresponds
543 * to a recognizable user.
545 private function loadSender() {
546 $viewer = $this->getViewer();
548 // Try to identify the user based on their "From" address.
549 $from_address = $this->newFromAddress();
550 if ($from_address) {
551 $user = id(new PhabricatorPeopleQuery())
552 ->setViewer($viewer)
553 ->withEmails(array($from_address->getAddress()))
554 ->executeOne();
555 if ($user) {
556 return $user;
560 return null;
563 private function validateSender(PhabricatorUser $sender) {
564 $failure_reason = null;
565 if ($sender->getIsDisabled()) {
566 $failure_reason = pht(
567 'Your account ("%s") is disabled, so you can not interact with '.
568 'over email.',
569 $sender->getUsername());
570 } else if ($sender->getIsStandardUser()) {
571 if (!$sender->getIsApproved()) {
572 $failure_reason = pht(
573 'Your account ("%s") has not been approved yet. You can not '.
574 'interact over email until your account is approved.',
575 $sender->getUsername());
576 } else if (PhabricatorUserEmail::isEmailVerificationRequired() &&
577 !$sender->getIsEmailVerified()) {
578 $failure_reason = pht(
579 'You have not verified the email address for your account ("%s"). '.
580 'You must verify your email address before you can interact over '.
581 'email.',
582 $sender->getUsername());
586 if ($failure_reason) {
587 throw new PhabricatorMetaMTAReceivedMailProcessingException(
588 MetaMTAReceivedMailStatus::STATUS_DISABLED_SENDER,
589 $failure_reason);