diff --git a/src/applications/metamta/query/PhabricatorMetaMTAActor.php b/src/applications/metamta/query/PhabricatorMetaMTAActor.php index a15a5b2390..1f4cc7da12 100644 --- a/src/applications/metamta/query/PhabricatorMetaMTAActor.php +++ b/src/applications/metamta/query/PhabricatorMetaMTAActor.php @@ -1,167 +1,181 @@ 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 setIsVerified($is_verified) { + $this->isVerified = $is_verified; + return $this; + } + + public function getIsVerified() { + return $this->isVerified; + } + 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 ($this->status === self::STATUS_DELIVERABLE); } public function getDeliverabilityReasons() { return $this->reasons; } public static function isDeliveryReason($reason) { switch ($reason) { case self::REASON_NONE: case self::REASON_FORCE: case self::REASON_FORCE_HERALD: case self::REASON_ROUTE_AS_MAIL: return true; default: // All other reasons cause the message to not be delivered. return false; } } public static function getReasonName($reason) { $names = array( self::REASON_NONE => pht('None'), self::REASON_DISABLED => pht('Disabled Recipient'), self::REASON_BOT => pht('Bot Recipient'), self::REASON_NO_ADDRESS => pht('No Address'), self::REASON_EXTERNAL_TYPE => pht('External Recipient'), self::REASON_UNMAILABLE => pht('Not Mailable'), self::REASON_RESPONSE => pht('Similar Reply'), self::REASON_SELF => pht('Self Mail'), self::REASON_MAIL_DISABLED => pht('Mail Disabled'), self::REASON_MAILTAGS => pht('Mail Tags'), self::REASON_UNLOADABLE => pht('Bad Recipient'), self::REASON_FORCE => pht('Forced Mail'), self::REASON_FORCE_HERALD => pht('Forced by Herald'), self::REASON_ROUTE_AS_NOTIFICATION => pht('Route as Notification'), self::REASON_ROUTE_AS_MAIL => pht('Route as Mail'), + self::REASON_UNVERIFIED => pht('Address Not Verified'), ); return idx($names, $reason, pht('Unknown ("%s")', $reason)); } public static function getReasonDescription($reason) { $descriptions = array( self::REASON_NONE => pht( 'No special rules affected this mail.'), 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.'), self::REASON_FORCE_HERALD => pht( 'This recipient was added by a "Send me an Email" rule in Herald, '. 'which overrides some delivery settings.'), self::REASON_ROUTE_AS_NOTIFICATION => pht( 'This message was downgraded to a notification by outbound mail '. 'rules in Herald.'), self::REASON_ROUTE_AS_MAIL => pht( 'This message was upgraded to email by outbound mail rules '. 'in Herald.'), + self::REASON_UNVERIFIED => pht( + 'This recipient does not have a verified primary email address.'), ); 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 38b04d6883..18b8063ee1 100644 --- a/src/applications/metamta/query/PhabricatorMetaMTAActorQuery.php +++ b/src/applications/metamta/query/PhabricatorMetaMTAActorQuery.php @@ -1,158 +1,166 @@ 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; 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) ->needUserSettings(true) ->execute(); $users = mpull($users, null, 'getPHID'); foreach ($phids as $phid) { $actor = $actors[$phid]; $user = idx($users, $phid); if (!$user) { $actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_UNLOADABLE); } else { $actor->setName($this->getUserName($user)); if ($user->getIsDisabled()) { $actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_DISABLED); } if ($user->getIsSystemAgent()) { $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(PhabricatorMetaMTAActor::REASON_NO_ADDRESS); } else { $actor->setEmailAddress($email->getAddress()); + $actor->setIsVerified($email->getIsVerified()); } } } 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(PhabricatorMetaMTAActor::REASON_UNLOADABLE); continue; } $actor->setName($xuser->getDisplayName()); if ($xuser->getAccountType() != 'email') { $actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_EXTERNAL_TYPE); continue; } $actor->setEmailAddress($xuser->getAccountID()); + + // NOTE: This effectively drops all outbound mail to unrecognized + // addresses unless "phabricator.allow-email-users" is set. See T12237 + // for context. + $allow_key = 'phabricator.allow-email-users'; + $allow_value = PhabricatorEnv::getEnvConfig($allow_key); + $actor->setIsVerified((bool)$allow_value); } } private function loadUnknownActors(array $actors, array $phids) { foreach ($phids as $phid) { $actor = $actors[$phid]; $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/receiver/PhabricatorMailReceiver.php b/src/applications/metamta/receiver/PhabricatorMailReceiver.php index 07d364b21f..05b44f364a 100644 --- a/src/applications/metamta/receiver/PhabricatorMailReceiver.php +++ b/src/applications/metamta/receiver/PhabricatorMailReceiver.php @@ -1,271 +1,275 @@ applicationEmail = $email; return $this; } public function getApplicationEmail() { return $this->applicationEmail; } abstract public function isEnabled(); abstract public function canAcceptMail(PhabricatorMetaMTAReceivedMail $mail); final protected function canAcceptApplicationMail( PhabricatorApplication $app, PhabricatorMetaMTAReceivedMail $mail) { $application_emails = id(new PhabricatorMetaMTAApplicationEmailQuery()) ->setViewer($this->getViewer()) ->withApplicationPHIDs(array($app->getPHID())) ->execute(); foreach ($mail->getToAddresses() as $to_address) { foreach ($application_emails as $application_email) { $create_address = $application_email->getAddress(); if ($this->matchAddresses($create_address, $to_address)) { $this->setApplicationEmail($application_email); return true; } } } return false; } abstract protected function processReceivedMail( PhabricatorMetaMTAReceivedMail $mail, PhabricatorUser $sender); final public function receiveMail( PhabricatorMetaMTAReceivedMail $mail, PhabricatorUser $sender) { $this->processReceivedMail($mail, $sender); } public function getViewer() { return PhabricatorUser::getOmnipotentUser(); } public function validateSender( PhabricatorMetaMTAReceivedMail $mail, PhabricatorUser $sender) { $failure_reason = null; if ($sender->getIsDisabled()) { $failure_reason = pht( 'Your account (%s) is disabled, so you can not interact with '. 'Phabricator over email.', $sender->getUsername()); } else if ($sender->getIsStandardUser()) { if (!$sender->getIsApproved()) { $failure_reason = pht( 'Your account (%s) has not been approved yet. You can not interact '. 'with Phabricator over email until your account is approved.', $sender->getUsername()); } else if (PhabricatorUserEmail::isEmailVerificationRequired() && !$sender->getIsEmailVerified()) { $failure_reason = pht( 'You have not verified the email address for your account (%s). '. 'You must verify your email address before you can interact '. 'with Phabricator over email.', $sender->getUsername()); } } if ($failure_reason) { throw new PhabricatorMetaMTAReceivedMailProcessingException( MetaMTAReceivedMailStatus::STATUS_DISABLED_SENDER, $failure_reason); } } /** * Identifies the sender's user account for a piece of received mail. Note * that this method does not validate that the sender is who they say they * are, just that they've presented some credential which corresponds to a * recognizable user. */ public function loadSender(PhabricatorMetaMTAReceivedMail $mail) { $raw_from = $mail->getHeader('From'); $from = self::getRawAddress($raw_from); $reasons = array(); // Try to find a user with this email address. $user = PhabricatorUser::loadOneWithEmailAddress($from); if ($user) { return $user; } else { $reasons[] = pht( 'This email was sent from "%s", but that address is not recognized by '. 'Phabricator and does not correspond to any known user account.', $raw_from); } // If we missed on "From", try "Reply-To" if we're configured for it. $raw_reply_to = $mail->getHeader('Reply-To'); if (strlen($raw_reply_to)) { $reply_to_key = 'metamta.insecure-auth-with-reply-to'; $allow_reply_to = PhabricatorEnv::getEnvConfig($reply_to_key); if ($allow_reply_to) { $reply_to = self::getRawAddress($raw_reply_to); $user = PhabricatorUser::loadOneWithEmailAddress($reply_to); if ($user) { return $user; } else { $reasons[] = pht( 'Phabricator is configured to authenticate users using the '. '"Reply-To" header, but the reply address ("%s") on this '. 'message does not correspond to any known user account.', $raw_reply_to); } } else { $reasons[] = pht( '(Phabricator is not configured to authenticate users using the '. '"Reply-To" header, so it was ignored.)'); } } // If we don't know who this user is, load or create an external user // account for them if we're configured for it. $email_key = 'phabricator.allow-email-users'; $allow_email_users = PhabricatorEnv::getEnvConfig($email_key); if ($allow_email_users) { $from_obj = new PhutilEmailAddress($from); $xuser = id(new PhabricatorExternalAccountQuery()) ->setViewer($this->getViewer()) ->withAccountTypes(array('email')) ->withAccountDomains(array($from_obj->getDomainName(), 'self')) ->withAccountIDs(array($from_obj->getAddress())) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->loadOneOrCreate(); return $xuser->getPhabricatorUser(); } else { + // NOTE: Currently, we'll always drop this mail (since it's headed to + // an unverified recipient). See T12237. These details are still useful + // because they'll appear in the mail logs and Mail web UI. + $reasons[] = pht( 'Phabricator is also not configured to allow unknown external users '. 'to send mail to the system using just an email address.'); $reasons[] = pht( 'To interact with Phabricator, add this address ("%s") to your '. 'account.', $raw_from); } if ($this->getApplicationEmail()) { $application_email = $this->getApplicationEmail(); $default_user_phid = $application_email->getConfigValue( PhabricatorMetaMTAApplicationEmail::CONFIG_DEFAULT_AUTHOR); if ($default_user_phid) { $user = id(new PhabricatorUser())->loadOneWhere( 'phid = %s', $default_user_phid); if ($user) { return $user; } - } - $reasons[] = pht( - "Phabricator is misconfigured, the application email ". - "'%s' is set to user '%s' but that user does not exist.", - $application_email->getAddress(), - $default_user_phid); + $reasons[] = pht( + 'Phabricator is misconfigured: the application email '. + '"%s" is set to user "%s", but that user does not exist.', + $application_email->getAddress(), + $default_user_phid); + } } $reasons = implode("\n\n", $reasons); throw new PhabricatorMetaMTAReceivedMailProcessingException( MetaMTAReceivedMailStatus::STATUS_UNKNOWN_SENDER, $reasons); } /** * Determine if two inbound email addresses are effectively identical. This * method strips and normalizes addresses so that equivalent variations are * correctly detected as identical. For example, these addresses are all * considered to match one another: * * "Abraham Lincoln" * alincoln@example.com * * "Abraham" # With configured prefix. * * @param string Email address. * @param string Another email address. * @return bool True if addresses match. */ public static function matchAddresses($u, $v) { $u = self::getRawAddress($u); $v = self::getRawAddress($v); $u = self::stripMailboxPrefix($u); $v = self::stripMailboxPrefix($v); return ($u === $v); } /** * Strip a global mailbox prefix from an address if it is present. Phabricator * can be configured to prepend a prefix to all reply addresses, which can * make forwarding rules easier to write. A prefix looks like: * * example@phabricator.example.com # No Prefix * phabricator+example@phabricator.example.com # Prefix "phabricator" * * @param string Email address, possibly with a mailbox prefix. * @return string Email address with any prefix stripped. */ public static function stripMailboxPrefix($address) { $address = id(new PhutilEmailAddress($address))->getAddress(); $prefix_key = 'metamta.single-reply-handler-prefix'; $prefix = PhabricatorEnv::getEnvConfig($prefix_key); $len = strlen($prefix); if ($len) { $prefix = $prefix.'+'; $len = $len + 1; } if ($len) { if (!strncasecmp($address, $prefix, $len)) { $address = substr($address, strlen($prefix)); } } return $address; } /** * Reduce an email address to its canonical form. For example, an adddress * like: * * "Abraham Lincoln" < ALincoln@example.com > * * ...will be reduced to: * * alincoln@example.com * * @param string Email address in noncanonical form. * @return string Canonical email address. */ public static function getRawAddress($address) { $address = id(new PhutilEmailAddress($address))->getAddress(); return trim(phutil_utf8_strtolower($address)); } } diff --git a/src/applications/metamta/storage/PhabricatorMetaMTAMail.php b/src/applications/metamta/storage/PhabricatorMetaMTAMail.php index 0c90e43832..7d8a638401 100644 --- a/src/applications/metamta/storage/PhabricatorMetaMTAMail.php +++ b/src/applications/metamta/storage/PhabricatorMetaMTAMail.php @@ -1,1171 +1,1181 @@ status = PhabricatorMailOutboundStatus::STATUS_QUEUE; $this->parameters = array('sensitive' => true); parent::__construct(); } protected function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, self::CONFIG_SERIALIZATION => array( 'parameters' => self::SERIALIZATION_JSON, ), self::CONFIG_COLUMN_SCHEMA => array( 'actorPHID' => 'phid?', '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'), ), 'key_actorPHID' => array( 'columns' => array('actorPHID'), ), 'relatedPHID' => array( 'columns' => array('relatedPHID'), ), 'key_created' => array( 'columns' => array('dateCreated'), ), ), ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhabricatorMetaMTAMailPHIDType::TYPECONST); } protected function setParam($param, $value) { $this->parameters[$param] = $value; return $this; } protected function getParam($param, $default = null) { // Some old mail was saved without parameters because no parameters were // set or encoding failed. Recover in these cases so we can perform // mail migrations, see T9251. if (!is_array($this->parameters)) { $this->parameters = array(); } return idx($this->parameters, $param, $default); } /** * 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 * @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" `. 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 setForceHeraldMailRecipientPHIDs(array $force) { $this->setParam('herald-force-recipients', $force); return $this; } private function getForceHeraldMailRecipientPHIDs() { return $this->getParam('herald-force-recipients', array()); } public function addPHIDHeaders($name, array $phids) { $phids = array_unique($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); $this->setActorPHID($from); return $this; } public function getFrom() { return $this->getParam('from'); } 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 setSensitiveContent($bool) { $this->setParam('sensitive', $bool); return $this; } public function hasSensitiveContent() { return $this->getParam('sensitive', true); } 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 mail ID and PHID. $result = parent::save(); // Write the recipient edges. $editor = new PhabricatorEdgeEditor(); $edge_type = PhabricatorMetaMTAMailHasRecipientEdgeType::EDGECONST; $recipient_phids = array_merge( $this->getToPHIDs(), $this->getCcPHIDs()); $expanded_phids = $this->expandRecipients($recipient_phids); $all_phids = array_unique(array_merge( $recipient_phids, $expanded_phids)); foreach ($all_phids as $curr_phid) { $editor->addEdge($this->getPHID(), $edge_type, $curr_phid); } $editor->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() != PhabricatorMailOutboundStatus::STATUS_QUEUE) { throw new Exception(pht('Trying to send an already-sent mail!')); } } try { $headers = $this->generateHeaders(); $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(); // 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())); } $preferences = $this->loadPreferences($target_phid); 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 'attachments': $value = $this->getAttachments(); foreach ($value as $attachment) { $mailer->addAttachment( $attachment->getData(), $attachment->getFilename(), $attachment->getMimeType()); } break; case 'subject': $subject = array(); if ($is_threaded) { if ($this->shouldAddRePrefix($preferences)) { $subject[] = 'Re:'; } } $subject[] = trim(idx($params, 'subject-prefix')); $vary_prefix = idx($params, 'vary-subject-prefix'); if ($vary_prefix != '') { if ($this->shouldVarySubject($preferences)) { $subject[] = $vary_prefix; } } $subject[] = $value; $mailer->setSubject(implode(' ', array_filter($subject))); break; case 'thread-id': // NOTE: Gmail freaks out about In-Reply-To and References which // aren't in the form ""; 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()) { $headers[] = array('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); $headers[] = array('In-Reply-To', $in_reply_to); $headers[] = array('References', $references); } $thread_index = $this->generateThreadIndex($value, $is_first); $headers[] = array('Thread-Index', $thread_index); break; default: // Other parameters are handled elsewhere or are not relevant to // constructing the message. break; } } $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 = $this->shouldSendHTML($preferences); if ($html_emails && isset($params['html-body'])) { $mailer->setHTMLBody($params['html-body']); } // Pass the headers to the mailer, then save the state so we can show // them in the web UI. foreach ($headers as $header) { list($header_key, $header_value) = $header; $mailer->addHeader($header_key, $header_value); } $this->setParam('headers.sent', $headers); // Save the final deliverability outcomes and reasoning so we can // explain why things happened the way they did. $actor_list = array(); foreach ($actors as $actor) { $actor_list[$actor->getPHID()] = array( 'deliverable' => $actor->isDeliverable(), 'reasons' => $actor->getDeliverabilityReasons(), ); } $this->setParam('actors.sent', $actor_list); $this->setParam('routing.sent', $this->getParam('routing')); $this->setParam('routingmap.sent', $this->getRoutingRuleMap()); if (!$add_to && !$add_cc) { $this->setStatus(PhabricatorMailOutboundStatus::STATUS_VOID); $this->setMessage( pht( '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(PhabricatorMailOutboundStatus::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(PhabricatorMailOutboundStatus::STATUS_VOID); $this->setMessage( pht( 'Phabricator is running in silent mode. See `%s` '. 'in the configuration to change this setting.', 'phabricator.silent')); return $this->save(); } // 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(PhabricatorMailOutboundStatus::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(PhabricatorMailOutboundStatus::STATUS_SENT); $this->save(); return $this; } catch (PhabricatorMetaMTAPermanentFailureException $ex) { $this ->setStatus(PhabricatorMailOutboundStatus::STATUS_FAIL) ->setMessage($ex->getMessage()) ->save(); throw $ex; } catch (Exception $ex) { $this ->setMessage($ex->getMessage()."\n".$ex->getTraceAsString()) ->save(); throw $ex; } } 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 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->getExpandedRecipientPHIDs(); return $this->loadActors($actor_phids); } public function getExpandedRecipientPHIDs() { $actor_phids = $this->getAllActorPHIDs(); return $this->expandRecipients($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 List of recipient PHIDs, possibly including aggregate * recipients. * @return list 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. We don't // bother annotating reasoning on the mail in this case because it should // always be obvious why the mail hit this rule (e.g., it is a password // reset mail). 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(PhabricatorMetaMTAActor::REASON_RESPONSE); } // Before running more rules, save a list of the actors who were // deliverable before we started running preference-based rules. This stops // us from trying to send mail to disabled users just because a Herald rule // added them, for example. $deliverable = array(); foreach ($actors as $phid => $actor) { if ($actor->isDeliverable()) { $deliverable[] = $phid; } } // For the rest of the rules, order matters. We're going to run all the // possible rules in order from weakest to strongest, and let the strongest // matching rule win. The weaker rules leave annotations behind which help // users understand why the mail was routed the way it was. // 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)) ->needUserSettings(true) ->execute(); $from_user = head($from_user); if ($from_user) { $pref_key = PhabricatorEmailSelfActionsSetting::SETTINGKEY; $exclude_self = $from_user->getUserSetting($pref_key); if ($exclude_self) { $from_actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_SELF); } } } $all_prefs = id(new PhabricatorUserPreferencesQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withUserPHIDs($actor_phids) ->needSyntheticPreferences(true) ->execute(); $all_prefs = mpull($all_prefs, null, 'getUserPHID'); $value_email = PhabricatorEmailTagsSetting::VALUE_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->getSettingValue( PhabricatorEmailTagsSetting::SETTINGKEY); // 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( PhabricatorMetaMTAActor::REASON_MAILTAGS); } } } foreach ($deliverable as $phid) { switch ($this->getRoutingRule($phid)) { case PhabricatorMailRoutingRule::ROUTE_AS_NOTIFICATION: $actors[$phid]->setUndeliverable( PhabricatorMetaMTAActor::REASON_ROUTE_AS_NOTIFICATION); break; case PhabricatorMailRoutingRule::ROUTE_AS_MAIL: $actors[$phid]->setDeliverable( PhabricatorMetaMTAActor::REASON_ROUTE_AS_MAIL); break; default: // No change. break; } } // If recipients were initially deliverable and were added by "Send me an // email" Herald rules, annotate them as such and make them deliverable // again, overriding any changes made by the "self mail" and "mail tags" // settings. $force_recipients = $this->getForceHeraldMailRecipientPHIDs(); $force_recipients = array_fuse($force_recipients); if ($force_recipients) { foreach ($deliverable as $phid) { if (isset($force_recipients[$phid])) { $actors[$phid]->setDeliverable( PhabricatorMetaMTAActor::REASON_FORCE_HERALD); } } } // Exclude recipients who don't want any mail. This rule is very strong // and runs last. foreach ($all_prefs as $phid => $prefs) { $exclude = $prefs->getSettingValue( PhabricatorEmailNotificationsSetting::SETTINGKEY); if ($exclude) { $actors[$phid]->setUndeliverable( PhabricatorMetaMTAActor::REASON_MAIL_DISABLED); } } + // Unless delivery was forced earlier (password resets, confirmation mail), + // never send mail to unverified addresses. + foreach ($actors as $phid => $actor) { + if ($actor->getIsVerified()) { + continue; + } + + $actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_UNVERIFIED); + } + return $actors; } private function shouldRateLimitMail(array $all_recipients) { try { PhabricatorSystemActionEngine::willTakeAction( $all_recipients, new PhabricatorMetaMTAErrorMailAction(), 1); return false; } catch (PhabricatorSystemActionRateLimitException $ex) { return true; } } public function delete() { $this->openTransaction(); queryfx( $this->establishConnection('w'), 'DELETE FROM %T WHERE src = %s AND type = %d', PhabricatorEdgeConfig::TABLE_NAME_EDGE, $this->getPHID(), PhabricatorMetaMTAMailHasRecipientEdgeType::EDGECONST); $ret = parent::delete(); $this->saveTransaction(); return $ret; } public function generateHeaders() { $headers = array(); $headers[] = array('X-Phabricator-Sent-This-Message', 'Yes'); $headers[] = array('X-Mail-Transport-Agent', 'MetaMTA'); // Some clients respect this to suppress OOF and other auto-responses. $headers[] = array('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); $headers[] = array('X-Phabricator-Mail-Tags', $tag_header); } $value = $this->getParam('headers', array()); 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); $headers[] = array($header_key, $header_value); } $is_bulk = $this->getParam('is-bulk'); if ($is_bulk) { $headers[] = array('Precedence', 'bulk'); } return $headers; } public function getDeliveredHeaders() { return $this->getParam('headers.sent'); } public function getDeliveredActors() { return $this->getParam('actors.sent'); } public function getDeliveredRoutingRules() { return $this->getParam('routing.sent'); } public function getDeliveredRoutingMap() { return $this->getParam('routingmap.sent'); } /* -( Routing )------------------------------------------------------------ */ public function addRoutingRule($routing_rule, $phids, $reason_phid) { $routing = $this->getParam('routing', array()); $routing[] = array( 'routingRule' => $routing_rule, 'phids' => $phids, 'reasonPHID' => $reason_phid, ); $this->setParam('routing', $routing); // Throw the routing map away so we rebuild it. $this->routingMap = null; return $this; } private function getRoutingRule($phid) { $map = $this->getRoutingRuleMap(); $info = idx($map, $phid, idx($map, 'default')); if ($info) { return idx($info, 'rule'); } return null; } private function getRoutingRuleMap() { if ($this->routingMap === null) { $map = array(); $routing = $this->getParam('routing', array()); foreach ($routing as $route) { $phids = $route['phids']; if ($phids === null) { $phids = array('default'); } foreach ($phids as $phid) { $new_rule = $route['routingRule']; $current_rule = idx($map, $phid); if ($current_rule === null) { $is_stronger = true; } else { $is_stronger = PhabricatorMailRoutingRule::isStrongerThan( $new_rule, $current_rule); } if ($is_stronger) { $map[$phid] = array( 'rule' => $new_rule, 'reason' => $route['reasonPHID'], ); } } } $this->routingMap = $map; } return $this->routingMap; } /* -( Preferences )-------------------------------------------------------- */ private function loadPreferences($target_phid) { $viewer = PhabricatorUser::getOmnipotentUser(); if (self::shouldMultiplexAllMail()) { $preferences = id(new PhabricatorUserPreferencesQuery()) ->setViewer($viewer) ->withUserPHIDs(array($target_phid)) ->needSyntheticPreferences(true) ->executeOne(); if ($preferences) { return $preferences; } } return PhabricatorUserPreferences::loadGlobalPreferences($viewer); } private function shouldAddRePrefix(PhabricatorUserPreferences $preferences) { $value = $preferences->getSettingValue( PhabricatorEmailRePrefixSetting::SETTINGKEY); return ($value == PhabricatorEmailRePrefixSetting::VALUE_RE_PREFIX); } private function shouldVarySubject(PhabricatorUserPreferences $preferences) { $value = $preferences->getSettingValue( PhabricatorEmailVarySubjectsSetting::SETTINGKEY); return ($value == PhabricatorEmailVarySubjectsSetting::VALUE_VARY_SUBJECTS); } private function shouldSendHTML(PhabricatorUserPreferences $preferences) { $value = $preferences->getSettingValue( PhabricatorEmailFormatSetting::SETTINGKEY); return ($value == PhabricatorEmailFormatSetting::VALUE_HTML_EMAIL); } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, ); } public function getPolicy($capability) { return PhabricatorPolicies::POLICY_NOONE; } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { $actor_phids = $this->getExpandedRecipientPHIDs(); return in_array($viewer->getPHID(), $actor_phids); } public function describeAutomaticCapability($capability) { return pht( 'The mail sender and message recipients can always see the mail.'); } } diff --git a/src/applications/metamta/storage/__tests__/PhabricatorMetaMTAMailTestCase.php b/src/applications/metamta/storage/__tests__/PhabricatorMetaMTAMailTestCase.php index 2f892eb084..635913439d 100644 --- a/src/applications/metamta/storage/__tests__/PhabricatorMetaMTAMailTestCase.php +++ b/src/applications/metamta/storage/__tests__/PhabricatorMetaMTAMailTestCase.php @@ -1,238 +1,254 @@ true, ); } public function testMailSendFailures() { $user = $this->generateNewTestUser(); $phid = $user->getPHID(); // Normally, the send should succeed. $mail = new PhabricatorMetaMTAMail(); $mail->addTos(array($phid)); $mailer = new PhabricatorMailImplementationTestAdapter(); $mail->sendNow($force = true, $mailer); $this->assertEqual( PhabricatorMailOutboundStatus::STATUS_SENT, $mail->getStatus()); // When the mailer fails temporarily, the mail should remain queued. $mail = new PhabricatorMetaMTAMail(); $mail->addTos(array($phid)); $mailer = new PhabricatorMailImplementationTestAdapter(); $mailer->setFailTemporarily(true); try { $mail->sendNow($force = true, $mailer); } catch (Exception $ex) { // Ignore. } $this->assertEqual( PhabricatorMailOutboundStatus::STATUS_QUEUE, $mail->getStatus()); // When the mailer fails permanently, the mail should be failed. $mail = new PhabricatorMetaMTAMail(); $mail->addTos(array($phid)); $mailer = new PhabricatorMailImplementationTestAdapter(); $mailer->setFailPermanently(true); try { $mail->sendNow($force = true, $mailer); } catch (Exception $ex) { // Ignore. } $this->assertEqual( PhabricatorMailOutboundStatus::STATUS_FAIL, $mail->getStatus()); } public function testRecipients() { $user = $this->generateNewTestUser(); $phid = $user->getPHID(); $mailer = new PhabricatorMailImplementationTestAdapter(); $mail = new PhabricatorMetaMTAMail(); $mail->addTos(array($phid)); $this->assertTrue( in_array($phid, $mail->buildRecipientList()), pht('"To" is a recipient.')); // Test that the "No Self Mail" and "No Mail" preferences work correctly. $mail->setFrom($phid); $this->assertTrue( in_array($phid, $mail->buildRecipientList()), pht('"From" does not exclude recipients by default.')); $user = $this->writeSetting( $user, PhabricatorEmailSelfActionsSetting::SETTINGKEY, true); $this->assertFalse( in_array($phid, $mail->buildRecipientList()), pht('"From" excludes recipients with no-self-mail set.')); $user = $this->writeSetting( $user, PhabricatorEmailSelfActionsSetting::SETTINGKEY, null); $this->assertTrue( in_array($phid, $mail->buildRecipientList()), pht('"From" does not exclude recipients by default.')); $user = $this->writeSetting( $user, PhabricatorEmailNotificationsSetting::SETTINGKEY, true); $this->assertFalse( in_array($phid, $mail->buildRecipientList()), pht('"From" excludes recipients with no-mail set.')); $mail->setForceDelivery(true); $this->assertTrue( in_array($phid, $mail->buildRecipientList()), pht('"From" includes no-mail recipients when forced.')); $mail->setForceDelivery(false); $user = $this->writeSetting( $user, PhabricatorEmailNotificationsSetting::SETTINGKEY, null); $this->assertTrue( in_array($phid, $mail->buildRecipientList()), pht('"From" does not exclude recipients by default.')); // Test that explicit exclusion works correctly. $mail->setExcludeMailRecipientPHIDs(array($phid)); $this->assertFalse( in_array($phid, $mail->buildRecipientList()), pht('Explicit exclude excludes recipients.')); $mail->setExcludeMailRecipientPHIDs(array()); // Test that mail tag preferences exclude recipients. $user = $this->writeSetting( $user, PhabricatorEmailTagsSetting::SETTINGKEY, array( 'test-tag' => false, )); $mail->setMailTags(array('test-tag')); $this->assertFalse( in_array($phid, $mail->buildRecipientList()), pht('Tag preference excludes recipients.')); $user = $this->writeSetting( $user, PhabricatorEmailTagsSetting::SETTINGKEY, null); $this->assertTrue( in_array($phid, $mail->buildRecipientList()), 'Recipients restored after tag preference removed.'); + + $email = id(new PhabricatorUserEmail())->loadOneWhere( + 'userPHID = %s AND isPrimary = 1', + $phid); + + $email->setIsVerified(0)->save(); + + $this->assertFalse( + in_array($phid, $mail->buildRecipientList()), + pht('Mail not sent to unverified address.')); + + $email->setIsVerified(1)->save(); + + $this->assertTrue( + in_array($phid, $mail->buildRecipientList()), + pht('Mail sent to verified address.')); } public function testThreadIDHeaders() { $this->runThreadIDHeadersWithConfiguration(true, true); $this->runThreadIDHeadersWithConfiguration(true, false); $this->runThreadIDHeadersWithConfiguration(false, true); $this->runThreadIDHeadersWithConfiguration(false, false); } private function runThreadIDHeadersWithConfiguration( $supports_message_id, $is_first_mail) { $mailer = new PhabricatorMailImplementationTestAdapter( array( 'supportsMessageIDHeader' => $supports_message_id, )); $thread_id = ''; $mail = new PhabricatorMetaMTAMail(); $mail->setThreadID($thread_id, $is_first_mail); $mail->sendNow($force = true, $mailer); $guts = $mailer->getGuts(); $dict = ipull($guts['headers'], 1, 0); if ($is_first_mail && $supports_message_id) { $expect_message_id = true; $expect_in_reply_to = false; $expect_references = false; } else { $expect_message_id = false; $expect_in_reply_to = true; $expect_references = true; } $case = ''; $this->assertTrue( isset($dict['Thread-Index']), pht('Expect Thread-Index header for case %s.', $case)); $this->assertEqual( $expect_message_id, isset($dict['Message-ID']), pht( 'Expectation about existence of Message-ID header for case %s.', $case)); $this->assertEqual( $expect_in_reply_to, isset($dict['In-Reply-To']), pht( 'Expectation about existence of In-Reply-To header for case %s.', $case)); $this->assertEqual( $expect_references, isset($dict['References']), pht( 'Expectation about existence of References header for case %s.', $case)); } private function writeSetting(PhabricatorUser $user, $key, $value) { $preferences = PhabricatorUserPreferences::loadUserPreferences($user); $editor = id(new PhabricatorUserPreferencesEditor()) ->setActor($user) ->setContentSource($this->newContentSource()) ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true); $xactions = array(); $xactions[] = $preferences->newTransaction($key, $value); $editor->applyTransactions($preferences, $xactions); return id(new PhabricatorPeopleQuery()) ->setViewer($user) ->withIDs(array($user->getID())) ->executeOne(); } }