diff --git a/src/applications/config/check/PhabricatorExtraConfigSetupCheck.php b/src/applications/config/check/PhabricatorExtraConfigSetupCheck.php index 5f84376e67..708bbae0cb 100644 --- a/src/applications/config/check/PhabricatorExtraConfigSetupCheck.php +++ b/src/applications/config/check/PhabricatorExtraConfigSetupCheck.php @@ -1,388 +1,393 @@ newIssue('config.unknown.'.$key) ->setShortName($short) ->setName($name) ->setSummary($summary); $stack = PhabricatorEnv::getConfigSourceStack(); $stack = $stack->getStack(); $found = array(); $found_local = false; $found_database = false; foreach ($stack as $source_key => $source) { $value = $source->getKeys(array($key)); if ($value) { $found[] = $source->getName(); if ($source instanceof PhabricatorConfigDatabaseSource) { $found_database = true; } if ($source instanceof PhabricatorConfigLocalSource) { $found_local = true; } } } $message = $message."\n\n".pht( 'This configuration value is defined in these %d '. 'configuration source(s): %s.', count($found), implode(', ', $found)); $issue->setMessage($message); if ($found_local) { $command = csprintf('phabricator/ $ ./bin/config delete %s', $key); $issue->addCommand($command); } if ($found_database) { $issue->addPhabricatorConfig($key); } } } /** * Return a map of deleted config options. Keys are option keys; values are * explanations of what happened to the option. */ public static function getAncientConfig() { $reason_auth = pht( 'This option has been migrated to the "Auth" application. Your old '. 'configuration is still in effect, but now stored in "Auth" instead of '. 'configuration. Going forward, you can manage authentication from '. 'the web UI.'); $auth_config = array( 'controller.oauth-registration', 'auth.password-auth-enabled', 'facebook.auth-enabled', 'facebook.registration-enabled', 'facebook.auth-permanent', 'facebook.application-id', 'facebook.application-secret', 'facebook.require-https-auth', 'github.auth-enabled', 'github.registration-enabled', 'github.auth-permanent', 'github.application-id', 'github.application-secret', 'google.auth-enabled', 'google.registration-enabled', 'google.auth-permanent', 'google.application-id', 'google.application-secret', 'ldap.auth-enabled', 'ldap.hostname', 'ldap.port', 'ldap.base_dn', 'ldap.search_attribute', 'ldap.search-first', 'ldap.username-attribute', 'ldap.real_name_attributes', 'ldap.activedirectory_domain', 'ldap.version', 'ldap.referrals', 'ldap.anonymous-user-name', 'ldap.anonymous-user-password', 'ldap.start-tls', 'disqus.auth-enabled', 'disqus.registration-enabled', 'disqus.auth-permanent', 'disqus.application-id', 'disqus.application-secret', 'phabricator.oauth-uri', 'phabricator.auth-enabled', 'phabricator.registration-enabled', 'phabricator.auth-permanent', 'phabricator.application-id', 'phabricator.application-secret', ); $ancient_config = array_fill_keys($auth_config, $reason_auth); $markup_reason = pht( 'Custom remarkup rules are now added by subclassing '. '%s or %s.', 'PhabricatorRemarkupCustomInlineRule', 'PhabricatorRemarkupCustomBlockRule'); $session_reason = pht( 'Sessions now expire and are garbage collected rather than having an '. 'arbitrary concurrency limit.'); $differential_field_reason = pht( 'All Differential fields are now managed through the configuration '. 'option "%s". Use that option to configure which fields are shown.', 'differential.fields'); $reply_domain_reason = pht( 'Individual application reply handler domains have been removed. '. 'Configure a reply domain with "%s".', 'metamta.reply-handler-domain'); $reply_handler_reason = pht( 'Reply handlers can no longer be overridden with configuration.'); $monospace_reason = pht( 'Phabricator no longer supports global customization of monospaced '. 'fonts.'); $public_mail_reason = pht( 'Inbound mail addresses are now configured for each application '. 'in the Applications tool.'); $gc_reason = pht( 'Garbage collectors are now configured with "%s".', 'bin/garbage set-policy'); $aphlict_reason = pht( 'Configuration of the notification server has changed substantially. '. 'For discussion, see T10794.'); $stale_reason = pht( 'The Differential revision list view age UI elements have been removed '. 'to simplify the interface.'); $global_settings_reason = pht( 'The "Re: Prefix" and "Vary Subjects" settings are now configured '. 'in global settings.'); $dashboard_reason = pht( 'This option has been removed, you can use Dashboards to provide '. 'homepage customization. See T11533 for more details.'); $elastic_reason = pht( 'Elasticsearch is now configured with "%s".', 'cluster.search'); $mailers_reason = pht( 'Inbound and outbound mail is now configured with "cluster.mailers".'); $ancient_config += array( 'phid.external-loaders' => pht( 'External loaders have been replaced. Extend `%s` '. 'to implement new PHID and handle types.', 'PhabricatorPHIDType'), 'maniphest.custom-task-extensions-class' => pht( 'Maniphest fields are now loaded automatically. '. 'You can configure them with `%s`.', 'maniphest.fields'), 'maniphest.custom-fields' => pht( 'Maniphest fields are now defined in `%s`. '. 'Existing definitions have been migrated.', 'maniphest.custom-field-definitions'), 'differential.custom-remarkup-rules' => $markup_reason, 'differential.custom-remarkup-block-rules' => $markup_reason, 'auth.sshkeys.enabled' => pht( 'SSH keys are now actually useful, so they are always enabled.'), 'differential.anonymous-access' => pht( 'Phabricator now has meaningful global access controls. See `%s`.', 'policy.allow-public'), 'celerity.resource-path' => pht( 'An alternate resource map is no longer supported. Instead, use '. 'multiple maps. See T4222.'), 'metamta.send-immediately' => pht( 'Mail is now always delivered by the daemons.'), 'auth.sessions.conduit' => $session_reason, 'auth.sessions.web' => $session_reason, 'tokenizer.ondemand' => pht( 'Phabricator now manages typeahead strategies automatically.'), 'differential.revision-custom-detail-renderer' => pht( 'Obsolete; use standard rendering events instead.'), 'differential.show-host-field' => $differential_field_reason, 'differential.show-test-plan-field' => $differential_field_reason, 'differential.field-selector' => $differential_field_reason, 'phabricator.show-beta-applications' => pht( 'This option has been renamed to `%s` to emphasize the '. 'unfinished nature of many prototype applications. '. 'Your existing setting has been migrated.', 'phabricator.show-prototypes'), 'notification.user' => pht( 'The notification server no longer requires root permissions. Start '. 'the server as the user you want it to run under.'), 'notification.debug' => pht( 'Notifications no longer have a dedicated debugging mode.'), 'translation.provider' => pht( 'The translation implementation has changed and providers are no '. 'longer used or supported.'), 'config.mask' => pht( 'Use `%s` instead of this option.', 'config.hide'), 'phd.start-taskmasters' => pht( 'Taskmasters now use an autoscaling pool. You can configure the '. 'pool size with `%s`.', 'phd.taskmasters'), 'storage.engine-selector' => pht( 'Phabricator now automatically discovers available storage engines '. 'at runtime.'), 'storage.upload-size-limit' => pht( 'Phabricator now supports arbitrarily large files. Consult the '. 'documentation for configuration details.'), 'security.allow-outbound-http' => pht( 'This option has been replaced with the more granular option `%s`.', 'security.outbound-blacklist'), 'metamta.reply.show-hints' => pht( 'Phabricator no longer shows reply hints in mail.'), 'metamta.differential.reply-handler-domain' => $reply_domain_reason, 'metamta.diffusion.reply-handler-domain' => $reply_domain_reason, 'metamta.macro.reply-handler-domain' => $reply_domain_reason, 'metamta.maniphest.reply-handler-domain' => $reply_domain_reason, 'metamta.pholio.reply-handler-domain' => $reply_domain_reason, 'metamta.diffusion.reply-handler' => $reply_handler_reason, 'metamta.differential.reply-handler' => $reply_handler_reason, 'metamta.maniphest.reply-handler' => $reply_handler_reason, 'metamta.package.reply-handler' => $reply_handler_reason, 'metamta.precedence-bulk' => pht( 'Phabricator now always sends transaction mail with '. '"Precedence: bulk" to improve deliverability.'), 'style.monospace' => $monospace_reason, 'style.monospace.windows' => $monospace_reason, 'search.engine-selector' => pht( 'Phabricator now automatically discovers available search engines '. 'at runtime.'), 'metamta.files.public-create-email' => $public_mail_reason, 'metamta.maniphest.public-create-email' => $public_mail_reason, 'metamta.maniphest.default-public-author' => $public_mail_reason, 'metamta.paste.public-create-email' => $public_mail_reason, 'security.allow-conduit-act-as-user' => pht( 'Impersonating users over the API is no longer supported.'), 'feed.public' => pht('The framable public feed is no longer supported.'), 'auth.login-message' => pht( 'This configuration option has been replaced with a modular '. 'handler. See T9346.'), 'gcdaemon.ttl.herald-transcripts' => $gc_reason, 'gcdaemon.ttl.daemon-logs' => $gc_reason, 'gcdaemon.ttl.differential-parse-cache' => $gc_reason, 'gcdaemon.ttl.markup-cache' => $gc_reason, 'gcdaemon.ttl.task-archive' => $gc_reason, 'gcdaemon.ttl.general-cache' => $gc_reason, 'gcdaemon.ttl.conduit-logs' => $gc_reason, 'phd.variant-config' => pht( 'This configuration is no longer relevant because daemons '. 'restart automatically on configuration changes.'), 'notification.ssl-cert' => $aphlict_reason, 'notification.ssl-key' => $aphlict_reason, 'notification.pidfile' => $aphlict_reason, 'notification.log' => $aphlict_reason, 'notification.enabled' => $aphlict_reason, 'notification.client-uri' => $aphlict_reason, 'notification.server-uri' => $aphlict_reason, 'metamta.differential.unified-comment-context' => pht( 'Inline comments are now always rendered with a limited amount '. 'of context.'), 'differential.days-fresh' => $stale_reason, 'differential.days-stale' => $stale_reason, 'metamta.re-prefix' => $global_settings_reason, 'metamta.vary-subjects' => $global_settings_reason, 'ui.custom-header' => pht( 'This option has been replaced with `ui.logo`, which provides more '. 'flexible configuration options.'), 'welcome.html' => $dashboard_reason, 'maniphest.priorities.unbreak-now' => $dashboard_reason, 'maniphest.priorities.needs-triage' => $dashboard_reason, 'mysql.implementation' => pht( 'Phabricator now automatically selects the best available '. 'MySQL implementation.'), 'mysql.configuration-provider' => pht( 'Phabricator now has application-level management of partitioning '. 'and replicas.'), 'search.elastic.host' => $elastic_reason, 'search.elastic.namespace' => $elastic_reason, 'metamta.mail-adapter' => $mailers_reason, 'amazon-ses.access-key' => $mailers_reason, 'amazon-ses.secret-key' => $mailers_reason, 'amazon-ses.endpoint' => $mailers_reason, 'mailgun.domain' => $mailers_reason, 'mailgun.api-key' => $mailers_reason, 'phpmailer.mailer' => $mailers_reason, 'phpmailer.smtp-host' => $mailers_reason, 'phpmailer.smtp-port' => $mailers_reason, 'phpmailer.smtp-protocol' => $mailers_reason, 'phpmailer.smtp-user' => $mailers_reason, 'phpmailer.smtp-password' => $mailers_reason, 'phpmailer.smtp-encoding' => $mailers_reason, 'sendgrid.api-user' => $mailers_reason, 'sendgrid.api-key' => $mailers_reason, 'celerity.resource-hash' => pht( 'This option generally did not prove useful. Resource hash keys '. 'are now managed automatically.'), 'celerity.enable-deflate' => pht( 'Resource deflation is now managed automatically.'), 'celerity.minify' => pht( 'Resource minification is now managed automatically.'), + + 'metamta.domain' => pht( + 'Mail thread IDs are now generated automatically.'), + 'metamta.placeholder-to-recipient' => pht( + 'Placeholder recipients are now generated automatically.'), ); return $ancient_config; } } diff --git a/src/applications/config/option/PhabricatorMetaMTAConfigOptions.php b/src/applications/config/option/PhabricatorMetaMTAConfigOptions.php index 0ad8c5c0d7..7b13303b51 100644 --- a/src/applications/config/option/PhabricatorMetaMTAConfigOptions.php +++ b/src/applications/config/option/PhabricatorMetaMTAConfigOptions.php @@ -1,314 +1,299 @@ deformat(pht(<<deformat(pht(<<deformat(pht(<<deformat(pht(<<deformat(pht(<<deformat(pht(<<deformat(pht(<<deformat(pht(<<deformat(pht(<<deformat(pht(<<deformat(pht(<<deformat(pht(<<deformat(pht(<<' - `real`: 'George Washington ' - `full`: 'gwashington (George Washington) ' The default is `full`. EODOC )); $mailers_description = $this->deformat(pht(<<newOption('cluster.mailers', 'cluster.mailers', null) ->setHidden(true) ->setDescription($mailers_description), $this->newOption( 'metamta.default-address', 'string', 'noreply@phabricator.example.com') ->setDescription(pht('Default "From" address.')), - $this->newOption( - 'metamta.domain', - 'string', - 'phabricator.example.com') - ->setDescription(pht('Domain used to generate Message-IDs.')), $this->newOption( 'metamta.one-mail-per-recipient', 'bool', true) ->setLocked(true) ->setBoolOptions( array( pht('Send Mail To Each Recipient'), pht('Send Mail To All Recipients'), )) ->setSummary( pht( 'Controls whether Phabricator sends one email with multiple '. 'recipients in the "To:" line, or multiple emails, each with a '. 'single recipient in the "To:" line.')) ->setDescription($one_mail_per_recipient_desc), $this->newOption('metamta.can-send-as-user', 'bool', false) ->setBoolOptions( array( pht('Send as User Taking Action'), pht('Send as Phabricator'), )) ->setSummary( pht( 'Controls whether Phabricator sends email "From" users.')) ->setDescription($send_as_user_desc), $this->newOption( 'metamta.reply-handler-domain', 'string', null) ->setLocked(true) ->setDescription(pht('Domain used for reply email addresses.')) ->addExample('phabricator.example.com', ''), $this->newOption('metamta.recipients.show-hints', 'bool', true) ->setBoolOptions( array( pht('Show Recipient Hints'), pht('No Recipient Hints'), )) ->setSummary(pht('Show "To:" and "Cc:" footer hints in email.')) ->setDescription($recipient_hints_description), $this->newOption('metamta.email-preferences', 'bool', true) ->setBoolOptions( array( pht('Show Email Preferences Link'), pht('No Email Preferences Link'), )) ->setSummary(pht('Show email preferences link in email.')) ->setDescription($email_preferences_description), $this->newOption('metamta.insecure-auth-with-reply-to', 'bool', false) ->setBoolOptions( array( pht('Allow Insecure Reply-To Auth'), pht('Disallow Reply-To Auth'), )) ->setSummary(pht('Trust "Reply-To" headers for authentication.')) ->setDescription($reply_to_description), - $this->newOption('metamta.placeholder-to-recipient', 'string', null) - ->setSummary(pht('Placeholder for mail with only CCs.')) - ->setDescription($placeholder_description), $this->newOption('metamta.public-replies', 'bool', false) ->setBoolOptions( array( pht('Use Public Replies (Less Secure)'), pht('Use Private Replies (More Secure)'), )) ->setSummary( pht( 'Phabricator can use less-secure but mailing list friendly public '. 'reply addresses.')) ->setDescription($public_replies_description), $this->newOption('metamta.single-reply-handler-prefix', 'string', null) ->setSummary( pht('Allow Phabricator to use a single mailbox for all replies.')) ->setDescription($single_description), $this->newOption('metamta.user-address-format', 'enum', 'full') ->setEnumOptions( array( 'short' => pht('Short'), 'real' => pht('Real'), 'full' => pht('Full'), )) ->setSummary(pht('Control how Phabricator renders user names in mail.')) ->setDescription($address_description) ->addExample('gwashington ', 'short') ->addExample('George Washington ', 'real') ->addExample( 'gwashington (George Washington) ', 'full'), $this->newOption('metamta.email-body-limit', 'int', 524288) ->setDescription( pht( 'You can set a limit for the maximum byte size of outbound mail. '. 'Mail which is larger than this limit will be truncated before '. 'being sent. This can be useful if your MTA rejects mail which '. 'exceeds some limit (this is reasonably common). Specify a value '. 'in bytes.')) ->setSummary(pht('Global cap for size of generated emails (bytes).')) ->addExample(524288, pht('Truncate at 512KB')) ->addExample(1048576, pht('Truncate at 1MB')), ); } } diff --git a/src/applications/metamta/storage/PhabricatorMetaMTAMail.php b/src/applications/metamta/storage/PhabricatorMetaMTAMail.php index 36095f6aa8..aeff432af5 100644 --- a/src/applications/metamta/storage/PhabricatorMetaMTAMail.php +++ b/src/applications/metamta/storage/PhabricatorMetaMTAMail.php @@ -1,1626 +1,1634 @@ status = PhabricatorMailOutboundStatus::STATUS_QUEUE; $this->parameters = array( 'sensitive' => true, 'mustEncrypt' => false, ); 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 setMutedPHIDs(array $muted) { $this->setParam('muted', $muted); return $this; } private function getMutedPHIDs() { return $this->getParam('muted', 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 getAttachmentFilePHIDs() { $file_phids = array(); $dictionaries = $this->getParam('attachments'); if ($dictionaries) { foreach ($dictionaries as $dictionary) { $file_phid = idx($dictionary, 'filePHID'); if ($file_phid) { $file_phids[] = $file_phid; } } } return $file_phids; } public function loadAttachedFiles(PhabricatorUser $viewer) { $file_phids = $this->getAttachmentFilePHIDs(); if (!$file_phids) { return array(); } return id(new PhabricatorFileQuery()) ->setViewer($viewer) ->withPHIDs($file_phids) ->execute(); } 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 setMustEncrypt($bool) { return $this->setParam('mustEncrypt', $bool); } public function getMustEncrypt() { return $this->getParam('mustEncrypt', false); } public function setMustEncryptURI($uri) { return $this->setParam('mustEncrypt.uri', $uri); } public function getMustEncryptURI() { return $this->getParam('mustEncrypt.uri'); } public function setMustEncryptSubject($subject) { return $this->setParam('mustEncrypt.subject', $subject); } public function getMustEncryptSubject() { return $this->getParam('mustEncrypt.subject'); } public function setMustEncryptReasons(array $reasons) { return $this->setParam('mustEncryptReasons', $reasons); } public function getMustEncryptReasons() { return $this->getParam('mustEncryptReasons', array()); } public function setMailStamps(array $stamps) { return $this->setParam('stamps', $stamps); } public function getMailStamps() { return $this->getParam('stamps', array()); } public function setMailStampMetadata($metadata) { return $this->setParam('stampMetadata', $metadata); } public function getMailStampMetadata() { return $this->getParam('stampMetadata', array()); } public function getMailerKey() { return $this->getParam('mailer.key'); } public function setTryMailers(array $mailers) { return $this->setParam('mailers.try', $mailers); } 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(); } /** * @return this */ 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(); $this->saveTransaction(); // Queue a task to send this mail. $mailer_task = PhabricatorWorker::scheduleTask( 'PhabricatorMetaMTAWorker', $this->getID(), array( 'priority' => PhabricatorWorker::PRIORITY_ALERTS, )); return $result; } /** * Attempt to deliver an email immediately, in this process. * * @return void */ public function sendNow() { if ($this->getStatus() != PhabricatorMailOutboundStatus::STATUS_QUEUE) { throw new Exception(pht('Trying to send an already-sent mail!')); } $mailers = self::newMailers( array( 'outbound' => true, )); $try_mailers = $this->getParam('mailers.try'); if ($try_mailers) { $mailers = mpull($mailers, null, 'getKey'); $mailers = array_select_keys($mailers, $try_mailers); } return $this->sendWithMailers($mailers); } public static function newMailers(array $constraints) { PhutilTypeSpec::checkMap( $constraints, array( 'types' => 'optional list', 'inbound' => 'optional bool', 'outbound' => 'optional bool', )); $mailers = array(); $config = PhabricatorEnv::getEnvConfig('cluster.mailers'); $adapters = PhabricatorMailImplementationAdapter::getAllAdapters(); $next_priority = -1; foreach ($config as $spec) { $type = $spec['type']; if (!isset($adapters[$type])) { throw new Exception( pht( 'Unknown mailer ("%s")!', $type)); } $key = $spec['key']; $mailer = id(clone $adapters[$type]) ->setKey($key); $priority = idx($spec, 'priority'); if (!$priority) { $priority = $next_priority; $next_priority--; } $mailer->setPriority($priority); $defaults = $mailer->newDefaultOptions(); $options = idx($spec, 'options', array()) + $defaults; $mailer->setOptions($options); $mailer->setSupportsInbound(idx($spec, 'inbound', true)); $mailer->setSupportsOutbound(idx($spec, 'outbound', true)); $mailers[] = $mailer; } // Remove mailers with the wrong types. if (isset($constraints['types'])) { $types = $constraints['types']; $types = array_fuse($types); foreach ($mailers as $key => $mailer) { $mailer_type = $mailer->getAdapterType(); if (!isset($types[$mailer_type])) { unset($mailers[$key]); } } } // If we're only looking for inbound mailers, remove mailers with inbound // support disabled. if (!empty($constraints['inbound'])) { foreach ($mailers as $key => $mailer) { if (!$mailer->getSupportsInbound()) { unset($mailers[$key]); } } } // If we're only looking for outbound mailers, remove mailers with outbound // support disabled. if (!empty($constraints['outbound'])) { foreach ($mailers as $key => $mailer) { if (!$mailer->getSupportsOutbound()) { unset($mailers[$key]); } } } $sorted = array(); $groups = mgroup($mailers, 'getPriority'); krsort($groups); foreach ($groups as $group) { // Reorder services within the same priority group randomly. shuffle($group); foreach ($group as $mailer) { $sorted[] = $mailer; } } foreach ($sorted as $mailer) { $mailer->prepareForSend(); } return $sorted; } public function sendWithMailers(array $mailers) { if (!$mailers) { $any_mailers = self::newMailers(array()); // NOTE: We can end up here with some custom list of "$mailers", like // from a unit test. In that case, this message could be misleading. We // can't really tell if the caller made up the list, so just assume they // aren't tricking us. if ($any_mailers) { $void_message = pht( 'No configured mailers support sending outbound mail.'); } else { $void_message = pht( 'No mailers are configured.'); } return $this ->setStatus(PhabricatorMailOutboundStatus::STATUS_VOID) ->setMessage($void_message) ->save(); } $exceptions = array(); foreach ($mailers as $template_mailer) { $mailer = null; try { $mailer = $this->buildMailer($template_mailer); } catch (Exception $ex) { $exceptions[] = $ex; continue; } if (!$mailer) { // If we don't get a mailer back, that means the mail doesn't // actually need to be sent (for example, because recipients have // declined to receive the mail). Void it and return. return $this ->setStatus(PhabricatorMailOutboundStatus::STATUS_VOID) ->save(); } 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.')); } } catch (PhabricatorMetaMTAPermanentFailureException $ex) { // If any mailer raises a permanent failure, stop trying to send the // mail with other mailers. $this ->setStatus(PhabricatorMailOutboundStatus::STATUS_FAIL) ->setMessage($ex->getMessage()) ->save(); throw $ex; } catch (Exception $ex) { $exceptions[] = $ex; continue; } // Keep track of which mailer actually ended up accepting the message. $mailer_key = $mailer->getKey(); if ($mailer_key !== null) { $this->setParam('mailer.key', $mailer_key); } return $this ->setStatus(PhabricatorMailOutboundStatus::STATUS_SENT) ->save(); } // If we make it here, no mailer could send the mail but no mailer failed // permanently either. We update the error message for the mail, but leave // it in the current status (usually, STATUS_QUEUE) and try again later. $messages = array(); foreach ($exceptions as $ex) { $messages[] = $ex->getMessage(); } $messages = implode("\n\n", $messages); $this ->setMessage($messages) ->save(); if (count($exceptions) === 1) { throw head($exceptions); } throw new PhutilAggregateException( pht('Encountered multiple exceptions while transmitting mail.'), $exceptions); } private function buildMailer(PhabricatorMailImplementationAdapter $mailer) { $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'); $must_encrypt = $this->getMustEncrypt(); $reply_to_name = idx($params, 'reply-to-name', ''); unset($params['reply-to-name']); $add_cc = array(); $add_to = array(); // If we're sending one mail to everyone, 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': // If the mail content must be encrypted, disguise the sender. if ($must_encrypt) { $mailer->setFrom($default_from, pht('Phabricator')); break; } $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': $attached_viewer = PhabricatorUser::getOmnipotentUser(); $files = $this->loadAttachedFiles($attached_viewer); foreach ($files as $file) { $file->attachToObject($this->getPHID()); } // If the mail content must be encrypted, don't add attachments. if ($must_encrypt) { break; } $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')); // If mail content must be encrypted, we replace the subject with // a generic one. if ($must_encrypt) { $encrypt_subject = $this->getMustEncryptSubject(); if (!strlen($encrypt_subject)) { $encrypt_subject = pht('Object Updated'); } $subject[] = $encrypt_subject; } else { $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'); + $domain = $this->newMailDomain(); $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; } } $stamps = $this->getMailStamps(); if ($stamps) { $headers[] = array('X-Phabricator-Stamps', implode(' ', $stamps)); } $raw_body = idx($params, 'body', ''); $body = $raw_body; if ($must_encrypt) { $parts = array(); $encrypt_uri = $this->getMustEncryptURI(); if (!strlen($encrypt_uri)) { $encrypt_phid = $this->getRelatedPHID(); if ($encrypt_phid) { $encrypt_uri = urisprintf( '/object/%s/', $encrypt_phid); } } if (strlen($encrypt_uri)) { $parts[] = pht( 'This secure message is notifying you of a change to this object:'); $parts[] = PhabricatorEnv::getProductionURI($encrypt_uri); } $parts[] = pht( 'The content for this message can only be transmitted over a '. 'secure channel. To view the message content, follow this '. 'link:'); $parts[] = PhabricatorEnv::getProductionURI($this->getURI()); $body = implode("\n\n", $parts); } else { $body = $raw_body; } $body_limit = PhabricatorEnv::getEnvConfig('metamta.email-body-limit'); if (strlen($body) > $body_limit) { $body = id(new PhutilUTF8StringTruncator()) ->setMaximumBytes($body_limit) ->truncateString($body); $body .= "\n"; $body .= pht('(This email was truncated at %d bytes.)', $body_limit); } $mailer->setBody($body); $body_limit -= strlen($body); // If we sent a different message body than we were asked to, record // what we actually sent to make debugging and diagnostics easier. if ($body !== $raw_body) { $this->setParam('body.sent', $body); } if ($must_encrypt) { $send_html = false; } else { $send_html = $this->shouldSendHTML($preferences); } if ($send_html) { $html_body = idx($params, 'html-body'); if (strlen($html_body)) { // NOTE: We just drop the entire HTML body if it won't fit. Safely // truncating HTML is hard, and we already have the text body to fall // back to. if (strlen($html_body) <= $body_limit) { $mailer->setHTMLBody($html_body); $body_limit -= strlen($html_body); } } } // Pass the headers to the mailer, then save the state so we can show // them in the web UI. If the mail must be encrypted, we remove headers // which are not on a strict whitelist to avoid disclosing information. $filtered_headers = $this->filterHeaders($headers, $must_encrypt); foreach ($filtered_headers as $header) { list($header_key, $header_value) = $header; $mailer->addHeader($header_key, $header_value); } $this->setParam('headers.unfiltered', $headers); $this->setParam('headers.sent', $filtered_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->setMessage( pht( 'Message has no valid recipients: all To/Cc are disabled, '. 'invalid, or configured not to receive this mail.')); return null; } if ($this->getIsErrorEmail()) { $all_recipients = array_merge($add_to, $add_cc); if ($this->shouldRateLimitMail($all_recipients)) { $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 null; } } if (PhabricatorEnv::getEnvConfig('phabricator.silent')) { $this->setMessage( pht( 'Phabricator is running in silent mode. See `%s` '. 'in the configuration to change this setting.', 'phabricator.silent')); return null; } - // 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:". + // Some mailers require a valid "To:" in order to deliver mail. If we don't + // have any "To:", fill it in with a placeholder "To:". This allows client + // rules based on whether the recipient is in "To:" or "CC:" to continue + // behaving in the same way. 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(); - } + $void_recipient = $this->newVoidEmailAddress(); + $add_to = array($void_recipient->getAddress()); } $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); } return $mailer; } 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 shouldMailEachRecipient() { 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; } } // Exclude muted recipients. We're doing this after saving deliverability // so that Herald "Send me an email" actions can still punch through a // mute. foreach ($this->getMutedPHIDs() as $muted_phid) { $muted_actor = idx($actors, $muted_phid); if (!$muted_actor) { continue; } $muted_actor->setUndeliverable(PhabricatorMetaMTAActor::REASON_MUTED); } // 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 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'); $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'); } if ($this->getMustEncrypt()) { $headers[] = array('X-Phabricator-Must-Encrypt', 'Yes'); } $related_phid = $this->getRelatedPHID(); if ($related_phid) { $headers[] = array('Thread-Topic', $related_phid); } $headers[] = array('X-Phabricator-Mail-ID', $this->getID()); $unique = Filesystem::readRandomCharacters(16); $headers[] = array('X-Phabricator-Send-Attempt', $unique); return $headers; } public function getDeliveredHeaders() { return $this->getParam('headers.sent'); } public function getUnfilteredHeaders() { $unfiltered = $this->getParam('headers.unfiltered'); if ($unfiltered === null) { // Older versions of Phabricator did not filter headers, and thus did // not record unfiltered headers. If we don't have unfiltered header // data just return the delivered headers for compatibility. return $this->getDeliveredHeaders(); } return $unfiltered; } public function getDeliveredActors() { return $this->getParam('actors.sent'); } public function getDeliveredRoutingRules() { return $this->getParam('routing.sent'); } public function getDeliveredRoutingMap() { return $this->getParam('routingmap.sent'); } public function getDeliveredBody() { return $this->getParam('body.sent'); } private function filterHeaders(array $headers, $must_encrypt) { if (!$must_encrypt) { return $headers; } $whitelist = array( 'In-Reply-To', 'Message-ID', 'Precedence', 'References', 'Thread-Index', 'Thread-Topic', 'X-Mail-Transport-Agent', 'X-Auto-Response-Suppress', 'X-Phabricator-Sent-This-Message', 'X-Phabricator-Must-Encrypt', 'X-Phabricator-Mail-ID', 'X-Phabricator-Send-Attempt', ); // NOTE: The major header we want to drop is "X-Phabricator-Mail-Tags". // This header contains a significant amount of meaningful information // about the object. $whitelist_map = array(); foreach ($whitelist as $term) { $whitelist_map[phutil_utf8_strtolower($term)] = true; } foreach ($headers as $key => $header) { list($name, $value) = $header; $name = phutil_utf8_strtolower($name); if (!isset($whitelist_map[$name])) { unset($headers[$key]); } } return $headers; } public function getURI() { return '/mail/detail/'.$this->getID().'/'; } + private function newMailDomain() { + $install_uri = PhabricatorEnv::getURI('/'); + $install_uri = new PhutilURI($install_uri); + + return $install_uri->getDomain(); + } + + public function newVoidEmailAddress() { + $domain = $this->newMailDomain(); + $address = "void-recipient@{$domain}"; + return new PhutilEmailAddress($address); + } + /* -( 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::shouldMailEachRecipient()) { $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); } public function shouldRenderMailStampsInBody($viewer) { $preferences = $this->loadPreferences($viewer->getPHID()); $value = $preferences->getSettingValue( PhabricatorEmailStampsSetting::SETTINGKEY); return ($value == PhabricatorEmailStampsSetting::VALUE_BODY_STAMPS); } /* -( 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.'); } /* -( PhabricatorDestructibleInterface )----------------------------------- */ public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $files = $this->loadAttachedFiles($engine->getViewer()); foreach ($files as $file) { $engine->destroyObject($file); } $this->delete(); } } diff --git a/src/docs/user/configuration/configuring_outbound_email.diviner b/src/docs/user/configuration/configuring_outbound_email.diviner index db04c21877..10805b77d9 100644 --- a/src/docs/user/configuration/configuring_outbound_email.diviner +++ b/src/docs/user/configuration/configuring_outbound_email.diviner @@ -1,334 +1,333 @@ @title Configuring Outbound Email @group config Instructions for configuring Phabricator to send mail. Overview ======== Phabricator can send outbound email through several different mail services, including a local mailer or various third-party services. Options include: | Send Mail With | Setup | Cost | Inbound | Notes | |---------|-------|------|---------|-------| | Mailgun | Easy | Cheap | Yes | Recommended | | Postmark | Easy | Cheap | Yes | Recommended | | Amazon SES | Easy | Cheap | No | Recommended | | SendGrid | Medium | Cheap | Yes | Discouraged | | External SMTP | Medium | Varies | No | Gmail, etc. | | Local SMTP | Hard | Free | No | sendmail, postfix, etc | | Custom | Hard | Free | No | Write a custom mailer for some other service. | | Drop in a Hole | Easy | Free | No | Drops mail in a deep, dark hole. | See below for details on how to select and configure mail delivery for each mailer. Overall, Mailgun and SES are much easier to set up, and using one of them is recommended. In particular, Mailgun will also let you set up inbound email easily. If you have some internal mail service you'd like to use you can also write a custom mailer, but this requires digging into the code. Phabricator sends mail in the background, so the daemons need to be running for it to be able to deliver mail. You should receive setup warnings if they are not. For more information on using daemons, see @{article:Managing Daemons with phd}. Basics ====== Regardless of how outbound email is delivered, you should configure these keys in your configuration: - **metamta.default-address** determines where mail is sent "From" by default. If your domain is `example.org`, set this to something like `noreply@example.org`. - - **metamta.domain** should be set to your domain, e.g. `example.org`. - **metamta.can-send-as-user** should be left as `false` in most cases, but see the documentation for details. Configuring Mailers =================== Configure one or more mailers by listing them in the the `cluster.mailers` configuration option. Most installs only need to configure one mailer, but you can configure multiple mailers to provide greater availability in the event of a service disruption. A valid `cluster.mailers` configuration looks something like this: ```lang=json [ { "key": "mycompany-mailgun", "type": "mailgun", "options": { "domain": "mycompany.com", "api-key": "..." } }, ... ] ``` The supported keys for each mailer are: - `key`: Required string. A unique name for this mailer. - `type`: Required string. Identifies the type of mailer. See below for options. - `priority`: Optional string. Advanced option which controls load balancing and failover behavior. See below for details. - `options`: Optional map. Additional options for the mailer type. - `inbound`: Optional bool. Use `false` to prevent this mailer from being used to receive inbound mail. - `outbound`: Optional bool. Use `false` to prevent this mailer from being used to send outbound mail. The `type` field can be used to select these third-party mailers: - `mailgun`: Use Mailgun. - `ses`: Use Amazon SES. - `sendgrid`: Use Sendgrid. It also supports these local mailers: - `sendmail`: Use the local `sendmail` binary. - `smtp`: Connect directly to an SMTP server. - `test`: Internal mailer for testing. Does not send mail. You can also write your own mailer by extending `PhabricatorMailImplementationAdapter`. Once you've selected a mailer, find the corresponding section below for instructions on configuring it. Setting Complex Configuration ============================= Mailers can not be edited from the web UI. If mailers could be edited from the web UI, it would give an attacker who compromised an administrator account a lot of power: they could redirect mail to a server they control and then intercept mail for any other account, including password reset mail. For more information about locked configuration options, see @{article:Configuration Guide: Locked and Hidden Configuration}. Setting `cluster.mailers` from the command line using `bin/config set` can be tricky because of shell escaping. The easiest way to do it is to use the `--stdin` flag. First, put your desired configuration in a file like this: ```lang=json, name=mailers.json [ { "key": "test-mailer", "type": "test" } ] ``` Then set the value like this: ``` phabricator/ $ ./bin/config set --stdin cluster.mailers < mailers.json ``` For alternatives and more information on configuration, see @{article:Configuration User Guide: Advanced Configuration} Mailer: Mailgun =============== Mailgun is a third-party email delivery service. You can learn more at . Mailgun is easy to configure and works well. To use this mailer, set `type` to `mailgun`, then configure these `options`: - `api-key`: Required string. Your Mailgun API key. - `domain`: Required string. Your Mailgun domain. Mailer: Postmark ================ Postmark is a third-party email delivery serivice. You can learn more at . To use this mailer, set `type` to `postmark`, then configure these `options`: - `access-token`: Required string. Your Postmark access token. - `inbound-addresses`: Optional list. Address ranges which you will accept inbound Postmark HTTP webook requests from. The default address list is preconfigured with Postmark's address range, so you generally will not need to set or adjust it. The option accepts a list of CIDR ranges, like `1.2.3.4/16` (IPv4) or `::ffff:0:0/96` (IPv6). The default ranges are: ```lang=json [ "50.31.156.6/32" ] ``` The default address ranges were last updated in February 2018, and were documented at: Mailer: Amazon SES ================== Amazon SES is Amazon's cloud email service. You can learn more at . To use this mailer, set `type` to `ses`, then configure these `options`: - `access-key`: Required string. Your Amazon SES access key. - `secret-key`: Required string. Your Amazon SES secret key. - `endpoint`: Required string. Your Amazon SES endpoint. NOTE: Amazon SES **requires you to verify your "From" address**. Configure which "From" address to use by setting "`metamta.default-address`" in your config, then follow the Amazon SES verification process to verify it. You won't be able to send email until you do this! Mailer: SendGrid ================ SendGrid is a third-party email delivery service. You can learn more at . You can configure SendGrid in two ways: you can send via SMTP or via the REST API. To use SMTP, configure Phabricator to use an `smtp` mailer. To use the REST API mailer, set `type` to `sendgrid`, then configure these `options`: - `api-user`: Required string. Your SendGrid login name. - `api-key`: Required string. Your SendGrid API key. NOTE: Users have experienced a number of odd issues with SendGrid, compared to fewer issues with other mailers. We discourage SendGrid unless you're already using it. Mailer: Sendmail ================ This requires a `sendmail` binary to be installed on the system. Most MTAs (e.g., sendmail, qmail, postfix) should do this, but your machine may not have one installed by default. For install instructions, consult the documentation for your favorite MTA. Since you'll be sending the mail yourself, you are subject to things like SPF rules, blackholes, and MTA configuration which are beyond the scope of this document. If you can already send outbound email from the command line or know how to configure it, this option is straightforward. If you have no idea how to do any of this, strongly consider using Mailgun or Amazon SES instead. To use this mailer, set `type` to `sendmail`. There are no `options` to configure. Mailer: STMP ============ You can use this adapter to send mail via an external SMTP server, like Gmail. To use this mailer, set `type` to `smtp`, then configure these `options`: - `host`: Required string. The hostname of your SMTP server. - `port`: Optional int. The port to connect to on your SMTP server. - `user`: Optional string. Username used for authentication. - `password`: Optional string. Password for authentication. - `protocol`: Optional string. Set to `tls` or `ssl` if necessary. Use `ssl` for Gmail. Disable Mail ============ To disable mail, just don't configure any mailers. Testing and Debugging Outbound Email ==================================== You can use the `bin/mail` utility to test, debug, and examine outbound mail. In particular: phabricator/ $ ./bin/mail list-outbound # List outbound mail. phabricator/ $ ./bin/mail show-outbound # Show details about messages. phabricator/ $ ./bin/mail send-test # Send test messages. Run `bin/mail help ` for more help on using these commands. You can monitor daemons using the Daemon Console (`/daemon/`, or click **Daemon Console** from the homepage). Priorities ========== By default, Phabricator will try each mailer in order: it will try the first mailer first. If that fails (for example, because the service is not available at the moment) it will try the second mailer, and so on. If you want to load balance between multiple mailers instead of using one as a primary, you can set `priority`. Phabricator will start with mailers in the highest priority group and go through them randomly, then fall back to the next group. For example, if you have two SMTP servers and you want to balance requests between them and then fall back to Mailgun if both fail, configure priorities like this: ```lang=json [ { "key": "smtp-uswest", "type": "smtp", "priority": 300, "options": "..." }, { "key": "smtp-useast", "type": "smtp", "priority": 300, "options": "..." }, { "key": "mailgun-fallback", "type": "mailgun", "options": "..." } } ``` Phabricator will start with servers in the highest priority group (the group with the **largest** `priority` number). In this example, the highest group is `300`, which has the two SMTP servers. They'll be tried in random order first. If both fail, Phabricator will move on to the next priority group. In this example, there are no other priority groups. If it still hasn't sent the mail, Phabricator will try servers which are not in any priority group, in the configured order. In this example there is only one such server, so it will try to send via Mailgun. Next Steps ========== Continue by: - @{article:Configuring Inbound Email} so users can reply to email they receive about revisions and tasks to interact with them; or - learning about daemons with @{article:Managing Daemons with phd}; or - returning to the @{article:Configuration Guide}.