Path: blob/master/src/applications/metamta/storage/PhabricatorMetaMTAReceivedMail.php
12256 views
<?php12final class PhabricatorMetaMTAReceivedMail extends PhabricatorMetaMTADAO {34protected $headers = array();5protected $bodies = array();6protected $attachments = array();7protected $status = '';89protected $relatedPHID;10protected $authorPHID;11protected $message;12protected $messageIDHash = '';1314protected function getConfiguration() {15return array(16self::CONFIG_SERIALIZATION => array(17'headers' => self::SERIALIZATION_JSON,18'bodies' => self::SERIALIZATION_JSON,19'attachments' => self::SERIALIZATION_JSON,20),21self::CONFIG_COLUMN_SCHEMA => array(22'relatedPHID' => 'phid?',23'authorPHID' => 'phid?',24'message' => 'text?',25'messageIDHash' => 'bytes12',26'status' => 'text32',27),28self::CONFIG_KEY_SCHEMA => array(29'relatedPHID' => array(30'columns' => array('relatedPHID'),31),32'authorPHID' => array(33'columns' => array('authorPHID'),34),35'key_messageIDHash' => array(36'columns' => array('messageIDHash'),37),38'key_created' => array(39'columns' => array('dateCreated'),40),41),42) + parent::getConfiguration();43}4445public function setHeaders(array $headers) {46// Normalize headers to lowercase.47$normalized = array();48foreach ($headers as $name => $value) {49$name = $this->normalizeMailHeaderName($name);50if ($name == 'message-id') {51$this->setMessageIDHash(PhabricatorHash::digestForIndex($value));52}53$normalized[$name] = $value;54}55$this->headers = $normalized;56return $this;57}5859public function getHeader($key, $default = null) {60$key = $this->normalizeMailHeaderName($key);61return idx($this->headers, $key, $default);62}6364private function normalizeMailHeaderName($name) {65return strtolower($name);66}6768public function getMessageID() {69return $this->getHeader('Message-ID');70}7172public function getSubject() {73return $this->getHeader('Subject');74}7576public function getCCAddresses() {77return $this->getRawEmailAddresses(idx($this->headers, 'cc'));78}7980public function getToAddresses() {81return $this->getRawEmailAddresses(idx($this->headers, 'to'));82}8384public function newTargetAddresses() {85$raw_addresses = array();8687foreach ($this->getToAddresses() as $raw_address) {88$raw_addresses[] = $raw_address;89}9091foreach ($this->getCCAddresses() as $raw_address) {92$raw_addresses[] = $raw_address;93}9495$raw_addresses = array_unique($raw_addresses);9697$addresses = array();98foreach ($raw_addresses as $raw_address) {99$addresses[] = new PhutilEmailAddress($raw_address);100}101102return $addresses;103}104105public function loadAllRecipientPHIDs() {106$addresses = $this->newTargetAddresses();107108// See T13317. Don't allow reserved addresses (like "noreply@...") to109// match user PHIDs.110foreach ($addresses as $key => $address) {111if (PhabricatorMailUtil::isReservedAddress($address)) {112unset($addresses[$key]);113}114}115116if (!$addresses) {117return array();118}119120$address_strings = array();121foreach ($addresses as $address) {122$address_strings[] = phutil_string_cast($address->getAddress());123}124125// See T13317. If a verified email address is in the "To" or "Cc" line,126// we'll count the user who owns that address as a recipient.127128// We require the address be verified because we'll trigger behavior (like129// adding subscribers) based on the recipient list, and don't want to add130// Alice as a subscriber if she adds an unverified "internal-bounces@"131// address to her account and this address gets caught in the crossfire.132// In the best case this is confusing; in the worst case it could133// some day give her access to objects she can't see.134135$recipients = id(new PhabricatorUserEmail())136->loadAllWhere(137'address IN (%Ls) AND isVerified = 1',138$address_strings);139140$recipient_phids = mpull($recipients, 'getUserPHID');141142return $recipient_phids;143}144145public function processReceivedMail() {146$viewer = $this->getViewer();147148$sender = null;149try {150$this->dropMailFromPhabricator();151$this->dropMailAlreadyReceived();152$this->dropEmptyMail();153154$sender = $this->loadSender();155if ($sender) {156$this->setAuthorPHID($sender->getPHID());157158// If we've identified the sender, mark them as the author of any159// attached files. We do this before we validate them (below), since160// they still authored these files even if their account is not allowed161// to interact via email.162163$attachments = $this->getAttachments();164if ($attachments) {165$files = id(new PhabricatorFileQuery())166->setViewer($viewer)167->withPHIDs($attachments)168->execute();169foreach ($files as $file) {170$file->setAuthorPHID($sender->getPHID())->save();171}172}173174$this->validateSender($sender);175}176177$receivers = id(new PhutilClassMapQuery())178->setAncestorClass('PhabricatorMailReceiver')179->setFilterMethod('isEnabled')180->execute();181182$reserved_recipient = null;183$targets = $this->newTargetAddresses();184foreach ($targets as $key => $target) {185// Never accept any reserved address as a mail target. This prevents186// security issues around "hostmaster@" and bad behavior with187// "noreply@".188if (PhabricatorMailUtil::isReservedAddress($target)) {189if (!$reserved_recipient) {190$reserved_recipient = $target;191}192unset($targets[$key]);193continue;194}195196// See T13234. Don't process mail if a user has attached this address197// to their account.198if (PhabricatorMailUtil::isUserAddress($target)) {199unset($targets[$key]);200continue;201}202}203204$any_accepted = false;205$receiver_exception = null;206foreach ($receivers as $receiver) {207$receiver = id(clone $receiver)208->setViewer($viewer);209210if ($sender) {211$receiver->setSender($sender);212}213214foreach ($targets as $target) {215try {216if (!$receiver->canAcceptMail($this, $target)) {217continue;218}219220$any_accepted = true;221222$receiver->receiveMail($this, $target);223} catch (Exception $ex) {224// If receivers raise exceptions, we'll keep the first one in hope225// that it points at a root cause.226if (!$receiver_exception) {227$receiver_exception = $ex;228}229}230}231}232233if ($receiver_exception) {234throw $receiver_exception;235}236237238if (!$any_accepted) {239if ($reserved_recipient) {240// If nothing accepted the mail, we normally raise an error to help241// users who mistakenly send mail to "barges@" instead of "bugs@".242243// However, if the recipient list included a reserved recipient, we244// don't bounce the mail with an error.245246// The intent here is that if a user does a "Reply All" and includes247// "From: noreply@phabricator" in the receipient list, we just want248// to drop the mail rather than send them an unhelpful bounce message.249250throw new PhabricatorMetaMTAReceivedMailProcessingException(251MetaMTAReceivedMailStatus::STATUS_RESERVED,252pht(253'No application handled this mail. This mail was sent to a '.254'reserved recipient ("%s") so bounces are suppressed.',255(string)$reserved_recipient));256} else if (!$sender) {257// NOTE: Currently, we'll always drop this mail (since it's headed to258// an unverified recipient). See T12237. These details are still259// useful because they'll appear in the mail logs and Mail web UI.260261throw new PhabricatorMetaMTAReceivedMailProcessingException(262MetaMTAReceivedMailStatus::STATUS_UNKNOWN_SENDER,263pht(264'This email was sent from an email address ("%s") that is not '.265'associated with a registered user account. To interact via '.266'email, add this address to your account.',267(string)$this->newFromAddress()));268} else {269throw new PhabricatorMetaMTAReceivedMailProcessingException(270MetaMTAReceivedMailStatus::STATUS_NO_RECEIVERS,271pht(272'This mail can not be processed because no application '.273'knows how to handle it. Check that the address you sent it to '.274'is correct.'));275}276}277} catch (PhabricatorMetaMTAReceivedMailProcessingException $ex) {278switch ($ex->getStatusCode()) {279case MetaMTAReceivedMailStatus::STATUS_DUPLICATE:280case MetaMTAReceivedMailStatus::STATUS_FROM_PHABRICATOR:281// Don't send an error email back in these cases, since they're282// very unlikely to be the sender's fault.283break;284case MetaMTAReceivedMailStatus::STATUS_RESERVED:285// This probably is the sender's fault, but it's likely an accident286// that we received the mail at all.287break;288case MetaMTAReceivedMailStatus::STATUS_EMPTY_IGNORED:289// This error is explicitly ignored.290break;291default:292$this->sendExceptionMail($ex, $sender);293break;294}295296$this297->setStatus($ex->getStatusCode())298->setMessage($ex->getMessage())299->save();300return $this;301} catch (Exception $ex) {302$this->sendExceptionMail($ex, $sender);303304$this305->setStatus(MetaMTAReceivedMailStatus::STATUS_UNHANDLED_EXCEPTION)306->setMessage(pht('Unhandled Exception: %s', $ex->getMessage()))307->save();308309throw $ex;310}311312return $this->setMessage('OK')->save();313}314315public function getCleanTextBody() {316$body = $this->getRawTextBody();317$parser = new PhabricatorMetaMTAEmailBodyParser();318return $parser->stripTextBody($body);319}320321public function parseBody() {322$body = $this->getRawTextBody();323$parser = new PhabricatorMetaMTAEmailBodyParser();324return $parser->parseBody($body);325}326327public function getRawTextBody() {328return idx($this->bodies, 'text');329}330331/**332* Strip an email address down to the actual [email protected] part if333* necessary, since sometimes it will have formatting like334* '"Abraham Lincoln" <[email protected]>'.335*/336private function getRawEmailAddress($address) {337$matches = null;338$ok = preg_match('/<(.*)>/', $address, $matches);339if ($ok) {340$address = $matches[1];341}342return $address;343}344345private function getRawEmailAddresses($addresses) {346$raw_addresses = array();347348if (phutil_nonempty_string($addresses)) {349foreach (explode(',', $addresses) as $address) {350$raw_addresses[] = $this->getRawEmailAddress($address);351}352}353354return array_filter($raw_addresses);355}356357/**358* If Phabricator sent the mail, always drop it immediately. This prevents359* loops where, e.g., the public bug address is also a user email address360* and creating a bug sends them an email, which loops.361*/362private function dropMailFromPhabricator() {363if (!$this->getHeader('x-phabricator-sent-this-message')) {364return;365}366367throw new PhabricatorMetaMTAReceivedMailProcessingException(368MetaMTAReceivedMailStatus::STATUS_FROM_PHABRICATOR,369pht(370"Ignoring email with '%s' header to avoid loops.",371'X-Phabricator-Sent-This-Message'));372}373374/**375* If this mail has the same message ID as some other mail, and isn't the376* first mail we we received with that message ID, we drop it as a duplicate.377*/378private function dropMailAlreadyReceived() {379$message_id_hash = $this->getMessageIDHash();380if (!$message_id_hash) {381// No message ID hash, so we can't detect duplicates. This should only382// happen with very old messages.383return;384}385386$messages = $this->loadAllWhere(387'messageIDHash = %s ORDER BY id ASC LIMIT 2',388$message_id_hash);389$messages_count = count($messages);390if ($messages_count <= 1) {391// If we only have one copy of this message, we're good to process it.392return;393}394395$first_message = reset($messages);396if ($first_message->getID() == $this->getID()) {397// If this is the first copy of the message, it is okay to process it.398// We may not have been able to to process it immediately when we received399// it, and could may have received several copies without processing any400// yet.401return;402}403404$message = pht(405'Ignoring email with "Message-ID" hash "%s" that has been seen %d '.406'times, including this message.',407$message_id_hash,408$messages_count);409410throw new PhabricatorMetaMTAReceivedMailProcessingException(411MetaMTAReceivedMailStatus::STATUS_DUPLICATE,412$message);413}414415private function dropEmptyMail() {416$body = $this->getCleanTextBody();417$attachments = $this->getAttachments();418419if (strlen($body) || $attachments) {420return;421}422423// Only send an error email if the user is talking to just Phabricator.424// We can assume if there is only one "To" address it is a Phabricator425// address since this code is running and everything.426$is_direct_mail = (count($this->getToAddresses()) == 1) &&427(count($this->getCCAddresses()) == 0);428429if ($is_direct_mail) {430$status_code = MetaMTAReceivedMailStatus::STATUS_EMPTY;431} else {432$status_code = MetaMTAReceivedMailStatus::STATUS_EMPTY_IGNORED;433}434435throw new PhabricatorMetaMTAReceivedMailProcessingException(436$status_code,437pht(438'Your message does not contain any body text or attachments, so '.439'this server can not do anything useful with it. Make sure comment '.440'text appears at the top of your message: quoted replies, inline '.441'text, and signatures are discarded and ignored.'));442}443444private function sendExceptionMail(445Exception $ex,446PhabricatorUser $viewer = null) {447448// If we've failed to identify a legitimate sender, we don't send them449// an error message back. We want to avoid sending mail to unverified450// addresses. See T12491.451if (!$viewer) {452return;453}454455if ($ex instanceof PhabricatorMetaMTAReceivedMailProcessingException) {456$status_code = $ex->getStatusCode();457$status_name = MetaMTAReceivedMailStatus::getHumanReadableName(458$status_code);459460$title = pht('Error Processing Mail (%s)', $status_name);461$description = $ex->getMessage();462} else {463$title = pht('Error Processing Mail (%s)', get_class($ex));464$description = pht('%s: %s', get_class($ex), $ex->getMessage());465}466467// TODO: Since headers don't necessarily have unique names, this may not468// really be all the headers. It would be nice to pass the raw headers469// through from the upper layers where possible.470471// On the MimeMailParser pathway, we arrive here with a list value for472// headers that appeared multiple times in the original mail. Be473// accommodating until header handling gets straightened out.474475$headers = array();476foreach ($this->headers as $key => $values) {477if (!is_array($values)) {478$values = array($values);479}480foreach ($values as $value) {481$headers[] = pht('%s: %s', $key, $value);482}483}484$headers = implode("\n", $headers);485486$body = pht(<<<EOBODY487Your email to %s was not processed, because an error occurred while488trying to handle it:489490%s491492-- Original Message Body -----------------------------------------------------493494%s495496-- Original Message Headers --------------------------------------------------497498%s499500EOBODY501,502PlatformSymbols::getPlatformServerName(),503wordwrap($description, 78),504$this->getRawTextBody(),505$headers);506507$mail = id(new PhabricatorMetaMTAMail())508->setIsErrorEmail(true)509->setSubject($title)510->addTos(array($viewer->getPHID()))511->setBody($body)512->saveAndSend();513}514515public function newContentSource() {516return PhabricatorContentSource::newForSource(517PhabricatorEmailContentSource::SOURCECONST,518array(519'id' => $this->getID(),520));521}522523public function newFromAddress() {524$raw_from = $this->getHeader('From');525526if (strlen($raw_from)) {527return new PhutilEmailAddress($raw_from);528}529530return null;531}532533private function getViewer() {534return PhabricatorUser::getOmnipotentUser();535}536537/**538* Identify the sender's user account for a piece of received mail.539*540* Note that this method does not validate that the sender is who they say541* they are, just that they've presented some credential which corresponds542* to a recognizable user.543*/544private function loadSender() {545$viewer = $this->getViewer();546547// Try to identify the user based on their "From" address.548$from_address = $this->newFromAddress();549if ($from_address) {550$user = id(new PhabricatorPeopleQuery())551->setViewer($viewer)552->withEmails(array($from_address->getAddress()))553->executeOne();554if ($user) {555return $user;556}557}558559return null;560}561562private function validateSender(PhabricatorUser $sender) {563$failure_reason = null;564if ($sender->getIsDisabled()) {565$failure_reason = pht(566'Your account ("%s") is disabled, so you can not interact with '.567'over email.',568$sender->getUsername());569} else if ($sender->getIsStandardUser()) {570if (!$sender->getIsApproved()) {571$failure_reason = pht(572'Your account ("%s") has not been approved yet. You can not '.573'interact over email until your account is approved.',574$sender->getUsername());575} else if (PhabricatorUserEmail::isEmailVerificationRequired() &&576!$sender->getIsEmailVerified()) {577$failure_reason = pht(578'You have not verified the email address for your account ("%s"). '.579'You must verify your email address before you can interact over '.580'email.',581$sender->getUsername());582}583}584585if ($failure_reason) {586throw new PhabricatorMetaMTAReceivedMailProcessingException(587MetaMTAReceivedMailStatus::STATUS_DISABLED_SENDER,588$failure_reason);589}590}591592}593594595