@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Allow multiple mail receivers to react to an individual email

Summary:
Fixes T7477. Fixes T13066. Currently, inbound mail is processed by the first receiver that matches any "To:" address. "Cc" addresses are ignored.

**To, CC, and Multiple Receivers**

Some users would like to be able to "Cc" addresses like `bugs@` instead of having to "To" the address, which makes perfect sense. That's the driving use case behind T7477.

Since users can To/Cc multiple "create object" or "update object" addresses, I also wanted to make the behavior more general. For example, if you email `bugs@` and also `paste@`, your mail might reasonably make both a Task and a Paste. Is this useful? I'm not sure. But it seems like it's pretty clearly the best match for user intent, and the least-surprising behavior we can have. There's also no good rule for picking which address "wins" when two or more match -- we ended up with "address order", which is pretty arbitrary since "To" and "Cc" are not really ordered fields.

One part of this change is removing `phabricator.allow-email-users`. In practice, this option only controlled whether users were allowed to send mail to "Application Email" addresses with a configured default author, and it's unlikely that we'll expand it since I think the future of external/grey users is Nuance, not richer interaction with Maniphest/Differential/etc. Since this option only made "Default Author" work and "Default Author" is optional, we can simplify behavior by making the rule work like this:

- If an address specifies a default author, it allows public email.
- If an address does not, it doesn't.

That's basically how it worked already, except that you could intentionally "break" the behavior by not configuring `phabricator.allow-email-users`. This is a backwards compatility change with possible security implications (it might allow email in that was previously blocked by configuration) that I'll call out in the changelog, but I suspect that no installs are really impacted and this new behavior is generally more intuitive.

A somewhat related change here is that each receiver is allowed to react to each individual email address, instead of firing once. This allows you to configure `bugs-a@` and `bugs-b@` and CC them both and get two tasks. Useful? Maybe not, but seems like the best execution of intent.

**Sender vs Author**

Adjacently, T13066 described an improvement to error handling behavior here: we did not distinguish between "sender" (the user matching the email "From" address) and "actor" (the user we're actually acting as in the application). These are different when you're some internet rando and send to `bugs@`, which has a default author. Then the "sender" is `null` and the "author" is `@bugs-robot` or whatever (some user account you've configured).

This refines "Sender" vs "Author". This is mostly a purity/correctness change, but it means that we won't send random email error messages to `@bugs-robot`.

Since receivers are now allowed to process mail with no "sender" if they have some default "actor" they would rather use instead, it's not an error to send from an invalid address unless nothing processes the mail.

**Other**

This removes the "abundant receivers" error since this is no longer an error.

This always sets "external user" mail recipients to be unverified. As far as I can tell, there's no pathway by which we send them email anyway (before or after this change), although it's possible I'm missing something somewhere.

Test Plan:
I did most of this with `bin/mail receive-test`. I rigged the workflow slightly for some of it since it doesn't support multiple addresses or explicit "CC" and adding either would be a bit tricky.

These could also be tested with `scripts/mail/mail_handler.php`, but I don't currently have the MIME parser extension installed locally after a recent upgrade to Mojave and suspect T13232 makes it tricky to install.

- Ran unit tests, which provide significant coverage of this flow.
- Sent mail to multiple Maniphest application emails, got multiple tasks.
- Sent mail to a Maniphest and a Paste application email, got a task and a paste.
- Sent mail to a task.
- Saw original email recorded on tasks. This is a behavior particular to tasks.
- Sent mail to a paste.
- Sent mail to a mock.
- Sent mail to a Phame blog post.
- Sent mail to a Legalpad document.
- Sent mail to a Conpherence thread.
- Sent mail to a poll.
- This isn't every type of supported object but it's enough of them that I'm pretty confident I didn't break the whole flow.
- Sent mail to an object I could not view (got an error).
- As a non-user, sent mail to several "create an object..." addresses.
- Addresses with a default user worked (e.g., created a task).
- Addresses without a default user did not work.

Reviewers: amckinley

Reviewed By: amckinley

Maniphest Tasks: T13066, T7477

Differential Revision: https://secure.phabricator.com/D19952

+406 -340
+1 -1
src/applications/audit/mail/PhabricatorAuditMailReceiver.php
··· 12 12 } 13 13 14 14 protected function loadObject($pattern, PhabricatorUser $viewer) { 15 - $id = (int)preg_replace('/^COMMIT/', '', $pattern); 15 + $id = (int)preg_replace('/^COMMIT/i', '', $pattern); 16 16 17 17 return id(new DiffusionCommitQuery()) 18 18 ->setViewer($viewer)
+1 -1
src/applications/calendar/mail/PhabricatorCalendarEventMailReceiver.php
··· 13 13 } 14 14 15 15 protected function loadObject($pattern, PhabricatorUser $viewer) { 16 - $id = (int)trim($pattern, 'E'); 16 + $id = (int)substr($pattern, 1); 17 17 18 18 return id(new PhabricatorCalendarEventQuery()) 19 19 ->setViewer($viewer)
+4
src/applications/config/check/PhabricatorExtraConfigSetupCheck.php
··· 394 394 395 395 'metamta.insecure-auth-with-reply-to' => pht( 396 396 'Authenticating users based on "Reply-To" is no longer supported.'), 397 + 398 + 'phabricator.allow-email-users' => pht( 399 + 'Public email is now accepted if the associated address has a '. 400 + 'default author, and rejected otherwise.'), 397 401 ); 398 402 399 403 return $ancient_config;
-8
src/applications/config/option/PhabricatorCoreConfigOptions.php
··· 234 234 $this->newOption('phabricator.cache-namespace', 'string', 'phabricator') 235 235 ->setLocked(true) 236 236 ->setDescription(pht('Cache namespace.')), 237 - $this->newOption('phabricator.allow-email-users', 'bool', false) 238 - ->setBoolOptions( 239 - array( 240 - pht('Allow'), 241 - pht('Disallow'), 242 - )) 243 - ->setDescription( 244 - pht('Allow non-members to interact with tasks over email.')), 245 237 $this->newOption('phabricator.silent', 'bool', false) 246 238 ->setLocked(true) 247 239 ->setBoolOptions(
+1 -1
src/applications/conpherence/mail/ConpherenceThreadMailReceiver.php
··· 13 13 } 14 14 15 15 protected function loadObject($pattern, PhabricatorUser $viewer) { 16 - $id = (int)trim($pattern, 'Z'); 16 + $id = (int)substr($pattern, 1); 17 17 18 18 return id(new ConpherenceThreadQuery()) 19 19 ->setViewer($viewer)
+1 -1
src/applications/countdown/mail/PhabricatorCountdownMailReceiver.php
··· 13 13 } 14 14 15 15 protected function loadObject($pattern, PhabricatorUser $viewer) { 16 - $id = (int)substr($pattern, 4); 16 + $id = (int)substr($pattern, 1); 17 17 18 18 return id(new PhabricatorCountdownQuery()) 19 19 ->setViewer($viewer)
+8 -6
src/applications/differential/mail/DifferentialCreateMailReceiver.php
··· 9 9 10 10 protected function processReceivedMail( 11 11 PhabricatorMetaMTAReceivedMail $mail, 12 - PhabricatorUser $sender) { 12 + PhutilEmailAddress $target) { 13 + 14 + $author = $this->getAuthor(); 13 15 14 16 $attachments = $mail->getAttachments(); 15 17 $files = array(); 16 18 $errors = array(); 17 19 if ($attachments) { 18 20 $files = id(new PhabricatorFileQuery()) 19 - ->setViewer($sender) 21 + ->setViewer($author) 20 22 ->withPHIDs($attachments) 21 23 ->execute(); 22 24 foreach ($files as $index => $file) { ··· 37 39 array( 38 40 'diff' => $file->loadFileData(), 39 41 )); 40 - $call->setUser($sender); 42 + $call->setUser($author); 41 43 try { 42 44 $result = $call->execute(); 43 45 $diffs[$file->getName()] = $result['uri']; ··· 56 58 array( 57 59 'diff' => $body, 58 60 )); 59 - $call->setUser($sender); 61 + $call->setUser($author); 60 62 try { 61 63 $result = $call->execute(); 62 64 $diffs[pht('Mail Body')] = $result['uri']; ··· 108 110 } 109 111 110 112 id(new PhabricatorMetaMTAMail()) 111 - ->addTos(array($sender->getPHID())) 113 + ->addTos(array($author->getPHID())) 112 114 ->setSubject($subject) 113 115 ->setSubjectPrefix($subject_prefix) 114 - ->setFrom($sender->getPHID()) 116 + ->setFrom($author->getPHID()) 115 117 ->setBody($body->render()) 116 118 ->saveAndSend(); 117 119 }
+1 -1
src/applications/differential/mail/DifferentialRevisionMailReceiver.php
··· 13 13 } 14 14 15 15 protected function loadObject($pattern, PhabricatorUser $viewer) { 16 - $id = (int)trim($pattern, 'D'); 16 + $id = (int)substr($pattern, 1); 17 17 18 18 return id(new DifferentialRevisionQuery()) 19 19 ->setViewer($viewer)
+7 -1
src/applications/files/mail/FileCreateMailReceiver.php
··· 9 9 10 10 protected function processReceivedMail( 11 11 PhabricatorMetaMTAReceivedMail $mail, 12 - PhabricatorUser $sender) { 12 + PhutilEmailAddress $target) { 13 + $author = $this->getAuthor(); 13 14 14 15 $attachment_phids = $mail->getAttachments(); 15 16 if (empty($attachment_phids)) { ··· 20 21 } 21 22 $first_phid = head($attachment_phids); 22 23 $mail->setRelatedPHID($first_phid); 24 + 25 + $sender = $this->getSender(); 26 + if (!$sender) { 27 + return; 28 + } 23 29 24 30 $attachment_count = count($attachment_phids); 25 31 if ($attachment_count > 1) {
+1 -1
src/applications/files/mail/FileMailReceiver.php
··· 12 12 } 13 13 14 14 protected function loadObject($pattern, PhabricatorUser $viewer) { 15 - $id = (int)trim($pattern, 'F'); 15 + $id = (int)substr($pattern, 1); 16 16 17 17 return id(new PhabricatorFileQuery()) 18 18 ->setViewer($viewer)
+1 -1
src/applications/legalpad/mail/LegalpadMailReceiver.php
··· 12 12 } 13 13 14 14 protected function loadObject($pattern, PhabricatorUser $viewer) { 15 - $id = (int)trim($pattern, 'L'); 15 + $id = (int)substr($pattern, 1); 16 16 17 17 return id(new LegalpadDocumentQuery()) 18 18 ->setViewer($viewer)
+9 -4
src/applications/maniphest/mail/ManiphestCreateMailReceiver.php
··· 9 9 10 10 protected function processReceivedMail( 11 11 PhabricatorMetaMTAReceivedMail $mail, 12 - PhabricatorUser $sender) { 12 + PhutilEmailAddress $target) { 13 + 14 + $author = $this->getAuthor(); 15 + $task = ManiphestTask::initializeNewTask($author); 13 16 14 - $task = ManiphestTask::initializeNewTask($sender); 15 - $task->setOriginalEmailSource($mail->getHeader('From')); 17 + $from_address = $mail->newFromAddress(); 18 + if ($from_address) { 19 + $task->setOriginalEmailSource((string)$from_address); 20 + } 16 21 17 22 $handler = new ManiphestReplyHandler(); 18 23 $handler->setMailReceiver($task); 19 24 20 - $handler->setActor($sender); 25 + $handler->setActor($author); 21 26 $handler->setExcludeMailRecipientPHIDs( 22 27 $mail->loadAllRecipientPHIDs()); 23 28 if ($this->getApplicationEmail()) {
+1 -1
src/applications/maniphest/mail/ManiphestTaskMailReceiver.php
··· 12 12 } 13 13 14 14 protected function loadObject($pattern, PhabricatorUser $viewer) { 15 - $id = (int)trim($pattern, 'T'); 15 + $id = (int)substr($pattern, 1); 16 16 17 17 return id(new ManiphestTaskQuery()) 18 18 ->setViewer($viewer)
+17 -2
src/applications/metamta/applicationpanel/PhabricatorMetaMTAApplicationEmailPanel.php
··· 261 261 ->setName($config_default) 262 262 ->setLimit(1) 263 263 ->setValue($v_default) 264 - ->setCaption(pht( 265 - 'Used if the "From:" address does not map to a known account.'))); 264 + ->setCaption( 265 + pht( 266 + 'Used if the "From:" address does not map to a user account. '. 267 + 'Setting a default author will allow anyone on the public '. 268 + 'internet to create objects in Phabricator by sending email to '. 269 + 'this address.'))); 266 270 267 271 if ($is_new) { 268 272 $title = pht('New Address'); ··· 369 373 $email_space = null; 370 374 } 371 375 376 + $default_author_phid = $email->getDefaultAuthorPHID(); 377 + if (!$default_author_phid) { 378 + $default_author = phutil_tag('em', array(), pht('None')); 379 + } else { 380 + $default_author = $viewer->renderHandle($default_author_phid); 381 + } 382 + 372 383 $rows[] = array( 373 384 $email_space, 374 385 $email->getAddress(), 386 + $default_author, 375 387 $button_edit, 376 388 $button_remove, 377 389 ); ··· 383 395 array( 384 396 pht('Space'), 385 397 pht('Email'), 398 + pht('Default'), 386 399 pht('Edit'), 387 400 pht('Delete'), 388 401 )); 389 402 $table->setColumnClasses( 390 403 array( 391 404 '', 405 + '', 392 406 'wide', 393 407 'action', 394 408 'action', ··· 397 411 $table->setColumnVisibility( 398 412 array( 399 413 PhabricatorSpacesNamespaceQuery::getViewerSpacesExist($viewer), 414 + true, 400 415 true, 401 416 $is_edit, 402 417 $is_edit,
-2
src/applications/metamta/constants/MetaMTAReceivedMailStatus.php
··· 6 6 const STATUS_DUPLICATE = 'err:duplicate'; 7 7 const STATUS_FROM_PHABRICATOR = 'err:self'; 8 8 const STATUS_NO_RECEIVERS = 'err:no-receivers'; 9 - const STATUS_ABUNDANT_RECEIVERS = 'err:multiple-receivers'; 10 9 const STATUS_UNKNOWN_SENDER = 'err:unknown-sender'; 11 10 const STATUS_DISABLED_SENDER = 'err:disabled-sender'; 12 11 const STATUS_NO_PUBLIC_MAIL = 'err:no-public-mail'; ··· 23 22 self::STATUS_DUPLICATE => pht('Duplicate Message'), 24 23 self::STATUS_FROM_PHABRICATOR => pht('Phabricator Mail'), 25 24 self::STATUS_NO_RECEIVERS => pht('No Receivers'), 26 - self::STATUS_ABUNDANT_RECEIVERS => pht('Multiple Receivers'), 27 25 self::STATUS_UNKNOWN_SENDER => pht('Unknown Sender'), 28 26 self::STATUS_DISABLED_SENDER => pht('Disabled Sender'), 29 27 self::STATUS_NO_PUBLIC_MAIL => pht('No Public Mail'),
+16 -2
src/applications/metamta/management/PhabricatorMailManagementReceiveTestWorkflow.php
··· 33 33 } 34 34 35 35 public function execute(PhutilArgumentParser $args) { 36 + $viewer = $this->getViewer(); 36 37 $console = PhutilConsole::getConsole(); 37 38 38 39 $to = $args->getArg('to'); ··· 95 96 if (preg_match('/.+@.+/', $to)) { 96 97 $header_content['to'] = $to; 97 98 } else { 99 + 98 100 // We allow the user to use an object name instead of a real address 99 101 // as a convenience. To build the mail, we build a similar message and 100 102 // look for a receiver which will accept it. 103 + 104 + // In the general case, mail may be processed by multiple receivers, 105 + // but mail to objects only ever has one receiver today. 106 + 101 107 $pseudohash = PhabricatorObjectMailReceiver::computeMailHash('x', 'y'); 108 + 109 + $raw_target = $to.'+1+'.$pseudohash; 110 + $target = new PhutilEmailAddress($raw_target.'@local.cli'); 111 + 102 112 $pseudomail = id(new PhabricatorMetaMTAReceivedMail()) 103 113 ->setHeaders( 104 114 array( 105 - 'to' => $to.'+1+'.$pseudohash, 115 + 'to' => $raw_target, 106 116 )); 107 117 108 118 $receivers = id(new PhutilClassMapQuery()) ··· 112 122 113 123 $receiver = null; 114 124 foreach ($receivers as $possible_receiver) { 115 - if (!$possible_receiver->canAcceptMail($pseudomail)) { 125 + $possible_receiver = id(clone $possible_receiver) 126 + ->setViewer($viewer) 127 + ->setSender($user); 128 + 129 + if (!$possible_receiver->canAcceptMail($pseudomail, $target)) { 116 130 continue; 117 131 } 118 132 $receiver = $possible_receiver;
+6 -6
src/applications/metamta/query/PhabricatorMetaMTAActorQuery.php
··· 121 121 122 122 $actor->setEmailAddress($xuser->getAccountID()); 123 123 124 - // NOTE: This effectively drops all outbound mail to unrecognized 125 - // addresses unless "phabricator.allow-email-users" is set. See T12237 126 - // for context. 127 - $allow_key = 'phabricator.allow-email-users'; 128 - $allow_value = PhabricatorEnv::getEnvConfig($allow_key); 129 - $actor->setIsVerified((bool)$allow_value); 124 + // Circa T7477, it appears that we never intentionally send email to 125 + // external users (even when they email "bugs@" to create a task). 126 + // Mark these users as unverified so mail to them is always dropped. 127 + // See also T12237. In the future, we might change this behavior. 128 + 129 + $actor->setIsVerified(false); 130 130 } 131 131 } 132 132
+85 -12
src/applications/metamta/receiver/PhabricatorApplicationMailReceiver.php
··· 3 3 abstract class PhabricatorApplicationMailReceiver 4 4 extends PhabricatorMailReceiver { 5 5 6 + private $applicationEmail; 7 + private $emailList; 8 + private $author; 9 + 6 10 abstract protected function newApplication(); 7 11 12 + final protected function setApplicationEmail( 13 + PhabricatorMetaMTAApplicationEmail $email) { 14 + $this->applicationEmail = $email; 15 + return $this; 16 + } 17 + 18 + final protected function getApplicationEmail() { 19 + return $this->applicationEmail; 20 + } 21 + 22 + final protected function setAuthor(PhabricatorUser $author) { 23 + $this->author = $author; 24 + return $this; 25 + } 26 + 27 + final protected function getAuthor() { 28 + return $this->author; 29 + } 30 + 8 31 final public function isEnabled() { 9 32 return $this->newApplication()->isInstalled(); 10 33 } 11 34 12 - final public function canAcceptMail(PhabricatorMetaMTAReceivedMail $mail) { 13 - $application = $this->newApplication(); 35 + final public function canAcceptMail( 36 + PhabricatorMetaMTAReceivedMail $mail, 37 + PhutilEmailAddress $target) { 38 + 14 39 $viewer = $this->getViewer(); 40 + $sender = $this->getSender(); 41 + 42 + foreach ($this->loadApplicationEmailList() as $application_email) { 43 + $create_address = $application_email->newAddress(); 44 + 45 + if (!PhabricatorMailUtil::matchAddresses($create_address, $target)) { 46 + continue; 47 + } 48 + 49 + if ($sender) { 50 + $author = $sender; 51 + } else { 52 + $author_phid = $application_email->getDefaultAuthorPHID(); 53 + 54 + // If this mail isn't from a recognized sender and the target address 55 + // does not have a default author, we can't accept it, and it's an 56 + // error because you tried to send it here. 57 + 58 + // You either need to be sending from a real address or be sending to 59 + // an address which accepts mail from the public internet. 15 60 16 - $application_emails = id(new PhabricatorMetaMTAApplicationEmailQuery()) 17 - ->setViewer($viewer) 18 - ->withApplicationPHIDs(array($application->getPHID())) 19 - ->execute(); 61 + if (!$author_phid) { 62 + throw new PhabricatorMetaMTAReceivedMailProcessingException( 63 + MetaMTAReceivedMailStatus::STATUS_UNKNOWN_SENDER, 64 + pht( 65 + 'You are sending from an unrecognized email address to '. 66 + 'an address which does not support public email ("%s").', 67 + (string)$target)); 68 + } 20 69 21 - foreach ($mail->newTargetAddresses() as $address) { 22 - foreach ($application_emails as $application_email) { 23 - $create_address = $application_email->newAddress(); 24 - if (PhabricatorMailUtil::matchAddresses($create_address, $address)) { 25 - $this->setApplicationEmail($application_email); 26 - return true; 70 + $author = id(new PhabricatorPeopleQuery()) 71 + ->setViewer($viewer) 72 + ->withPHIDs(array($author_phid)) 73 + ->executeOne(); 74 + if (!$author) { 75 + throw new Exception( 76 + pht( 77 + 'Application email ("%s") has an invalid default author ("%s").', 78 + (string)$create_address, 79 + $author_phid)); 27 80 } 28 81 } 82 + 83 + $this 84 + ->setApplicationEmail($application_email) 85 + ->setAuthor($author); 86 + 87 + return true; 29 88 } 30 89 31 90 return false; 91 + } 92 + 93 + private function loadApplicationEmailList() { 94 + if ($this->emailList === null) { 95 + $viewer = $this->getViewer(); 96 + $application = $this->newApplication(); 97 + 98 + $this->emailList = id(new PhabricatorMetaMTAApplicationEmailQuery()) 99 + ->setViewer($viewer) 100 + ->withApplicationPHIDs(array($application->getPHID())) 101 + ->execute(); 102 + } 103 + 104 + return $this->emailList; 32 105 } 33 106 34 107 }
+21 -148
src/applications/metamta/receiver/PhabricatorMailReceiver.php
··· 2 2 3 3 abstract class PhabricatorMailReceiver extends Phobject { 4 4 5 - private $applicationEmail; 5 + private $viewer; 6 + private $sender; 6 7 7 - public function setApplicationEmail( 8 - PhabricatorMetaMTAApplicationEmail $email) { 9 - $this->applicationEmail = $email; 8 + final public function setViewer(PhabricatorUser $viewer) { 9 + $this->viewer = $viewer; 10 10 return $this; 11 11 } 12 12 13 - public function getApplicationEmail() { 14 - return $this->applicationEmail; 13 + final public function getViewer() { 14 + return $this->viewer; 15 + } 16 + 17 + final public function setSender(PhabricatorUser $sender) { 18 + $this->sender = $sender; 19 + return $this; 20 + } 21 + 22 + final public function getSender() { 23 + return $this->sender; 15 24 } 16 25 17 26 abstract public function isEnabled(); 18 - abstract public function canAcceptMail(PhabricatorMetaMTAReceivedMail $mail); 27 + abstract public function canAcceptMail( 28 + PhabricatorMetaMTAReceivedMail $mail, 29 + PhutilEmailAddress $target); 19 30 20 31 abstract protected function processReceivedMail( 21 32 PhabricatorMetaMTAReceivedMail $mail, 22 - PhabricatorUser $sender); 33 + PhutilEmailAddress $target); 23 34 24 35 final public function receiveMail( 25 36 PhabricatorMetaMTAReceivedMail $mail, 26 - PhabricatorUser $sender) { 27 - $this->processReceivedMail($mail, $sender); 28 - } 29 - 30 - public function getViewer() { 31 - return PhabricatorUser::getOmnipotentUser(); 32 - } 33 - 34 - public function validateSender( 35 - PhabricatorMetaMTAReceivedMail $mail, 36 - PhabricatorUser $sender) { 37 - 38 - $failure_reason = null; 39 - if ($sender->getIsDisabled()) { 40 - $failure_reason = pht( 41 - 'Your account (%s) is disabled, so you can not interact with '. 42 - 'Phabricator over email.', 43 - $sender->getUsername()); 44 - } else if ($sender->getIsStandardUser()) { 45 - if (!$sender->getIsApproved()) { 46 - $failure_reason = pht( 47 - 'Your account (%s) has not been approved yet. You can not interact '. 48 - 'with Phabricator over email until your account is approved.', 49 - $sender->getUsername()); 50 - } else if (PhabricatorUserEmail::isEmailVerificationRequired() && 51 - !$sender->getIsEmailVerified()) { 52 - $failure_reason = pht( 53 - 'You have not verified the email address for your account (%s). '. 54 - 'You must verify your email address before you can interact '. 55 - 'with Phabricator over email.', 56 - $sender->getUsername()); 57 - } 58 - } 59 - 60 - if ($failure_reason) { 61 - throw new PhabricatorMetaMTAReceivedMailProcessingException( 62 - MetaMTAReceivedMailStatus::STATUS_DISABLED_SENDER, 63 - $failure_reason); 64 - } 65 - } 66 - 67 - /** 68 - * Identifies the sender's user account for a piece of received mail. Note 69 - * that this method does not validate that the sender is who they say they 70 - * are, just that they've presented some credential which corresponds to a 71 - * recognizable user. 72 - */ 73 - public function loadSender(PhabricatorMetaMTAReceivedMail $mail) { 74 - $raw_from = $mail->getHeader('From'); 75 - $from = self::getRawAddress($raw_from); 76 - 77 - $reasons = array(); 78 - 79 - // Try to find a user with this email address. 80 - $user = PhabricatorUser::loadOneWithEmailAddress($from); 81 - if ($user) { 82 - return $user; 83 - } else { 84 - $reasons[] = pht( 85 - 'This email was sent from "%s", but that address is not recognized by '. 86 - 'Phabricator and does not correspond to any known user account.', 87 - $raw_from); 88 - } 89 - 90 - // If we don't know who this user is, load or create an external user 91 - // account for them if we're configured for it. 92 - $email_key = 'phabricator.allow-email-users'; 93 - $allow_email_users = PhabricatorEnv::getEnvConfig($email_key); 94 - if ($allow_email_users) { 95 - $from_obj = new PhutilEmailAddress($from); 96 - $xuser = id(new PhabricatorExternalAccountQuery()) 97 - ->setViewer($this->getViewer()) 98 - ->withAccountTypes(array('email')) 99 - ->withAccountDomains(array($from_obj->getDomainName(), 'self')) 100 - ->withAccountIDs(array($from_obj->getAddress())) 101 - ->requireCapabilities( 102 - array( 103 - PhabricatorPolicyCapability::CAN_VIEW, 104 - PhabricatorPolicyCapability::CAN_EDIT, 105 - )) 106 - ->loadOneOrCreate(); 107 - return $xuser->getPhabricatorUser(); 108 - } else { 109 - // NOTE: Currently, we'll always drop this mail (since it's headed to 110 - // an unverified recipient). See T12237. These details are still useful 111 - // because they'll appear in the mail logs and Mail web UI. 112 - 113 - $reasons[] = pht( 114 - 'Phabricator is also not configured to allow unknown external users '. 115 - 'to send mail to the system using just an email address.'); 116 - $reasons[] = pht( 117 - 'To interact with Phabricator, add this address ("%s") to your '. 118 - 'account.', 119 - $raw_from); 120 - } 121 - 122 - if ($this->getApplicationEmail()) { 123 - $application_email = $this->getApplicationEmail(); 124 - $default_user_phid = $application_email->getConfigValue( 125 - PhabricatorMetaMTAApplicationEmail::CONFIG_DEFAULT_AUTHOR); 126 - 127 - if ($default_user_phid) { 128 - $user = id(new PhabricatorUser())->loadOneWhere( 129 - 'phid = %s', 130 - $default_user_phid); 131 - if ($user) { 132 - return $user; 133 - } 134 - 135 - $reasons[] = pht( 136 - 'Phabricator is misconfigured: the application email '. 137 - '"%s" is set to user "%s", but that user does not exist.', 138 - $application_email->getAddress(), 139 - $default_user_phid); 140 - } 141 - } 142 - 143 - $reasons = implode("\n\n", $reasons); 144 - 145 - throw new PhabricatorMetaMTAReceivedMailProcessingException( 146 - MetaMTAReceivedMailStatus::STATUS_UNKNOWN_SENDER, 147 - $reasons); 148 - } 149 - 150 - /** 151 - * Reduce an email address to its canonical form. For example, an address 152 - * like: 153 - * 154 - * "Abraham Lincoln" < ALincoln@example.com > 155 - * 156 - * ...will be reduced to: 157 - * 158 - * alincoln@example.com 159 - * 160 - * @param string Email address in noncanonical form. 161 - * @return string Canonical email address. 162 - */ 163 - public static function getRawAddress($address) { 164 - $address = id(new PhutilEmailAddress($address))->getAddress(); 165 - return trim(phutil_utf8_strtolower($address)); 37 + PhutilEmailAddress $target) { 38 + $this->processReceivedMail($mail, $target); 166 39 } 167 40 168 41 }
+45 -63
src/applications/metamta/receiver/PhabricatorObjectMailReceiver.php
··· 29 29 30 30 final protected function processReceivedMail( 31 31 PhabricatorMetaMTAReceivedMail $mail, 32 - PhabricatorUser $sender) { 33 - 34 - $object = $this->loadObjectFromMail($mail, $sender); 35 - $mail->setRelatedPHID($object->getPHID()); 36 - 37 - $this->processReceivedObjectMail($mail, $object, $sender); 32 + PhutilEmailAddress $target) { 38 33 39 - return $this; 40 - } 41 - 42 - protected function processReceivedObjectMail( 43 - PhabricatorMetaMTAReceivedMail $mail, 44 - PhabricatorLiskDAO $object, 45 - PhabricatorUser $sender) { 46 - 47 - $handler = $this->getTransactionReplyHandler(); 48 - if ($handler) { 49 - return $handler 50 - ->setMailReceiver($object) 51 - ->setActor($sender) 52 - ->setExcludeMailRecipientPHIDs($mail->loadAllRecipientPHIDs()) 53 - ->processEmail($mail); 34 + $parts = $this->matchObjectAddress($target); 35 + if (!$parts) { 36 + // We should only make it here if we matched already in "canAcceptMail()", 37 + // so this is a surprise. 38 + throw new Exception( 39 + pht( 40 + 'Failed to parse object address ("%s") during processing.', 41 + (string)$target)); 54 42 } 55 43 56 - throw new PhutilMethodNotImplementedException(); 57 - } 58 - 59 - protected function getTransactionReplyHandler() { 60 - return null; 61 - } 62 - 63 - public function loadMailReceiverObject($pattern, PhabricatorUser $viewer) { 64 - return $this->loadObject($pattern, $viewer); 65 - } 66 - 67 - public function validateSender( 68 - PhabricatorMetaMTAReceivedMail $mail, 69 - PhabricatorUser $sender) { 70 - 71 - parent::validateSender($mail, $sender); 72 - 73 - $parts = $this->matchObjectAddressInMail($mail); 74 44 $pattern = $parts['pattern']; 45 + $sender = $this->getSender(); 75 46 76 47 try { 77 - $object = $this->loadObjectFromMail($mail, $sender); 48 + $object = $this->loadObject($pattern, $sender); 78 49 } catch (PhabricatorPolicyException $policy_exception) { 79 50 throw new PhabricatorMetaMTAReceivedMailProcessingException( 80 51 MetaMTAReceivedMailStatus::STATUS_POLICY_PROBLEM, ··· 95 66 } 96 67 97 68 $sender_identifier = $parts['sender']; 98 - 99 69 if ($sender_identifier === 'public') { 100 70 if (!PhabricatorEnv::getEnvConfig('metamta.public-replies')) { 101 71 throw new PhabricatorMetaMTAReceivedMailProcessingException( ··· 136 106 'is correct.', 137 107 $pattern)); 138 108 } 109 + 110 + $mail->setRelatedPHID($object->getPHID()); 111 + $this->processReceivedObjectMail($mail, $object, $sender); 112 + 113 + return $this; 139 114 } 140 115 116 + protected function processReceivedObjectMail( 117 + PhabricatorMetaMTAReceivedMail $mail, 118 + PhabricatorLiskDAO $object, 119 + PhabricatorUser $sender) { 141 120 142 - final public function canAcceptMail(PhabricatorMetaMTAReceivedMail $mail) { 143 - if ($this->matchObjectAddressInMail($mail)) { 144 - return true; 121 + $handler = $this->getTransactionReplyHandler(); 122 + if ($handler) { 123 + return $handler 124 + ->setMailReceiver($object) 125 + ->setActor($sender) 126 + ->setExcludeMailRecipientPHIDs($mail->loadAllRecipientPHIDs()) 127 + ->processEmail($mail); 145 128 } 146 129 147 - return false; 130 + throw new PhutilMethodNotImplementedException(); 148 131 } 149 132 150 - private function matchObjectAddressInMail( 151 - PhabricatorMetaMTAReceivedMail $mail) { 133 + protected function getTransactionReplyHandler() { 134 + return null; 135 + } 152 136 153 - foreach ($mail->newTargetAddresses() as $address) { 154 - $parts = $this->matchObjectAddress($address); 155 - if ($parts) { 156 - return $parts; 157 - } 137 + public function loadMailReceiverObject($pattern, PhabricatorUser $viewer) { 138 + return $this->loadObject($pattern, $viewer); 139 + } 140 + 141 + final public function canAcceptMail( 142 + PhabricatorMetaMTAReceivedMail $mail, 143 + PhutilEmailAddress $target) { 144 + 145 + // If we don't have a valid sender user account, we can never accept 146 + // mail to any object. 147 + $sender = $this->getSender(); 148 + if (!$sender) { 149 + return false; 158 150 } 159 151 160 - return null; 152 + return (bool)$this->matchObjectAddress($target); 161 153 } 162 154 163 155 private function matchObjectAddress(PhutilEmailAddress $address) { ··· 186 178 '$)Ui'; 187 179 188 180 return $regexp; 189 - } 190 - 191 - private function loadObjectFromMail( 192 - PhabricatorMetaMTAReceivedMail $mail, 193 - PhabricatorUser $sender) { 194 - $parts = $this->matchObjectAddressInMail($mail); 195 - 196 - return $this->loadObject( 197 - phutil_utf8_strtoupper($parts['pattern']), 198 - $sender); 199 181 } 200 182 201 183 public static function computeMailHash($mail_key, $phid) {
+3
src/applications/metamta/storage/PhabricatorMetaMTAApplicationEmail.php
··· 67 67 return idx($this->configData, $key, $default); 68 68 } 69 69 70 + public function getDefaultAuthorPHID() { 71 + return $this->getConfigValue(self::CONFIG_DEFAULT_AUTHOR); 72 + } 70 73 71 74 public function getInUseMessage() { 72 75 $applications = PhabricatorApplication::getAllApplications();
+154 -60
src/applications/metamta/storage/PhabricatorMetaMTAReceivedMail.php
··· 125 125 } 126 126 127 127 public function processReceivedMail() { 128 + $viewer = $this->getViewer(); 128 129 129 130 $sender = null; 130 131 try { ··· 132 133 $this->dropMailAlreadyReceived(); 133 134 $this->dropEmptyMail(); 134 135 135 - $receiver = $this->loadReceiver(); 136 - $sender = $receiver->loadSender($this); 137 - $receiver->validateSender($this, $sender); 136 + $sender = $this->loadSender(); 137 + if ($sender) { 138 + $this->setAuthorPHID($sender->getPHID()); 139 + 140 + // If we've identified the sender, mark them as the author of any 141 + // attached files. We do this before we validate them (below), since 142 + // they still authored these files even if their account is not allowed 143 + // to interact via email. 144 + 145 + $attachments = $this->getAttachments(); 146 + if ($attachments) { 147 + $files = id(new PhabricatorFileQuery()) 148 + ->setViewer($viewer) 149 + ->withPHIDs($attachments) 150 + ->execute(); 151 + foreach ($files as $file) { 152 + $file->setAuthorPHID($sender->getPHID())->save(); 153 + } 154 + } 138 155 139 - $this->setAuthorPHID($sender->getPHID()); 156 + $this->validateSender($sender); 157 + } 158 + 159 + $receivers = id(new PhutilClassMapQuery()) 160 + ->setAncestorClass('PhabricatorMailReceiver') 161 + ->setFilterMethod('isEnabled') 162 + ->execute(); 163 + 164 + $any_accepted = false; 165 + $receiver_exception = null; 166 + 167 + $targets = $this->newTargetAddresses(); 168 + foreach ($receivers as $receiver) { 169 + $receiver = id(clone $receiver) 170 + ->setViewer($viewer); 171 + 172 + if ($sender) { 173 + $receiver->setSender($sender); 174 + } 175 + 176 + foreach ($targets as $target) { 177 + try { 178 + if (!$receiver->canAcceptMail($this, $target)) { 179 + continue; 180 + } 181 + 182 + $any_accepted = true; 140 183 141 - // Now that we've identified the sender, mark them as the author of 142 - // any attached files. 143 - $attachments = $this->getAttachments(); 144 - if ($attachments) { 145 - $files = id(new PhabricatorFileQuery()) 146 - ->setViewer(PhabricatorUser::getOmnipotentUser()) 147 - ->withPHIDs($attachments) 148 - ->execute(); 149 - foreach ($files as $file) { 150 - $file->setAuthorPHID($sender->getPHID())->save(); 184 + $receiver->receiveMail($this, $target); 185 + } catch (Exception $ex) { 186 + // If receivers raise exceptions, we'll keep the first one in hope 187 + // that it points at a root cause. 188 + if (!$receiver_exception) { 189 + $receiver_exception = $ex; 190 + } 191 + } 151 192 } 152 193 } 153 194 154 - $receiver->receiveMail($this, $sender); 195 + if ($receiver_exception) { 196 + throw $receiver_exception; 197 + } 198 + 199 + if (!$any_accepted) { 200 + if (!$sender) { 201 + // NOTE: Currently, we'll always drop this mail (since it's headed to 202 + // an unverified recipient). See T12237. These details are still 203 + // useful because they'll appear in the mail logs and Mail web UI. 204 + 205 + throw new PhabricatorMetaMTAReceivedMailProcessingException( 206 + MetaMTAReceivedMailStatus::STATUS_UNKNOWN_SENDER, 207 + pht( 208 + 'This email was sent from an email address ("%s") that is not '. 209 + 'associated with a Phabricator account. To interact with '. 210 + 'Phabricator via email, add this address to your account.', 211 + (string)$this->newFromAddress())); 212 + } else { 213 + throw new PhabricatorMetaMTAReceivedMailProcessingException( 214 + MetaMTAReceivedMailStatus::STATUS_NO_RECEIVERS, 215 + pht( 216 + 'Phabricator can not process this mail because no application '. 217 + 'knows how to handle it. Check that the address you sent it to '. 218 + 'is correct.'. 219 + "\n\n". 220 + '(No concrete, enabled subclass of PhabricatorMailReceiver can '. 221 + 'accept this mail.)')); 222 + } 223 + } 155 224 } catch (PhabricatorMetaMTAReceivedMailProcessingException $ex) { 156 225 switch ($ex->getStatusCode()) { 157 226 case MetaMTAReceivedMailStatus::STATUS_DUPLICATE: ··· 311 380 'text, and signatures are discarded and ignored.')); 312 381 } 313 382 314 - /** 315 - * Load a concrete instance of the @{class:PhabricatorMailReceiver} which 316 - * accepts this mail, if one exists. 317 - */ 318 - private function loadReceiver() { 319 - $receivers = id(new PhutilClassMapQuery()) 320 - ->setAncestorClass('PhabricatorMailReceiver') 321 - ->setFilterMethod('isEnabled') 322 - ->execute(); 323 - 324 - $accept = array(); 325 - foreach ($receivers as $key => $receiver) { 326 - if ($receiver->canAcceptMail($this)) { 327 - $accept[$key] = $receiver; 328 - } 329 - } 330 - 331 - if (!$accept) { 332 - throw new PhabricatorMetaMTAReceivedMailProcessingException( 333 - MetaMTAReceivedMailStatus::STATUS_NO_RECEIVERS, 334 - pht( 335 - 'Phabricator can not process this mail because no application '. 336 - 'knows how to handle it. Check that the address you sent it to is '. 337 - 'correct.'. 338 - "\n\n". 339 - '(No concrete, enabled subclass of PhabricatorMailReceiver can '. 340 - 'accept this mail.)')); 341 - } 342 - 343 - if (count($accept) > 1) { 344 - $names = implode(', ', array_keys($accept)); 345 - throw new PhabricatorMetaMTAReceivedMailProcessingException( 346 - MetaMTAReceivedMailStatus::STATUS_ABUNDANT_RECEIVERS, 347 - pht( 348 - 'Phabricator is not able to process this mail because more than '. 349 - 'one application is willing to accept it, creating ambiguity. '. 350 - 'Mail needs to be accepted by exactly one receiving application.'. 351 - "\n\n". 352 - 'Accepting receivers: %s.', 353 - $names)); 354 - } 355 - 356 - return head($accept); 357 - } 358 - 359 383 private function sendExceptionMail( 360 384 Exception $ex, 361 385 PhabricatorUser $viewer = null) { ··· 432 456 array( 433 457 'id' => $this->getID(), 434 458 )); 459 + } 460 + 461 + public function newFromAddress() { 462 + $raw_from = $this->getHeader('From'); 463 + 464 + if (strlen($raw_from)) { 465 + return new PhutilEmailAddress($raw_from); 466 + } 467 + 468 + return null; 469 + } 470 + 471 + private function getViewer() { 472 + return PhabricatorUser::getOmnipotentUser(); 473 + } 474 + 475 + /** 476 + * Identify the sender's user account for a piece of received mail. 477 + * 478 + * Note that this method does not validate that the sender is who they say 479 + * they are, just that they've presented some credential which corresponds 480 + * to a recognizable user. 481 + */ 482 + private function loadSender() { 483 + $viewer = $this->getViewer(); 484 + 485 + // Try to identify the user based on their "From" address. 486 + $from_address = $this->newFromAddress(); 487 + if ($from_address) { 488 + $user = id(new PhabricatorPeopleQuery()) 489 + ->setViewer($viewer) 490 + ->withEmails(array($from_address->getAddress())) 491 + ->executeOne(); 492 + if ($user) { 493 + return $user; 494 + } 495 + } 496 + 497 + return null; 498 + } 499 + 500 + private function validateSender(PhabricatorUser $sender) { 501 + $failure_reason = null; 502 + if ($sender->getIsDisabled()) { 503 + $failure_reason = pht( 504 + 'Your account ("%s") is disabled, so you can not interact with '. 505 + 'Phabricator over email.', 506 + $sender->getUsername()); 507 + } else if ($sender->getIsStandardUser()) { 508 + if (!$sender->getIsApproved()) { 509 + $failure_reason = pht( 510 + 'Your account ("%s") has not been approved yet. You can not '. 511 + 'interact with Phabricator over email until your account is '. 512 + 'approved.', 513 + $sender->getUsername()); 514 + } else if (PhabricatorUserEmail::isEmailVerificationRequired() && 515 + !$sender->getIsEmailVerified()) { 516 + $failure_reason = pht( 517 + 'You have not verified the email address for your account ("%s"). '. 518 + 'You must verify your email address before you can interact '. 519 + 'with Phabricator over email.', 520 + $sender->getUsername()); 521 + } 522 + } 523 + 524 + if ($failure_reason) { 525 + throw new PhabricatorMetaMTAReceivedMailProcessingException( 526 + MetaMTAReceivedMailStatus::STATUS_DISABLED_SENDER, 527 + $failure_reason); 528 + } 435 529 } 436 530 437 531 }
+4 -4
src/applications/metamta/storage/__tests__/PhabricatorMetaMTAReceivedMailTestCase.php
··· 48 48 } 49 49 50 50 public function testDropUnreceivableMail() { 51 + $user = $this->generateNewTestUser() 52 + ->save(); 53 + 51 54 $mail = new PhabricatorMetaMTAReceivedMail(); 52 55 $mail->setHeaders( 53 56 array( 54 57 'Message-ID' => 'test@example.com', 55 58 'To' => 'does+not+exist@example.com', 59 + 'From' => $user->loadPrimaryEmail()->getAddress(), 56 60 )); 57 61 $mail->setBodies( 58 62 array( ··· 69 73 70 74 public function testDropUnknownSenderMail() { 71 75 $this->setManiphestCreateEmail(); 72 - 73 - $env = PhabricatorEnv::beginScopedEnv(); 74 - $env->overrideEnvConfig('phabricator.allow-email-users', false); 75 - $env->overrideEnvConfig('metamta.maniphest.default-public-author', null); 76 76 77 77 $mail = new PhabricatorMetaMTAReceivedMail(); 78 78 $mail->setHeaders(
+9 -4
src/applications/paste/mail/PasteCreateMailReceiver.php
··· 9 9 10 10 protected function processReceivedMail( 11 11 PhabricatorMetaMTAReceivedMail $mail, 12 - PhabricatorUser $sender) { 12 + PhutilEmailAddress $target) { 13 + $author = $this->getAuthor(); 13 14 14 15 $title = $mail->getSubject(); 15 16 if (!$title) { ··· 26 27 ->setTransactionType(PhabricatorPasteTitleTransaction::TRANSACTIONTYPE) 27 28 ->setNewValue($title); 28 29 29 - $paste = PhabricatorPaste::initializeNewPaste($sender); 30 + $paste = PhabricatorPaste::initializeNewPaste($author); 30 31 31 32 $content_source = $mail->newContentSource(); 32 33 33 34 $editor = id(new PhabricatorPasteEditor()) 34 - ->setActor($sender) 35 + ->setActor($author) 35 36 ->setContentSource($content_source) 36 37 ->setContinueOnNoEffect(true); 37 38 $xactions = $editor->applyTransactions($paste, $xactions); 38 39 39 40 $mail->setRelatedPHID($paste->getPHID()); 40 41 42 + $sender = $this->getSender(); 43 + if (!$sender) { 44 + return; 45 + } 46 + 41 47 $subject_prefix = 42 48 PhabricatorEnv::getEnvConfig('metamta.paste.subject-prefix'); 43 49 $subject = pht('You successfully created a paste.'); ··· 55 61 ->setBody($body->render()) 56 62 ->saveAndSend(); 57 63 } 58 - 59 64 60 65 }
+1 -1
src/applications/paste/mail/PasteMailReceiver.php
··· 12 12 } 13 13 14 14 protected function loadObject($pattern, PhabricatorUser $viewer) { 15 - $id = (int)trim($pattern, 'P'); 15 + $id = (int)substr($pattern, 1); 16 16 17 17 return id(new PhabricatorPasteQuery()) 18 18 ->setViewer($viewer)
+1 -1
src/applications/phame/mail/PhamePostMailReceiver.php
··· 13 13 } 14 14 15 15 protected function loadObject($pattern, PhabricatorUser $viewer) { 16 - $id = (int)substr($pattern, 4); 16 + $id = (int)substr($pattern, 1); 17 17 18 18 return id(new PhamePostQuery()) 19 19 ->setViewer($viewer)
+1 -1
src/applications/pholio/mail/PholioMockMailReceiver.php
··· 12 12 } 13 13 14 14 protected function loadObject($pattern, PhabricatorUser $viewer) { 15 - $id = (int)trim($pattern, 'M'); 15 + $id = (int)substr($pattern, 1); 16 16 17 17 return id(new PholioMockQuery()) 18 18 ->setViewer($viewer)
+1 -1
src/applications/ponder/mail/PonderAnswerMailReceiver.php
··· 12 12 } 13 13 14 14 protected function loadObject($pattern, PhabricatorUser $viewer) { 15 - $id = (int)trim($pattern, 'ANSR'); 15 + $id = (int)substr($pattern, 4); 16 16 17 17 return id(new PonderAnswerQuery()) 18 18 ->setViewer($viewer)
+4 -4
src/applications/ponder/mail/PonderQuestionCreateMailReceiver.php
··· 9 9 10 10 protected function processReceivedMail( 11 11 PhabricatorMetaMTAReceivedMail $mail, 12 - PhabricatorUser $sender) { 12 + PhutilEmailAddress $target) { 13 + $author = $this->getAuthor(); 13 14 14 15 $title = $mail->getSubject(); 15 16 if (!strlen($title)) { ··· 26 27 ->setTransactionType(PonderQuestionTransaction::TYPE_CONTENT) 27 28 ->setNewValue($mail->getCleanTextBody()); 28 29 29 - $question = PonderQuestion::initializeNewQuestion($sender); 30 + $question = PonderQuestion::initializeNewQuestion($author); 30 31 31 32 $content_source = $mail->newContentSource(); 32 33 33 34 $editor = id(new PonderQuestionEditor()) 34 - ->setActor($sender) 35 + ->setActor($author) 35 36 ->setContentSource($content_source) 36 37 ->setContinueOnNoEffect(true); 37 38 $xactions = $editor->applyTransactions($question, $xactions); 38 39 39 40 $mail->setRelatedPHID($question->getPHID()); 40 - 41 41 } 42 42 43 43
+1 -1
src/applications/ponder/mail/PonderQuestionMailReceiver.php
··· 12 12 } 13 13 14 14 protected function loadObject($pattern, PhabricatorUser $viewer) { 15 - $id = (int)trim($pattern, 'Q'); 15 + $id = (int)substr($pattern, 1); 16 16 17 17 return id(new PonderQuestionQuery()) 18 18 ->setViewer($viewer)
+1 -1
src/applications/slowvote/mail/PhabricatorSlowvoteMailReceiver.php
··· 13 13 } 14 14 15 15 protected function loadObject($pattern, PhabricatorUser $viewer) { 16 - $id = (int)substr($pattern, 4); 16 + $id = (int)substr($pattern, 1); 17 17 18 18 return id(new PhabricatorSlowvoteQuery()) 19 19 ->setViewer($viewer)