diff --git a/src/applications/metamta/management/PhabricatorMailManagementShowOutboundWorkflow.php b/src/applications/metamta/management/PhabricatorMailManagementShowOutboundWorkflow.php index bf4b962e2..3b798259b 100644 --- a/src/applications/metamta/management/PhabricatorMailManagementShowOutboundWorkflow.php +++ b/src/applications/metamta/management/PhabricatorMailManagementShowOutboundWorkflow.php @@ -1,161 +1,162 @@ <?php final class PhabricatorMailManagementShowOutboundWorkflow extends PhabricatorMailManagementWorkflow { protected function didConstruct() { $this ->setName('show-outbound') ->setSynopsis('Show diagnostic details about outbound mail.') ->setExamples( '**show-outbound** --id 1 --id 2') ->setArguments( array( array( 'name' => 'id', 'param' => 'id', 'help' => 'Show details about outbound mail with given ID.', 'repeat' => true, ), array( 'name' => 'dump-html', 'help' => pht( 'Dump the HTML body of the mail. You can redirect it to a '. 'file and then open it in a browser.'), ), )); } public function execute(PhutilArgumentParser $args) { $console = PhutilConsole::getConsole(); $ids = $args->getArg('id'); if (!$ids) { throw new PhutilArgumentUsageException( "Use the '--id' flag to specify one or more messages to show."); } $messages = id(new PhabricatorMetaMTAMail())->loadAllWhere( 'id IN (%Ld)', $ids); if ($ids) { $ids = array_fuse($ids); $missing = array_diff_key($ids, $messages); if ($missing) { throw new PhutilArgumentUsageException( 'Some specified messages do not exist: '. implode(', ', array_keys($missing))); } } $last_key = last_key($messages); foreach ($messages as $message_key => $message) { if ($args->getArg('dump-html')) { $html_body = $message->getHTMLBody(); if (strlen($html_body)) { $template = "<!doctype html><html><body>{$html_body}</body></html>"; $console->writeOut("%s\n", $html_body); } else { $console->writeErr( "%s\n", pht('(This message has no HTML body.)')); } continue; } $info = array(); $info[] = pht('PROPERTIES'); $info[] = pht('ID: %d', $message->getID()); $info[] = pht('Status: %s', $message->getStatus()); $info[] = pht('Related PHID: %s', $message->getRelatedPHID()); $info[] = pht('Message: %s', $message->getMessage()); $info[] = null; $info[] = pht('PARAMETERS'); $parameters = $message->getParameters(); foreach ($parameters as $key => $value) { if ($key == 'body') { continue; } if ($key == 'html-body') { continue; } if ($key == 'headers') { continue; } if ($key == 'attachments') { continue; } if (!is_scalar($value)) { $value = json_encode($value); } $info[] = pht('%s: %s', $key, $value); } $info[] = null; $info[] = pht('HEADERS'); foreach (idx($parameters, 'headers', array()) as $header) { list($name, $value) = $header; $info[] = "{$name}: {$value}"; } $attachments = idx($parameters, 'attachments'); if ($attachments) { $info[] = null; $info[] = pht('ATTACHMENTS'); foreach ($attachments as $attachment) { $info[] = idx($attachment, 'filename', pht('Unnamed File')); } } $actors = $message->loadAllActors(); $actors = array_select_keys( $actors, array_merge($message->getToPHIDs(), $message->getCcPHIDs())); $info[] = null; $info[] = pht('RECIPIENTS'); foreach ($actors as $actor) { if ($actor->isDeliverable()) { $info[] = ' '.coalesce($actor->getName(), $actor->getPHID()); } else { $info[] = '! '.coalesce($actor->getName(), $actor->getPHID()); - foreach ($actor->getUndeliverableReasons() as $reason) { - $info[] = ' - '.$reason; - } + } + foreach ($actor->getDeliverabilityReasons() as $reason) { + $desc = PhabricatorMetaMTAActor::getReasonDescription($reason); + $info[] = ' - '.$desc; } } $info[] = null; $info[] = pht('TEXT BODY'); if (strlen($message->getBody())) { $info[] = $message->getBody(); } else { $info[] = pht('(This message has no text body.)'); } $info[] = null; $info[] = pht('HTML BODY'); if (strlen($message->getHTMLBody())) { $info[] = $message->getHTMLBody(); $info[] = null; } else { $info[] = pht('(This message has no HTML body.)'); } $console->writeOut('%s', implode("\n", $info)); if ($message_key != $last_key) { $console->writeOut("\n%s\n\n", str_repeat('-', 80)); } } } } diff --git a/src/applications/metamta/query/PhabricatorMetaMTAActor.php b/src/applications/metamta/query/PhabricatorMetaMTAActor.php index 8580a7bc3..e314570a5 100644 --- a/src/applications/metamta/query/PhabricatorMetaMTAActor.php +++ b/src/applications/metamta/query/PhabricatorMetaMTAActor.php @@ -1,50 +1,117 @@ <?php final class PhabricatorMetaMTAActor { + const STATUS_DELIVERABLE = 'deliverable'; + const STATUS_UNDELIVERABLE = 'undeliverable'; + + const REASON_UNLOADABLE = 'unloadable'; + const REASON_UNMAILABLE = 'unmailable'; + const REASON_NO_ADDRESS = 'noaddress'; + const REASON_DISABLED = 'disabled'; + const REASON_MAIL_DISABLED = 'maildisabled'; + const REASON_EXTERNAL_TYPE = 'exernaltype'; + const REASON_RESPONSE = 'response'; + const REASON_SELF = 'self'; + const REASON_MAILTAGS = 'mailtags'; + const REASON_BOT = 'bot'; + const REASON_FORCE = 'force'; + private $phid; private $emailAddress; private $name; + private $status = self::STATUS_DELIVERABLE; private $reasons = array(); public function setName($name) { $this->name = $name; return $this; } public function getName() { return $this->name; } public function setEmailAddress($email_address) { $this->emailAddress = $email_address; return $this; } public function getEmailAddress() { return $this->emailAddress; } public function setPHID($phid) { $this->phid = $phid; return $this; } public function getPHID() { return $this->phid; } public function setUndeliverable($reason) { $this->reasons[] = $reason; + $this->status = self::STATUS_UNDELIVERABLE; + return $this; + } + + public function setDeliverable($reason) { + $this->reasons[] = $reason; + $this->status = self::STATUS_DELIVERABLE; return $this; } public function isDeliverable() { - return empty($this->reasons); + return ($this->status === self::STATUS_DELIVERABLE); } - public function getUndeliverableReasons() { + public function getDeliverabilityReasons() { return $this->reasons; } + public static function getReasonDescription($reason) { + $descriptions = array( + self::REASON_DISABLED => pht( + 'This user is disabled; disabled users do not receive mail.'), + self::REASON_BOT => pht( + 'This user is a bot; bot accounts do not receive mail.'), + self::REASON_NO_ADDRESS => pht( + 'Unable to load an email address for this PHID.'), + self::REASON_EXTERNAL_TYPE => pht( + 'Only external accounts of type "email" are deliverable; this '. + 'account has a different type.'), + self::REASON_UNMAILABLE => pht( + 'This PHID type does not correspond to a mailable object.'), + self::REASON_RESPONSE => pht( + 'This message is a response to another email message, and this '. + 'recipient received the original email message, so we are not '. + 'sending them this substantially similar message (for example, '. + 'the sender used "Reply All" instead of "Reply" in response to '. + 'mail from Phabricator).'), + self::REASON_SELF => pht( + 'This recipient is the user whose actions caused delivery of '. + 'this message, but they have set preferences so they do not '. + 'receive mail about their own actions (Settings > Email '. + 'Preferences > Self Actions).'), + self::REASON_MAIL_DISABLED => pht( + 'This recipient has disabled all email notifications '. + '(Settings > Email Preferences > Email Notifications).'), + self::REASON_MAILTAGS => pht( + 'This mail has tags which control which users receive it, and '. + 'this recipient has not elected to receive mail with any of '. + 'the tags on this message (Settings > Email Preferences).'), + self::REASON_UNLOADABLE => pht( + 'Unable to load user record for this PHID.'), + self::REASON_FORCE => pht( + 'Delivery of this mail is forced and ignores deliver preferences. '. + 'Mail which uses forced delivery is usually related to account '. + 'management or authentication. For example, password reset email '. + 'ignores mail preferences.'), + ); + + return idx($descriptions, $reason, pht('Unknown Reason ("%s")', $reason)); + } + + } diff --git a/src/applications/metamta/query/PhabricatorMetaMTAActorQuery.php b/src/applications/metamta/query/PhabricatorMetaMTAActorQuery.php index 58178edb1..1f593472a 100644 --- a/src/applications/metamta/query/PhabricatorMetaMTAActorQuery.php +++ b/src/applications/metamta/query/PhabricatorMetaMTAActorQuery.php @@ -1,192 +1,182 @@ <?php final class PhabricatorMetaMTAActorQuery extends PhabricatorQuery { private $phids = array(); private $viewer; public function setViewer(PhabricatorUser $viewer) { $this->viewer = $viewer; return $this; } public function getViewer() { return $this->viewer; } public function withPHIDs(array $phids) { $this->phids = $phids; return $this; } public function execute() { $phids = array_fuse($this->phids); $actors = array(); $type_map = array(); foreach ($phids as $phid) { $type_map[phid_get_type($phid)][] = $phid; $actors[$phid] = id(new PhabricatorMetaMTAActor())->setPHID($phid); } // TODO: Move this to PhabricatorPHIDType, or the objects, or some // interface. foreach ($type_map as $type => $phids) { switch ($type) { case PhabricatorPeopleUserPHIDType::TYPECONST: $this->loadUserActors($actors, $phids); break; case PhabricatorPeopleExternalPHIDType::TYPECONST: $this->loadExternalUserActors($actors, $phids); break; case PhabricatorMailingListListPHIDType::TYPECONST: $this->loadMailingListActors($actors, $phids); break; default: $this->loadUnknownActors($actors, $phids); break; } } return $actors; } private function loadUserActors(array $actors, array $phids) { assert_instances_of($actors, 'PhabricatorMetaMTAActor'); $emails = id(new PhabricatorUserEmail())->loadAllWhere( 'userPHID IN (%Ls) AND isPrimary = 1', $phids); $emails = mpull($emails, null, 'getUserPHID'); $users = id(new PhabricatorPeopleQuery()) ->setViewer($this->getViewer()) ->withPHIDs($phids) ->execute(); $users = mpull($users, null, 'getPHID'); foreach ($phids as $phid) { $actor = $actors[$phid]; $user = idx($users, $phid); if (!$user) { - $actor->setUndeliverable( - pht('Unable to load user record for this PHID.')); + $actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_UNLOADABLE); } else { $actor->setName($this->getUserName($user)); if ($user->getIsDisabled()) { - $actor->setUndeliverable( - pht('This user is disabled; disabled users do not receive mail.')); + $actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_DISABLED); } if ($user->getIsSystemAgent()) { - $actor->setUndeliverable( - pht('This user is a bot; bot accounts do not receive mail.')); + $actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_BOT); } // NOTE: We do send email to unapproved users, and to unverified users, // because it would otherwise be impossible to get them to verify their // email addresses. Possibly we should white-list this kind of mail and // deny all other types of mail. } $email = idx($emails, $phid); if (!$email) { - $actor->setUndeliverable( - pht('Unable to load email record for this PHID.')); + $actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_NO_ADDRESS); } else { $actor->setEmailAddress($email->getAddress()); } } } private function loadExternalUserActors(array $actors, array $phids) { assert_instances_of($actors, 'PhabricatorMetaMTAActor'); $xusers = id(new PhabricatorExternalAccountQuery()) ->setViewer($this->getViewer()) ->withPHIDs($phids) ->execute(); $xusers = mpull($xusers, null, 'getPHID'); foreach ($phids as $phid) { $actor = $actors[$phid]; $xuser = idx($xusers, $phid); if (!$xuser) { - $actor->setUndeliverable( - pht('Unable to load external user record for this PHID.')); + $actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_UNLOADABLE); continue; } $actor->setName($xuser->getDisplayName()); if ($xuser->getAccountType() != 'email') { - $actor->setUndeliverable( - pht( - 'Only external accounts of type "email" are deliverable; this '. - 'account has a different type.')); + $actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_EXTERNAL_TYPE); continue; } $actor->setEmailAddress($xuser->getAccountID()); } } private function loadMailingListActors(array $actors, array $phids) { assert_instances_of($actors, 'PhabricatorMetaMTAActor'); $lists = id(new PhabricatorMailingListQuery()) ->setViewer($this->getViewer()) ->withPHIDs($phids) ->execute(); $lists = mpull($lists, null, 'getPHID'); foreach ($phids as $phid) { $actor = $actors[$phid]; $list = idx($lists, $phid); if (!$list) { - $actor->setUndeliverable( - pht( - 'Unable to load mailing list record for this PHID.')); + $actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_UNLOADABLE); continue; } $actor->setName($list->getName()); $actor->setEmailAddress($list->getEmail()); } } private function loadUnknownActors(array $actors, array $phids) { foreach ($phids as $phid) { $actor = $actors[$phid]; - $actor->setUndeliverable(pht('This PHID type is not mailable.')); + $actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_UNMAILABLE); } } /** * Small helper function to make sure we format the username properly as * specified by the `metamta.user-address-format` configuration value. */ private function getUserName(PhabricatorUser $user) { $format = PhabricatorEnv::getEnvConfig('metamta.user-address-format'); switch ($format) { case 'short': $name = $user->getUserName(); break; case 'real': $name = strlen($user->getRealName()) ? $user->getRealName() : $user->getUserName(); break; case 'full': default: $name = $user->getFullName(); break; } return $name; } } diff --git a/src/applications/metamta/storage/PhabricatorMetaMTAMail.php b/src/applications/metamta/storage/PhabricatorMetaMTAMail.php index 7bf19a935..5d5cccf37 100644 --- a/src/applications/metamta/storage/PhabricatorMetaMTAMail.php +++ b/src/applications/metamta/storage/PhabricatorMetaMTAMail.php @@ -1,964 +1,951 @@ <?php /** * @task recipients Managing Recipients */ final class PhabricatorMetaMTAMail extends PhabricatorMetaMTADAO { const STATUS_QUEUE = 'queued'; const STATUS_SENT = 'sent'; const STATUS_FAIL = 'fail'; const STATUS_VOID = 'void'; const RETRY_DELAY = 5; protected $parameters; protected $status; protected $message; protected $relatedPHID; private $recipientExpansionMap; public function __construct() { $this->status = self::STATUS_QUEUE; $this->parameters = array(); parent::__construct(); } protected function getConfiguration() { return array( self::CONFIG_SERIALIZATION => array( 'parameters' => self::SERIALIZATION_JSON, ), self::CONFIG_COLUMN_SCHEMA => array( 'status' => 'text32', 'relatedPHID' => 'phid?', // T6203/NULLABILITY // This should just be empty if there's no body. 'message' => 'text?', ), self::CONFIG_KEY_SCHEMA => array( 'status' => array( 'columns' => array('status'), ), 'relatedPHID' => array( 'columns' => array('relatedPHID'), ), 'key_created' => array( 'columns' => array('dateCreated'), ), ), ) + parent::getConfiguration(); } protected function setParam($param, $value) { $this->parameters[$param] = $value; return $this; } protected function getParam($param, $default = null) { return idx($this->parameters, $param, $default); } /** * Set tags (@{class:MetaMTANotificationType} constants) which identify the * content of this mail in a general way. These tags are used to allow users * to opt out of receiving certain types of mail, like updates when a task's * projects change. * * @param list<const> List of @{class:MetaMTANotificationType} constants. * @return this */ public function setMailTags(array $tags) { $this->setParam('mailtags', array_unique($tags)); return $this; } public function getMailTags() { return $this->getParam('mailtags', array()); } /** * In Gmail, conversations will be broken if you reply to a thread and the * server sends back a response without referencing your Message-ID, even if * it references a Message-ID earlier in the thread. To avoid this, use the * parent email's message ID explicitly if it's available. This overwrites the * "In-Reply-To" and "References" headers we would otherwise generate. This * needs to be set whenever an action is triggered by an email message. See * T251 for more details. * * @param string The "Message-ID" of the email which precedes this one. * @return this */ public function setParentMessageID($id) { $this->setParam('parent-message-id', $id); return $this; } public function getParentMessageID() { return $this->getParam('parent-message-id'); } public function getSubject() { return $this->getParam('subject'); } public function addTos(array $phids) { $phids = array_unique($phids); $this->setParam('to', $phids); return $this; } public function addRawTos(array $raw_email) { // Strip addresses down to bare emails, since the MailAdapter API currently // requires we pass it just the address (like `alincoln@logcabin.org`), not // a full string like `"Abraham Lincoln" <alincoln@logcabin.org>`. foreach ($raw_email as $key => $email) { $object = new PhutilEmailAddress($email); $raw_email[$key] = $object->getAddress(); } $this->setParam('raw-to', $raw_email); return $this; } public function addCCs(array $phids) { $phids = array_unique($phids); $this->setParam('cc', $phids); return $this; } public function setExcludeMailRecipientPHIDs(array $exclude) { $this->setParam('exclude', $exclude); return $this; } private function getExcludeMailRecipientPHIDs() { return $this->getParam('exclude', array()); } public function getTranslation(array $objects) { $default_translation = PhabricatorEnv::getEnvConfig('translation.provider'); $return = null; $recipients = array_merge( idx($this->parameters, 'to', array()), idx($this->parameters, 'cc', array())); foreach (array_select_keys($objects, $recipients) as $object) { $translation = null; if ($object instanceof PhabricatorUser) { $translation = $object->getTranslation(); } if (!$translation) { $translation = $default_translation; } if ($return && $translation != $return) { return $default_translation; } $return = $translation; } if (!$return) { $return = $default_translation; } return $return; } public function addPHIDHeaders($name, array $phids) { foreach ($phids as $phid) { $this->addHeader($name, '<'.$phid.'>'); } return $this; } public function addHeader($name, $value) { $this->parameters['headers'][] = array($name, $value); return $this; } public function addAttachment(PhabricatorMetaMTAAttachment $attachment) { $this->parameters['attachments'][] = $attachment->toDictionary(); return $this; } public function getAttachments() { $dicts = $this->getParam('attachments'); $result = array(); foreach ($dicts as $dict) { $result[] = PhabricatorMetaMTAAttachment::newFromDictionary($dict); } return $result; } public function setAttachments(array $attachments) { assert_instances_of($attachments, 'PhabricatorMetaMTAAttachment'); $this->setParam('attachments', mpull($attachments, 'toDictionary')); return $this; } public function setFrom($from) { $this->setParam('from', $from); return $this; } public function setRawFrom($raw_email, $raw_name) { $this->setParam('raw-from', array($raw_email, $raw_name)); return $this; } public function setReplyTo($reply_to) { $this->setParam('reply-to', $reply_to); return $this; } public function setSubject($subject) { $this->setParam('subject', $subject); return $this; } public function setSubjectPrefix($prefix) { $this->setParam('subject-prefix', $prefix); return $this; } public function setVarySubjectPrefix($prefix) { $this->setParam('vary-subject-prefix', $prefix); return $this; } public function setBody($body) { $this->setParam('body', $body); return $this; } public function setHTMLBody($html) { $this->setParam('html-body', $html); return $this; } public function getBody() { return $this->getParam('body'); } public function getHTMLBody() { return $this->getParam('html-body'); } public function setIsErrorEmail($is_error) { $this->setParam('is-error', $is_error); return $this; } public function getIsErrorEmail() { return $this->getParam('is-error', false); } public function getToPHIDs() { return $this->getParam('to', array()); } public function getRawToAddresses() { return $this->getParam('raw-to', array()); } public function getCcPHIDs() { return $this->getParam('cc', array()); } /** * Force delivery of a message, even if recipients have preferences which * would otherwise drop the message. * * This is primarily intended to let users who don't want any email still * receive things like password resets. * * @param bool True to force delivery despite user preferences. * @return this */ public function setForceDelivery($force) { $this->setParam('force', $force); return $this; } public function getForceDelivery() { return $this->getParam('force', false); } /** * Flag that this is an auto-generated bulk message and should have bulk * headers added to it if appropriate. Broadly, this means some flavor of * "Precedence: bulk" or similar, but is implementation and configuration * dependent. * * @param bool True if the mail is automated bulk mail. * @return this */ public function setIsBulk($is_bulk) { $this->setParam('is-bulk', $is_bulk); return $this; } /** * Use this method to set an ID used for message threading. MetaMTA will * set appropriate headers (Message-ID, In-Reply-To, References and * Thread-Index) based on the capabilities of the underlying mailer. * * @param string Unique identifier, appropriate for use in a Message-ID, * In-Reply-To or References headers. * @param bool If true, indicates this is the first message in the thread. * @return this */ public function setThreadID($thread_id, $is_first_message = false) { $this->setParam('thread-id', $thread_id); $this->setParam('is-first-message', $is_first_message); return $this; } /** * Save a newly created mail to the database. The mail will eventually be * delivered by the MetaMTA daemon. * * @return this */ public function saveAndSend() { return $this->save(); } public function save() { if ($this->getID()) { return parent::save(); } // NOTE: When mail is sent from CLI scripts that run tasks in-process, we // may re-enter this method from within scheduleTask(). The implementation // is intended to avoid anything awkward if we end up reentering this // method. $this->openTransaction(); // Save to generate a task ID. $result = parent::save(); // Queue a task to send this mail. $mailer_task = PhabricatorWorker::scheduleTask( 'PhabricatorMetaMTAWorker', $this->getID(), array( 'priority' => PhabricatorWorker::PRIORITY_ALERTS, )); $this->saveTransaction(); return $result; } public function buildDefaultMailer() { return PhabricatorEnv::newObjectFromConfig('metamta.mail-adapter'); } /** * Attempt to deliver an email immediately, in this process. * * @param bool Try to deliver this email even if it has already been * delivered or is in backoff after a failed delivery attempt. * @param PhabricatorMailImplementationAdapter Use a specific mail adapter, * instead of the default. * * @return void */ public function sendNow( $force_send = false, PhabricatorMailImplementationAdapter $mailer = null) { if ($mailer === null) { $mailer = $this->buildDefaultMailer(); } if (!$force_send) { if ($this->getStatus() != self::STATUS_QUEUE) { throw new Exception('Trying to send an already-sent mail!'); } } try { $params = $this->parameters; $actors = $this->loadAllActors(); $deliverable_actors = $this->filterDeliverableActors($actors); $default_from = PhabricatorEnv::getEnvConfig('metamta.default-address'); if (empty($params['from'])) { $mailer->setFrom($default_from); } $is_first = idx($params, 'is-first-message'); unset($params['is-first-message']); $is_threaded = (bool)idx($params, 'thread-id'); $reply_to_name = idx($params, 'reply-to-name', ''); unset($params['reply-to-name']); $add_cc = array(); $add_to = array(); // Only try to use preferences if everything is multiplexed, so we // get consistent behavior. $use_prefs = self::shouldMultiplexAllMail(); $prefs = null; if ($use_prefs) { // If multiplexing is enabled, some recipients will be in "Cc" // rather than "To". We'll move them to "To" later (or supply a // dummy "To") but need to look for the recipient in either the // "To" or "Cc" fields here. $target_phid = head(idx($params, 'to', array())); if (!$target_phid) { $target_phid = head(idx($params, 'cc', array())); } if ($target_phid) { $user = id(new PhabricatorUser())->loadOneWhere( 'phid = %s', $target_phid); if ($user) { $prefs = $user->loadPreferences(); } } } foreach ($params as $key => $value) { switch ($key) { case 'raw-from': list($from_email, $from_name) = $value; $mailer->setFrom($from_email, $from_name); break; case 'from': $from = $value; $actor_email = null; $actor_name = null; $actor = idx($actors, $from); if ($actor) { $actor_email = $actor->getEmailAddress(); $actor_name = $actor->getName(); } $can_send_as_user = $actor_email && PhabricatorEnv::getEnvConfig('metamta.can-send-as-user'); if ($can_send_as_user) { $mailer->setFrom($actor_email, $actor_name); } else { $from_email = coalesce($actor_email, $default_from); $from_name = coalesce($actor_name, pht('Phabricator')); if (empty($params['reply-to'])) { $params['reply-to'] = $from_email; $params['reply-to-name'] = $from_name; } $mailer->setFrom($default_from, $from_name); } break; case 'reply-to': $mailer->addReplyTo($value, $reply_to_name); break; case 'to': $to_phids = $this->expandRecipients($value); $to_actors = array_select_keys($deliverable_actors, $to_phids); $add_to = array_merge( $add_to, mpull($to_actors, 'getEmailAddress')); break; case 'raw-to': $add_to = array_merge($add_to, $value); break; case 'cc': $cc_phids = $this->expandRecipients($value); $cc_actors = array_select_keys($deliverable_actors, $cc_phids); $add_cc = array_merge( $add_cc, mpull($cc_actors, 'getEmailAddress')); break; case 'headers': foreach ($value as $pair) { list($header_key, $header_value) = $pair; // NOTE: If we have \n in a header, SES rejects the email. $header_value = str_replace("\n", ' ', $header_value); $mailer->addHeader($header_key, $header_value); } break; case 'attachments': $value = $this->getAttachments(); foreach ($value as $attachment) { $mailer->addAttachment( $attachment->getData(), $attachment->getFilename(), $attachment->getMimeType()); } break; case 'subject': $subject = array(); if ($is_threaded) { $add_re = PhabricatorEnv::getEnvConfig('metamta.re-prefix'); if ($prefs) { $add_re = $prefs->getPreference( PhabricatorUserPreferences::PREFERENCE_RE_PREFIX, $add_re); } if ($add_re) { $subject[] = 'Re:'; } } $subject[] = trim(idx($params, 'subject-prefix')); $vary_prefix = idx($params, 'vary-subject-prefix'); if ($vary_prefix != '') { $use_subject = PhabricatorEnv::getEnvConfig( 'metamta.vary-subjects'); if ($prefs) { $use_subject = $prefs->getPreference( PhabricatorUserPreferences::PREFERENCE_VARY_SUBJECT, $use_subject); } if ($use_subject) { $subject[] = $vary_prefix; } } $subject[] = $value; $mailer->setSubject(implode(' ', array_filter($subject))); break; case 'is-bulk': if ($value) { $mailer->addHeader('Precedence', 'bulk'); } break; case 'thread-id': // NOTE: Gmail freaks out about In-Reply-To and References which // aren't in the form "<string@domain.tld>"; this is also required // by RFC 2822, although some clients are more liberal in what they // accept. $domain = PhabricatorEnv::getEnvConfig('metamta.domain'); $value = '<'.$value.'@'.$domain.'>'; if ($is_first && $mailer->supportsMessageIDHeader()) { $mailer->addHeader('Message-ID', $value); } else { $in_reply_to = $value; $references = array($value); $parent_id = $this->getParentMessageID(); if ($parent_id) { $in_reply_to = $parent_id; // By RFC 2822, the most immediate parent should appear last // in the "References" header, so this order is intentional. $references[] = $parent_id; } $references = implode(' ', $references); $mailer->addHeader('In-Reply-To', $in_reply_to); $mailer->addHeader('References', $references); } $thread_index = $this->generateThreadIndex($value, $is_first); $mailer->addHeader('Thread-Index', $thread_index); break; case 'mailtags': // Handled below. break; case 'subject-prefix': case 'vary-subject-prefix': // Handled above. break; default: // Just discard. } } $body = idx($params, 'body', ''); $max = PhabricatorEnv::getEnvConfig('metamta.email-body-limit'); if (strlen($body) > $max) { $body = id(new PhutilUTF8StringTruncator()) ->setMaximumBytes($max) ->truncateString($body); $body .= "\n"; $body .= pht('(This email was truncated at %d bytes.)', $max); } $mailer->setBody($body); $html_emails = false; if ($use_prefs && $prefs) { $html_emails = $prefs->getPreference( PhabricatorUserPreferences::PREFERENCE_HTML_EMAILS, $html_emails); } if ($html_emails && isset($params['html-body'])) { $mailer->setHTMLBody($params['html-body']); } if (!$add_to && !$add_cc) { $this->setStatus(self::STATUS_VOID); $this->setMessage( 'Message has no valid recipients: all To/Cc are disabled, invalid, '. 'or configured not to receive this mail.'); return $this->save(); } if ($this->getIsErrorEmail()) { $all_recipients = array_merge($add_to, $add_cc); if ($this->shouldRateLimitMail($all_recipients)) { $this->setStatus(self::STATUS_VOID); $this->setMessage( pht( 'This is an error email, but one or more recipients have '. 'exceeded the error email rate limit. Declining to deliver '. 'message.')); return $this->save(); } } if (PhabricatorEnv::getEnvConfig('phabricator.silent')) { $this->setStatus(self::STATUS_VOID); $this->setMessage( pht( 'Phabricator is running in silent mode. See `phabricator.silent` '. 'in the configuration to change this setting.')); return $this->save(); } $mailer->addHeader('X-Phabricator-Sent-This-Message', 'Yes'); $mailer->addHeader('X-Mail-Transport-Agent', 'MetaMTA'); // Some clients respect this to suppress OOF and other auto-responses. $mailer->addHeader('X-Auto-Response-Suppress', 'All'); // If the message has mailtags, filter out any recipients who don't want // to receive this type of mail. $mailtags = $this->getParam('mailtags'); if ($mailtags) { $tag_header = array(); foreach ($mailtags as $mailtag) { $tag_header[] = '<'.$mailtag.'>'; } $tag_header = implode(', ', $tag_header); $mailer->addHeader('X-Phabricator-Mail-Tags', $tag_header); } // Some mailers require a valid "To:" in order to deliver mail. If we // don't have any "To:", try to fill it in with a placeholder "To:". // If that also fails, move the "Cc:" line to "To:". if (!$add_to) { $placeholder_key = 'metamta.placeholder-to-recipient'; $placeholder = PhabricatorEnv::getEnvConfig($placeholder_key); if ($placeholder !== null) { $add_to = array($placeholder); } else { $add_to = $add_cc; $add_cc = array(); } } $add_to = array_unique($add_to); $add_cc = array_diff(array_unique($add_cc), $add_to); $mailer->addTos($add_to); if ($add_cc) { $mailer->addCCs($add_cc); } } catch (Exception $ex) { $this ->setStatus(self::STATUS_FAIL) ->setMessage($ex->getMessage()) ->save(); throw $ex; } try { $ok = $mailer->send(); if (!$ok) { // TODO: At some point, we should clean this up and make all mailers // throw. throw new Exception( pht('Mail adapter encountered an unexpected, unspecified failure.')); } $this->setStatus(self::STATUS_SENT); $this->save(); return $this; } catch (PhabricatorMetaMTAPermanentFailureException $ex) { $this ->setStatus(self::STATUS_FAIL) ->setMessage($ex->getMessage()) ->save(); throw $ex; } catch (Exception $ex) { $this ->setMessage($ex->getMessage()."\n".$ex->getTraceAsString()) ->save(); throw $ex; } } public static function getReadableStatus($status_code) { static $readable = array( self::STATUS_QUEUE => 'Queued for Delivery', self::STATUS_FAIL => 'Delivery Failed', self::STATUS_SENT => 'Sent', self::STATUS_VOID => 'Void', ); $status_code = coalesce($status_code, '?'); return idx($readable, $status_code, $status_code); } private function generateThreadIndex($seed, $is_first_mail) { // When threading, Outlook ignores the 'References' and 'In-Reply-To' // headers that most clients use. Instead, it uses a custom 'Thread-Index' // header. The format of this header is something like this (from // camel-exchange-folder.c in Evolution Exchange): /* A new post to a folder gets a 27-byte-long thread index. (The value * is apparently unique but meaningless.) Each reply to a post gets a * 32-byte-long thread index whose first 27 bytes are the same as the * parent's thread index. Each reply to any of those gets a * 37-byte-long thread index, etc. The Thread-Index header contains a * base64 representation of this value. */ // The specific implementation uses a 27-byte header for the first email // a recipient receives, and a random 5-byte suffix (32 bytes total) // thereafter. This means that all the replies are (incorrectly) siblings, // but it would be very difficult to keep track of the entire tree and this // gets us reasonable client behavior. $base = substr(md5($seed), 0, 27); if (!$is_first_mail) { // Not totally sure, but it seems like outlook orders replies by // thread-index rather than timestamp, so to get these to show up in the // right order we use the time as the last 4 bytes. $base .= ' '.pack('N', time()); } return base64_encode($base); } public static function shouldMultiplexAllMail() { return PhabricatorEnv::getEnvConfig('metamta.one-mail-per-recipient'); } /* -( Managing Recipients )------------------------------------------------ */ /** * Get all of the recipients for this mail, after preference filters are * applied. This list has all objects to whom delivery will be attempted. * * Note that this expands recipients into their members, because delivery * is never directly attempted to aggregate actors like projects. * * @return list<phid> A list of all recipients to whom delivery will be * attempted. * @task recipients */ public function buildRecipientList() { $actors = $this->loadAllActors(); $actors = $this->filterDeliverableActors($actors); return mpull($actors, 'getPHID'); } public function loadAllActors() { $actor_phids = $this->getAllActorPHIDs(); $actor_phids = $this->expandRecipients($actor_phids); return $this->loadActors($actor_phids); } private function getAllActorPHIDs() { return array_merge( array($this->getParam('from')), $this->getToPHIDs(), $this->getCcPHIDs()); } /** * Expand a list of recipient PHIDs (possibly including aggregate recipients * like projects) into a deaggregated list of individual recipient PHIDs. * For example, this will expand project PHIDs into a list of the project's * members. * * @param list<phid> List of recipient PHIDs, possibly including aggregate * recipients. * @return list<phid> Deaggregated list of mailable recipients. */ private function expandRecipients(array $phids) { if ($this->recipientExpansionMap === null) { $all_phids = $this->getAllActorPHIDs(); $this->recipientExpansionMap = id(new PhabricatorMetaMTAMemberQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPHIDs($all_phids) ->execute(); } $results = array(); foreach ($phids as $phid) { foreach ($this->recipientExpansionMap[$phid] as $recipient_phid) { $results[$recipient_phid] = $recipient_phid; } } return array_keys($results); } private function filterDeliverableActors(array $actors) { assert_instances_of($actors, 'PhabricatorMetaMTAActor'); $deliverable_actors = array(); foreach ($actors as $phid => $actor) { if ($actor->isDeliverable()) { $deliverable_actors[$phid] = $actor; } } return $deliverable_actors; } private function loadActors(array $actor_phids) { $actor_phids = array_filter($actor_phids); $viewer = PhabricatorUser::getOmnipotentUser(); $actors = id(new PhabricatorMetaMTAActorQuery()) ->setViewer($viewer) ->withPHIDs($actor_phids) ->execute(); if (!$actors) { return array(); } if ($this->getForceDelivery()) { // If we're forcing delivery, skip all the opt-out checks. + foreach ($actors as $actor) { + $actor->setDeliverable(PhabricatorMetaMTAActor::REASON_FORCE); + } return $actors; } // Exclude explicit recipients. foreach ($this->getExcludeMailRecipientPHIDs() as $phid) { $actor = idx($actors, $phid); if (!$actor) { continue; } - $actor->setUndeliverable( - pht( - 'This message is a response to another email message, and this '. - 'recipient received the original email message, so we are not '. - 'sending them this substantially similar message (for example, '. - 'the sender used "Reply All" instead of "Reply" in response to '. - 'mail from Phabricator).')); + $actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_RESPONSE); } // Exclude the actor if their preferences are set. $from_phid = $this->getParam('from'); $from_actor = idx($actors, $from_phid); if ($from_actor) { $from_user = id(new PhabricatorPeopleQuery()) ->setViewer($viewer) ->withPHIDs(array($from_phid)) ->execute(); $from_user = head($from_user); if ($from_user) { $pref_key = PhabricatorUserPreferences::PREFERENCE_NO_SELF_MAIL; $exclude_self = $from_user ->loadPreferences() ->getPreference($pref_key); if ($exclude_self) { - $from_actor->setUndeliverable( - pht( - 'This recipient is the user whose actions caused delivery of '. - 'this message, but they have set preferences so they do not '. - 'receive mail about their own actions (Settings > Email '. - 'Preferences > Self Actions).')); + $from_actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_SELF); } } } $all_prefs = id(new PhabricatorUserPreferences())->loadAllWhere( 'userPHID in (%Ls)', $actor_phids); $all_prefs = mpull($all_prefs, null, 'getUserPHID'); // Exclude recipients who don't want any mail. foreach ($all_prefs as $phid => $prefs) { $exclude = $prefs->getPreference( PhabricatorUserPreferences::PREFERENCE_NO_MAIL, false); if ($exclude) { $actors[$phid]->setUndeliverable( - pht( - 'This recipient has disabled all email notifications '. - '(Settings > Email Preferences > Email Notifications).')); + PhabricatorMetaMTAActor::REASON_MAIL_DISABLED); } } $value_email = PhabricatorUserPreferences::MAILTAG_PREFERENCE_EMAIL; // Exclude all recipients who have set preferences to not receive this type // of email (for example, a user who says they don't want emails about task // CC changes). $tags = $this->getParam('mailtags'); if ($tags) { foreach ($all_prefs as $phid => $prefs) { $user_mailtags = $prefs->getPreference( PhabricatorUserPreferences::PREFERENCE_MAILTAGS, array()); // The user must have elected to receive mail for at least one // of the mailtags. $send = false; foreach ($tags as $tag) { if (((int)idx($user_mailtags, $tag, $value_email)) == $value_email) { $send = true; break; } } if (!$send) { $actors[$phid]->setUndeliverable( - pht( - 'This mail has tags which control which users receive it, and '. - 'this recipient has not elected to receive mail with any of '. - 'the tags on this message (Settings > Email Preferences).')); + PhabricatorMetaMTAActor::REASON_MAILTAGS); } } } return $actors; } private function shouldRateLimitMail(array $all_recipients) { try { PhabricatorSystemActionEngine::willTakeAction( $all_recipients, new PhabricatorMetaMTAErrorMailAction(), 1); return false; } catch (PhabricatorSystemActionRateLimitException $ex) { return true; } } }