Path: blob/master/src/applications/metamta/engine/PhabricatorMailEmailEngine.php
12256 views
<?php12final class PhabricatorMailEmailEngine3extends PhabricatorMailMessageEngine {45public function newMessage() {6$mailer = $this->getMailer();7$mail = $this->getMail();89$message = new PhabricatorMailEmailMessage();1011$from_address = $this->newFromEmailAddress();12$message->setFromAddress($from_address);1314$reply_address = $this->newReplyToEmailAddress();15if ($reply_address) {16$message->setReplyToAddress($reply_address);17}1819$to_addresses = $this->newToEmailAddresses();20$cc_addresses = $this->newCCEmailAddresses();2122if (!$to_addresses && !$cc_addresses) {23$mail->setMessage(24pht(25'Message has no valid recipients: all To/CC are disabled, '.26'invalid, or configured not to receive this mail.'));27return null;28}2930// If this email describes a mail processing error, we rate limit outbound31// messages to each individual address. This prevents messes where32// something is stuck in a loop or dumps a ton of messages on us suddenly.33if ($mail->getIsErrorEmail()) {34$all_recipients = array();35foreach ($to_addresses as $to_address) {36$all_recipients[] = $to_address->getAddress();37}38foreach ($cc_addresses as $cc_address) {39$all_recipients[] = $cc_address->getAddress();40}41if ($this->shouldRateLimitMail($all_recipients)) {42$mail->setMessage(43pht(44'This is an error email, but one or more recipients have '.45'exceeded the error email rate limit. Declining to deliver '.46'message.'));47return null;48}49}5051// Some mailers require a valid "To:" in order to deliver mail. If we52// don't have any "To:", try to fill it in with a placeholder "To:".53// If that also fails, move the "Cc:" line to "To:".54if (!$to_addresses) {55$void_address = $this->newVoidEmailAddress();56$to_addresses = array($void_address);57}5859$to_addresses = $this->getUniqueEmailAddresses($to_addresses);60$cc_addresses = $this->getUniqueEmailAddresses(61$cc_addresses,62$to_addresses);6364$message->setToAddresses($to_addresses);65$message->setCCAddresses($cc_addresses);6667$attachments = $this->newEmailAttachments();68$message->setAttachments($attachments);6970$subject = $this->newEmailSubject();71$message->setSubject($subject);7273$headers = $this->newEmailHeaders();74foreach ($this->newEmailThreadingHeaders($mailer) as $threading_header) {75$headers[] = $threading_header;76}7778$stamps = $mail->getMailStamps();79if ($stamps) {80$headers[] = $this->newEmailHeader(81'X-Phabricator-Stamps',82implode(' ', $stamps));83}8485$must_encrypt = $mail->getMustEncrypt();8687$raw_body = $mail->getBody();88$body = $raw_body;89if ($must_encrypt) {90$parts = array();9192$encrypt_uri = $mail->getMustEncryptURI();93if ($encrypt_uri === null || !strlen($encrypt_uri)) {94$encrypt_phid = $mail->getRelatedPHID();95if ($encrypt_phid) {96$encrypt_uri = urisprintf(97'/object/%s/',98$encrypt_phid);99}100}101102if ($encrypt_uri !== null && strlen($encrypt_uri)) {103$parts[] = pht(104'This secure message is notifying you of a change to this object:');105$parts[] = PhabricatorEnv::getProductionURI($encrypt_uri);106}107108$parts[] = pht(109'The content for this message can only be transmitted over a '.110'secure channel. To view the message content, follow this '.111'link:');112113$parts[] = PhabricatorEnv::getProductionURI($mail->getURI());114115$body = implode("\n\n", $parts);116} else {117$body = $raw_body;118}119120$body_limit = PhabricatorEnv::getEnvConfig('metamta.email-body-limit');121122$body = phutil_string_cast($body);123if (strlen($body) > $body_limit) {124$body = id(new PhutilUTF8StringTruncator())125->setMaximumBytes($body_limit)126->truncateString($body);127$body .= "\n";128$body .= pht('(This email was truncated at %d bytes.)', $body_limit);129}130$message->setTextBody($body);131$body_limit -= strlen($body);132133// If we sent a different message body than we were asked to, record134// what we actually sent to make debugging and diagnostics easier.135if ($body !== $raw_body) {136$mail->setDeliveredBody($body);137}138139if ($must_encrypt) {140$send_html = false;141} else {142$send_html = $this->shouldSendHTML();143}144145if ($send_html) {146$html_body = $mail->getHTMLBody();147if (phutil_nonempty_string($html_body)) {148// NOTE: We just drop the entire HTML body if it won't fit. Safely149// truncating HTML is hard, and we already have the text body to fall150// back to.151if (strlen($html_body) <= $body_limit) {152$message->setHTMLBody($html_body);153$body_limit -= strlen($html_body);154}155}156}157158// Pass the headers to the mailer, then save the state so we can show159// them in the web UI. If the mail must be encrypted, we remove headers160// which are not on a strict whitelist to avoid disclosing information.161$filtered_headers = $this->filterHeaders($headers, $must_encrypt);162$message->setHeaders($filtered_headers);163164$mail->setUnfilteredHeaders($headers);165$mail->setDeliveredHeaders($headers);166167if (PhabricatorEnv::getEnvConfig('phabricator.silent')) {168$mail->setMessage(169pht(170'This software is running in silent mode. See `%s` '.171'in the configuration to change this setting.',172'phabricator.silent'));173174return null;175}176177return $message;178}179180/* -( Message Components )------------------------------------------------- */181182private function newFromEmailAddress() {183$from_address = $this->newDefaultEmailAddress();184$mail = $this->getMail();185186// If the mail content must be encrypted, always disguise the sender.187$must_encrypt = $mail->getMustEncrypt();188if ($must_encrypt) {189return $from_address;190}191192// If we have a raw "From" address, use that.193$raw_from = $mail->getRawFrom();194if ($raw_from) {195list($from_email, $from_name) = $raw_from;196return $this->newEmailAddress($from_email, $from_name);197}198199// Otherwise, use as much of the information for any sending entity as200// we can.201$from_phid = $mail->getFrom();202203$actor = $this->getActor($from_phid);204if ($actor) {205$actor_email = $actor->getEmailAddress();206$actor_name = $actor->getName();207} else {208$actor_email = null;209$actor_name = null;210}211212$send_as_user = PhabricatorEnv::getEnvConfig('metamta.can-send-as-user');213if ($send_as_user) {214if ($actor_email !== null) {215$from_address->setAddress($actor_email);216}217}218219if ($actor_name !== null) {220$from_address->setDisplayName($actor_name);221}222223return $from_address;224}225226private function newReplyToEmailAddress() {227$mail = $this->getMail();228229$reply_raw = $mail->getReplyTo();230if (!phutil_nonempty_string($reply_raw)) {231return null;232}233234$reply_address = new PhutilEmailAddress($reply_raw);235236// If we have a sending object, change the display name.237$from_phid = $mail->getFrom();238$actor = $this->getActor($from_phid);239if ($actor) {240$reply_address->setDisplayName($actor->getName());241}242243// If we don't have a display name, fill in a default.244$reply_display_name = $reply_address->getDisplayName();245if ($reply_display_name === null || !strlen($reply_display_name)) {246$reply_address->setDisplayName(PlatformSymbols::getPlatformServerName());247}248249return $reply_address;250}251252private function newToEmailAddresses() {253$mail = $this->getMail();254255$phids = $mail->getToPHIDs();256$addresses = $this->newEmailAddressesFromActorPHIDs($phids);257258foreach ($mail->getRawToAddresses() as $raw_address) {259$addresses[] = new PhutilEmailAddress($raw_address);260}261262return $addresses;263}264265private function newCCEmailAddresses() {266$mail = $this->getMail();267$phids = $mail->getCcPHIDs();268return $this->newEmailAddressesFromActorPHIDs($phids);269}270271private function newEmailAddressesFromActorPHIDs(array $phids) {272$mail = $this->getMail();273$phids = $mail->expandRecipients($phids);274275$addresses = array();276foreach ($phids as $phid) {277$actor = $this->getActor($phid);278if (!$actor) {279continue;280}281282if (!$actor->isDeliverable()) {283continue;284}285286$addresses[] = new PhutilEmailAddress($actor->getEmailAddress());287}288289return $addresses;290}291292private function newEmailSubject() {293$mail = $this->getMail();294295$is_threaded = (bool)$mail->getThreadID();296$must_encrypt = $mail->getMustEncrypt();297298$subject = array();299300if ($is_threaded) {301if ($this->shouldAddRePrefix()) {302$subject[] = 'Re:';303}304}305306$subject_prefix = $mail->getSubjectPrefix();307$subject_prefix = phutil_string_cast($subject_prefix);308$subject_prefix = trim($subject_prefix);309310$subject[] = $subject_prefix;311312// If mail content must be encrypted, we replace the subject with313// a generic one.314if ($must_encrypt) {315$encrypt_subject = $mail->getMustEncryptSubject();316if ($encrypt_subject === null || !strlen($encrypt_subject)) {317$encrypt_subject = pht('Object Updated');318}319$subject[] = $encrypt_subject;320} else {321$vary_prefix = $mail->getVarySubjectPrefix();322if (phutil_nonempty_string($vary_prefix)) {323if ($this->shouldVarySubject()) {324$subject[] = $vary_prefix;325}326}327328$subject[] = $mail->getSubject();329}330331foreach ($subject as $key => $part) {332if (!phutil_nonempty_string($part)) {333unset($subject[$key]);334}335}336337$subject = implode(' ', $subject);338return $subject;339}340341private function newEmailHeaders() {342$mail = $this->getMail();343344$headers = array();345346$headers[] = $this->newEmailHeader(347'X-Phabricator-Sent-This-Message',348'Yes');349$headers[] = $this->newEmailHeader(350'X-Mail-Transport-Agent',351'MetaMTA');352353// Some clients respect this to suppress OOF and other auto-responses.354$headers[] = $this->newEmailHeader(355'X-Auto-Response-Suppress',356'All');357358$mailtags = $mail->getMailTags();359if ($mailtags) {360$tag_header = array();361foreach ($mailtags as $mailtag) {362$tag_header[] = '<'.$mailtag.'>';363}364$tag_header = implode(', ', $tag_header);365$headers[] = $this->newEmailHeader(366'X-Phabricator-Mail-Tags',367$tag_header);368}369370$value = $mail->getHeaders();371foreach ($value as $pair) {372list($header_key, $header_value) = $pair;373374// NOTE: If we have \n in a header, SES rejects the email.375$header_value = str_replace("\n", ' ', $header_value);376$headers[] = $this->newEmailHeader($header_key, $header_value);377}378379$is_bulk = $mail->getIsBulk();380if ($is_bulk) {381$headers[] = $this->newEmailHeader('Precedence', 'bulk');382}383384if ($mail->getMustEncrypt()) {385$headers[] = $this->newEmailHeader('X-Phabricator-Must-Encrypt', 'Yes');386}387388$related_phid = $mail->getRelatedPHID();389if ($related_phid) {390$headers[] = $this->newEmailHeader('Thread-Topic', $related_phid);391}392393$headers[] = $this->newEmailHeader(394'X-Phabricator-Mail-ID',395$mail->getID());396397$unique = Filesystem::readRandomCharacters(16);398$headers[] = $this->newEmailHeader(399'X-Phabricator-Send-Attempt',400$unique);401402return $headers;403}404405private function newEmailThreadingHeaders() {406$mailer = $this->getMailer();407$mail = $this->getMail();408409$headers = array();410411$thread_id = $mail->getThreadID();412if (!phutil_nonempty_string($thread_id)) {413return $headers;414}415416$is_first = $mail->getIsFirstMessage();417418// NOTE: Gmail freaks out about In-Reply-To and References which aren't in419// the form "<[email protected]>"; this is also required by RFC 2822,420// although some clients are more liberal in what they accept.421$domain = $this->newMailDomain();422$thread_id = '<'.$thread_id.'@'.$domain.'>';423424if ($is_first && $mailer->supportsMessageIDHeader()) {425$headers[] = $this->newEmailHeader('Message-ID', $thread_id);426} else {427$in_reply_to = $thread_id;428$references = array($thread_id);429$parent_id = $mail->getParentMessageID();430if ($parent_id) {431$in_reply_to = $parent_id;432// By RFC 2822, the most immediate parent should appear last433// in the "References" header, so this order is intentional.434$references[] = $parent_id;435}436$references = implode(' ', $references);437$headers[] = $this->newEmailHeader('In-Reply-To', $in_reply_to);438$headers[] = $this->newEmailHeader('References', $references);439}440$thread_index = $this->generateThreadIndex($thread_id, $is_first);441$headers[] = $this->newEmailHeader('Thread-Index', $thread_index);442443return $headers;444}445446private function newEmailAttachments() {447$mail = $this->getMail();448449// If the mail content must be encrypted, don't add attachments.450$must_encrypt = $mail->getMustEncrypt();451if ($must_encrypt) {452return array();453}454455return $mail->getAttachments();456}457458/* -( Preferences )-------------------------------------------------------- */459460private function shouldAddRePrefix() {461$preferences = $this->getPreferences();462463$value = $preferences->getSettingValue(464PhabricatorEmailRePrefixSetting::SETTINGKEY);465466return ($value == PhabricatorEmailRePrefixSetting::VALUE_RE_PREFIX);467}468469private function shouldVarySubject() {470$preferences = $this->getPreferences();471472$value = $preferences->getSettingValue(473PhabricatorEmailVarySubjectsSetting::SETTINGKEY);474475return ($value == PhabricatorEmailVarySubjectsSetting::VALUE_VARY_SUBJECTS);476}477478private function shouldSendHTML() {479$preferences = $this->getPreferences();480481$value = $preferences->getSettingValue(482PhabricatorEmailFormatSetting::SETTINGKEY);483484return ($value == PhabricatorEmailFormatSetting::VALUE_HTML_EMAIL);485}486487488/* -( Utilities )---------------------------------------------------------- */489490private function newEmailHeader($name, $value) {491return id(new PhabricatorMailHeader())492->setName($name)493->setValue($value);494}495496private function newEmailAddress($address, $name = null) {497$object = id(new PhutilEmailAddress())498->setAddress($address);499500if ($name !== null && strlen($name)) {501$object->setDisplayName($name);502}503504return $object;505}506507public function newDefaultEmailAddress() {508$raw_address = PhabricatorEnv::getEnvConfig('metamta.default-address');509510if ($raw_address == null || !strlen($raw_address)) {511$domain = $this->newMailDomain();512$raw_address = "noreply@{$domain}";513}514515$address = new PhutilEmailAddress($raw_address);516517if (!phutil_nonempty_string($address->getDisplayName())) {518$address->setDisplayName(PlatformSymbols::getPlatformServerName());519}520521return $address;522}523524public function newVoidEmailAddress() {525return $this->newDefaultEmailAddress();526}527528private function newMailDomain() {529$domain = PhabricatorEnv::getEnvConfig('metamta.reply-handler-domain');530if ($domain !== null && strlen($domain)) {531return $domain;532}533534$install_uri = PhabricatorEnv::getURI('/');535$install_uri = new PhutilURI($install_uri);536537return $install_uri->getDomain();538}539540private function filterHeaders(array $headers, $must_encrypt) {541assert_instances_of($headers, 'PhabricatorMailHeader');542543if (!$must_encrypt) {544return $headers;545}546547$whitelist = array(548'In-Reply-To',549'Message-ID',550'Precedence',551'References',552'Thread-Index',553'Thread-Topic',554555'X-Mail-Transport-Agent',556'X-Auto-Response-Suppress',557558'X-Phabricator-Sent-This-Message',559'X-Phabricator-Must-Encrypt',560'X-Phabricator-Mail-ID',561'X-Phabricator-Send-Attempt',562);563564// NOTE: The major header we want to drop is "X-Phabricator-Mail-Tags".565// This header contains a significant amount of meaningful information566// about the object.567568$whitelist_map = array();569foreach ($whitelist as $term) {570$whitelist_map[phutil_utf8_strtolower($term)] = true;571}572573foreach ($headers as $key => $header) {574$name = $header->getName();575$name = phutil_utf8_strtolower($name);576577if (!isset($whitelist_map[$name])) {578unset($headers[$key]);579}580}581582return $headers;583}584585private function getUniqueEmailAddresses(586array $addresses,587array $exclude = array()) {588assert_instances_of($addresses, 'PhutilEmailAddress');589assert_instances_of($exclude, 'PhutilEmailAddress');590591$seen = array();592593foreach ($exclude as $address) {594$seen[$address->getAddress()] = true;595}596597foreach ($addresses as $key => $address) {598$raw_address = $address->getAddress();599600if (isset($seen[$raw_address])) {601unset($addresses[$key]);602continue;603}604605$seen[$raw_address] = true;606}607608return array_values($addresses);609}610611private function generateThreadIndex($seed, $is_first_mail) {612// When threading, Outlook ignores the 'References' and 'In-Reply-To'613// headers that most clients use. Instead, it uses a custom 'Thread-Index'614// header. The format of this header is something like this (from615// camel-exchange-folder.c in Evolution Exchange):616617/* A new post to a folder gets a 27-byte-long thread index. (The value618* is apparently unique but meaningless.) Each reply to a post gets a619* 32-byte-long thread index whose first 27 bytes are the same as the620* parent's thread index. Each reply to any of those gets a621* 37-byte-long thread index, etc. The Thread-Index header contains a622* base64 representation of this value.623*/624625// The specific implementation uses a 27-byte header for the first email626// a recipient receives, and a random 5-byte suffix (32 bytes total)627// thereafter. This means that all the replies are (incorrectly) siblings,628// but it would be very difficult to keep track of the entire tree and this629// gets us reasonable client behavior.630631$base = substr(md5($seed), 0, 27);632if (!$is_first_mail) {633// Not totally sure, but it seems like outlook orders replies by634// thread-index rather than timestamp, so to get these to show up in the635// right order we use the time as the last 4 bytes.636$base .= ' '.pack('N', time());637}638639return base64_encode($base);640}641642private function shouldRateLimitMail(array $all_recipients) {643try {644PhabricatorSystemActionEngine::willTakeAction(645$all_recipients,646new PhabricatorMetaMTAErrorMailAction(),6471);648return false;649} catch (PhabricatorSystemActionRateLimitException $ex) {650return true;651}652}653654}655656657