1
0
Fork 0
mirror of https://we.phorge.it/source/phorge.git synced 2024-12-04 20:52:43 +01:00
phorge-phorge/src/applications/metamta/replyhandler/PhabricatorMailReplyHandler.php
epriestley 6db97bde12 Build separate mail for each recipient, honoring recipient access levels
Summary:
Ref T6367. Removes `multiplexMail()`!

We can't pass a single body into a function which splits it anymore: we need to split recipients first, then build bodies for each recipient list. This lets us build separate bodies for each recipient's individual translation/access levels.

The new logic does this:

  - First, split recipients into groups called "targets".
    - Each target corresponds to one actual mail we're going to build.
    - Each target has a viewer (whose translation / access levels will be used to generate the mail).
    - Each target has a to/cc list (the users who we'll ultimately send the mail to).
  - For each target, build a custom mail body based on the viewer's access levels and settings (language prefs not actually implemented).
  - Then, deliver the mail.

Test Plan:
  - Read new config help.

Then did a bunch of testing, primarily with `bin/mail list-outbound` and `bin/mail show-outbound` (to review generated mail), `bin/phd debug taskmaster` (to run daemons freely) and `bin/worker execute --id <id>` (to repeatedly test a specific piece of code after identifying an issue).

With `one-mail-per-recipient` on (default):

  - Sent mail to multiple users.
  - Verified mail showed up in `mail list-outbound`.
  - Examined mail with `mail show-outbound`.
  - Added a project that a subscriber could not see.
    - Verified it was not present in `X-Phabricator-Projects`.
    - Verified it was rendered as "Restricted Project" for the non-permissioned viewer.
  - Added a subscriber, then changed the object policy so they could not see it and sent mail.
    - Verified I received mail but the other user did not.
  - Enabled public replies and verified mail generated with public addresses.
  - Disabld public replies and verified mail generated with private addresses.

With `one-mail-per-recipient` off:

  - Verified that one mail is sent to all recipients.
  - Verified users who can not see the object are still filtered.
  - Verified that partially-visible projects are completely visible in the mail (this violates policies, as documented, as the best available compromise).
  - Enabled public replies and verified the mail generated with "Reply To".

Reviewers: btrahan

Reviewed By: btrahan

Subscribers: carlsverre, epriestley

Maniphest Tasks: T6367

Differential Revision: https://secure.phabricator.com/D13131
2015-06-03 18:59:31 -07:00

394 lines
11 KiB
PHP

<?php
abstract class PhabricatorMailReplyHandler {
private $mailReceiver;
private $applicationEmail;
private $actor;
private $excludePHIDs = array();
final public function setMailReceiver($mail_receiver) {
$this->validateMailReceiver($mail_receiver);
$this->mailReceiver = $mail_receiver;
return $this;
}
final public function getMailReceiver() {
return $this->mailReceiver;
}
public function setApplicationEmail(
PhabricatorMetaMTAApplicationEmail $email) {
$this->applicationEmail = $email;
return $this;
}
public function getApplicationEmail() {
return $this->applicationEmail;
}
final public function setActor(PhabricatorUser $actor) {
$this->actor = $actor;
return $this;
}
final public function getActor() {
return $this->actor;
}
final public function setExcludeMailRecipientPHIDs(array $exclude) {
$this->excludePHIDs = $exclude;
return $this;
}
final public function getExcludeMailRecipientPHIDs() {
return $this->excludePHIDs;
}
abstract public function validateMailReceiver($mail_receiver);
abstract public function getPrivateReplyHandlerEmailAddress(
PhabricatorUser $user);
public function getReplyHandlerDomain() {
return PhabricatorEnv::getEnvConfig('metamta.reply-handler-domain');
}
abstract protected function receiveEmail(
PhabricatorMetaMTAReceivedMail $mail);
public function processEmail(PhabricatorMetaMTAReceivedMail $mail) {
$this->dropEmptyMail($mail);
return $this->receiveEmail($mail);
}
private function dropEmptyMail(PhabricatorMetaMTAReceivedMail $mail) {
$body = $mail->getCleanTextBody();
$attachments = $mail->getAttachments();
if (strlen($body) || $attachments) {
return;
}
// Only send an error email if the user is talking to just Phabricator.
// We can assume if there is only one "To" address it is a Phabricator
// address since this code is running and everything.
$is_direct_mail = (count($mail->getToAddresses()) == 1) &&
(count($mail->getCCAddresses()) == 0);
if ($is_direct_mail) {
$status_code = MetaMTAReceivedMailStatus::STATUS_EMPTY;
} else {
$status_code = MetaMTAReceivedMailStatus::STATUS_EMPTY_IGNORED;
}
throw new PhabricatorMetaMTAReceivedMailProcessingException(
$status_code,
pht(
'Your message does not contain any body text or attachments, so '.
'Phabricator can not do anything useful with it. Make sure comment '.
'text appears at the top of your message: quoted replies, inline '.
'text, and signatures are discarded and ignored.'));
}
public function supportsPrivateReplies() {
return (bool)$this->getReplyHandlerDomain() &&
!$this->supportsPublicReplies();
}
public function supportsPublicReplies() {
if (!PhabricatorEnv::getEnvConfig('metamta.public-replies')) {
return false;
}
if (!$this->getReplyHandlerDomain()) {
return false;
}
return (bool)$this->getPublicReplyHandlerEmailAddress();
}
final public function supportsReplies() {
return $this->supportsPrivateReplies() ||
$this->supportsPublicReplies();
}
public function getPublicReplyHandlerEmailAddress() {
return null;
}
protected function getDefaultPublicReplyHandlerEmailAddress($prefix) {
$receiver = $this->getMailReceiver();
$receiver_id = $receiver->getID();
$domain = $this->getReplyHandlerDomain();
// We compute a hash using the object's own PHID to prevent an attacker
// from blindly interacting with objects that they haven't ever received
// mail about by just sending to D1@, D2@, etc...
$hash = PhabricatorObjectMailReceiver::computeMailHash(
$receiver->getMailKey(),
$receiver->getPHID());
$address = "{$prefix}{$receiver_id}+public+{$hash}@{$domain}";
return $this->getSingleReplyHandlerPrefix($address);
}
protected function getSingleReplyHandlerPrefix($address) {
$single_handle_prefix = PhabricatorEnv::getEnvConfig(
'metamta.single-reply-handler-prefix');
return ($single_handle_prefix)
? $single_handle_prefix.'+'.$address
: $address;
}
protected function getDefaultPrivateReplyHandlerEmailAddress(
PhabricatorUser $user,
$prefix) {
$receiver = $this->getMailReceiver();
$receiver_id = $receiver->getID();
$user_id = $user->getID();
$hash = PhabricatorObjectMailReceiver::computeMailHash(
$receiver->getMailKey(),
$user->getPHID());
$domain = $this->getReplyHandlerDomain();
$address = "{$prefix}{$receiver_id}+{$user_id}+{$hash}@{$domain}";
return $this->getSingleReplyHandlerPrefix($address);
}
final protected function enhanceBodyWithAttachments(
$body,
array $attachments) {
if (!$attachments) {
return $body;
}
$files = id(new PhabricatorFileQuery())
->setViewer($this->getActor())
->withPHIDs($attachments)
->execute();
$output = array();
$output[] = $body;
// We're going to put all the non-images first in a list, then embed
// the images.
$head = array();
$tail = array();
foreach ($files as $file) {
if ($file->isViewableImage()) {
$tail[] = $file;
} else {
$head[] = $file;
}
}
if ($head) {
$list = array();
foreach ($head as $file) {
$list[] = ' - {'.$file->getMonogram().', layout=link}';
}
$output[] = implode("\n", $list);
}
if ($tail) {
$list = array();
foreach ($tail as $file) {
$list[] = '{'.$file->getMonogram().'}';
}
$output[] = implode("\n\n", $list);
}
$output = implode("\n\n", $output);
return rtrim($output);
}
/**
* Produce a list of mail targets for a given to/cc list.
*
* Each target should be sent a separate email, and contains the information
* required to generate it with appropriate permissions and configuration.
*
* @param list<phid> List of "To" PHIDs.
* @param list<phid> List of "CC" PHIDs.
* @return list<PhabricatorMailTarget> List of targets.
*/
final public function getMailTargets(array $raw_to, array $raw_cc) {
list($to, $cc) = $this->expandRecipientPHIDs($raw_to, $raw_cc);
list($to, $cc) = $this->loadRecipientUsers($to, $cc);
list($to, $cc) = $this->filterRecipientUsers($to, $cc);
if (!$to && !$cc) {
return array();
}
$template = id(new PhabricatorMailTarget())
->setRawToPHIDs($raw_to)
->setRawCCPHIDs($raw_cc);
// Set the public reply address as the default, if one exists. We
// might replace this with a private address later.
if ($this->supportsPublicReplies()) {
$reply_to = $this->getPublicReplyHandlerEmailAddress();
if ($reply_to) {
$template->setReplyTo($reply_to);
}
}
$supports_private_replies = $this->supportsPrivateReplies();
$mail_all = !PhabricatorEnv::getEnvConfig('metamta.one-mail-per-recipient');
$targets = array();
if ($mail_all) {
$target = id(clone $template)
->setViewer(PhabricatorUser::getOmnipotentUser())
->setToMap($to)
->setCCMap($cc);
$targets[] = $target;
} else {
$map = $to + $cc;
foreach ($map as $phid => $user) {
$target = id(clone $template)
->setViewer($user)
->setToMap(array($phid => $user))
->setCCMap(array());
if ($supports_private_replies) {
$reply_to = $this->getPrivateReplyHandlerEmailAddress($user);
if ($reply_to) {
$target->setReplyTo($reply_to);
}
}
$targets[] = $target;
}
}
return $targets;
}
/**
* Expand lists of recipient PHIDs.
*
* This takes any compound recipients (like projects) and looks up all their
* members.
*
* @param list<phid> List of To PHIDs.
* @param list<phid> List of CC PHIDs.
* @return pair<list<phid>, list<phid>> Expanded PHID lists.
*/
private function expandRecipientPHIDs(array $to, array $cc) {
$to_result = array();
$cc_result = array();
$all_phids = array_merge($to, $cc);
if ($all_phids) {
$map = id(new PhabricatorMetaMTAMemberQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withPHIDs($all_phids)
->execute();
foreach ($to as $phid) {
foreach ($map[$phid] as $expanded) {
$to_result[$expanded] = $expanded;
}
}
foreach ($cc as $phid) {
foreach ($map[$phid] as $expanded) {
$cc_result[$expanded] = $expanded;
}
}
}
// Remove recipients from "CC" if they're also present in "To".
$cc_result = array_diff_key($cc_result, $to_result);
return array(array_values($to_result), array_values($cc_result));
}
/**
* Load @{class:PhabricatorUser} objects for each recipient.
*
* Invalid recipients are dropped from the results.
*
* @param list<phid> List of To PHIDs.
* @param list<phid> List of CC PHIDs.
* @return pair<wild, wild> Maps from PHIDs to users.
*/
private function loadRecipientUsers(array $to, array $cc) {
$to_result = array();
$cc_result = array();
$all_phids = array_merge($to, $cc);
if ($all_phids) {
$users = id(new PhabricatorPeopleQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withPHIDs($all_phids)
->execute();
$users = mpull($users, null, 'getPHID');
foreach ($to as $phid) {
if (isset($users[$phid])) {
$to_result[$phid] = $users[$phid];
}
}
foreach ($cc as $phid) {
if (isset($users[$phid])) {
$cc_result[$phid] = $users[$phid];
}
}
}
return array($to_result, $cc_result);
}
/**
* Remove recipients who do not have permission to view the mail receiver.
*
* @param map<string, PhabricatorUser> Map of "To" users.
* @param map<string, PhabricatorUser> Map of "CC" users.
* @return pair<wild, wild> Filtered user maps.
*/
private function filterRecipientUsers(array $to, array $cc) {
$to_result = array();
$cc_result = array();
$all_users = $to + $cc;
if ($all_users) {
$can_see = array();
$object = $this->getMailReceiver();
foreach ($all_users as $phid => $user) {
$visible = PhabricatorPolicyFilter::hasCapability(
$user,
$object,
PhabricatorPolicyCapability::CAN_VIEW);
if ($visible) {
$can_see[$phid] = true;
}
}
foreach ($to as $phid => $user) {
if (!empty($can_see[$phid])) {
$to_result[$phid] = $all_users[$phid];
}
}
foreach ($cc as $phid => $user) {
if (!empty($can_see[$phid])) {
$cc_result[$phid] = $all_users[$phid];
}
}
}
return array($to_result, $cc_result);
}
}