"; this is also required
+ // by RFC 2822, although some clients are more liberal in what they
+ // accept.
+ $domain = PhabricatorEnv::getEnvConfig('metamta.domain');
+ $value = '<'.$value.'@'.$domain.'>';
+
if ($is_first && $mailer->supportsMessageIDHeader()) {
$mailer->addHeader('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);
$mailer->addHeader('In-Reply-To', $in_reply_to);
$mailer->addHeader('References', $references);
}
$thread_index = $this->generateThreadIndex($value, $is_first);
$mailer->addHeader('Thread-Index', $thread_index);
break;
case 'mailtags':
// Handled below.
break;
+ case 'vary-subject':
+ // Handled above.
+ break;
default:
// Just discard.
}
}
$mailer->addHeader('X-Phabricator-Sent-This-Message', 'Yes');
$mailer->addHeader('X-Mail-Transport-Agent', 'MetaMTA');
// If the message has mailtags, filter out any recipients who don't want
// to receive this type of mail.
$mailtags = $this->getParam('mailtags');
if ($mailtags && ($add_to || $add_cc)) {
$tag_header = array();
foreach ($mailtags as $mailtag) {
$tag_header[] = '<'.$mailtag.'>';
}
$tag_header = implode(', ', $tag_header);
$mailer->addHeader('X-Phabricator-Mail-Tags', $tag_header);
$exclude = array();
$all_recipients = array_merge(
array_keys($add_to),
array_keys($add_cc));
$all_prefs = id(new PhabricatorUserPreferences())->loadAllWhere(
'userPHID in (%Ls)',
$all_recipients);
$all_prefs = mpull($all_prefs, null, 'getUserPHID');
foreach ($all_recipients as $recipient) {
$prefs = idx($all_prefs, $recipient);
if (!$prefs) {
continue;
}
$user_mailtags = $prefs->getPreference(
PhabricatorUserPreferences::PREFERENCE_MAILTAGS,
array());
// The user must have elected to receive mail for at least one
// of the mailtags.
$send = false;
foreach ($mailtags as $tag) {
if (idx($user_mailtags, $tag, true)) {
$send = true;
break;
}
}
if (!$send) {
$exclude[$recipient] = true;
}
}
$add_to = array_diff_key($add_to, $exclude);
$add_cc = array_diff_key($add_cc, $exclude);
}
if ($add_to) {
$mailer->addTos($add_to);
if ($add_cc) {
$mailer->addCCs($add_cc);
}
} else if ($add_cc) {
// If we have CC addresses but no "to" address, promote the CCs to
// "to".
$mailer->addTos($add_cc);
} else {
$this->setStatus(self::STATUS_VOID);
$this->setMessage(
"Message has no valid recipients: all To/CC are disabled or ".
"configured not to receive this mail.");
return $this->save();
}
} catch (Exception $ex) {
$this->setStatus(self::STATUS_FAIL);
$this->setMessage($ex->getMessage());
return $this->save();
}
if ($this->getRetryCount() < $this->getSimulatedFailureCount()) {
$ok = false;
$error = 'Simulated failure.';
} else {
try {
$ok = $mailer->send();
$error = null;
} catch (Exception $ex) {
$ok = false;
$error = $ex->getMessage()."\n".$ex->getTraceAsString();
}
}
if (!$ok) {
$this->setMessage($error);
if ($this->getRetryCount() > self::MAX_RETRIES) {
$this->setStatus(self::STATUS_FAIL);
} else {
$this->setRetryCount($this->getRetryCount() + 1);
$next_retry = time() + ($this->getRetryCount() * self::RETRY_DELAY);
$this->setNextRetry($next_retry);
}
} else {
$this->setStatus(self::STATUS_SENT);
}
return $this->save();
}
public static function getReadableStatus($status_code) {
static $readable = array(
self::STATUS_QUEUE => "Queued for Delivery",
self::STATUS_FAIL => "Delivery Failed",
self::STATUS_SENT => "Sent",
self::STATUS_VOID => "Void",
);
$status_code = coalesce($status_code, '?');
return idx($readable, $status_code, $status_code);
}
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);
}
private function getDeliverableEmailsFromHandles(
array $phids,
array $handles,
array $exclude) {
assert_instances_of($handles, 'PhabricatorObjectHandle');
$emails = array();
foreach ($phids as $phid) {
if ($handles[$phid]->isDisabled()) {
continue;
}
if (!$handles[$phid]->isComplete()) {
continue;
}
if (isset($exclude[$phid])) {
continue;
}
$emails[$phid] = $handles[$phid]->getEmail();
}
return $emails;
}
+ public static function shouldMultiplexAllMail() {
+ return PhabricatorEnv::getEnvConfig('metamta.one-mail-per-recipient');
+ }
+
}
diff --git a/src/applications/people/controller/settings/panels/emailpref/PhabricatorUserEmailPreferenceSettingsPanelController.php b/src/applications/people/controller/settings/panels/emailpref/PhabricatorUserEmailPreferenceSettingsPanelController.php
index 8b7e5c73d..d8d3b73dc 100644
--- a/src/applications/people/controller/settings/panels/emailpref/PhabricatorUserEmailPreferenceSettingsPanelController.php
+++ b/src/applications/people/controller/settings/panels/emailpref/PhabricatorUserEmailPreferenceSettingsPanelController.php
@@ -1,212 +1,253 @@
getRequest();
$user = $request->getUser();
$preferences = $user->loadPreferences();
$pref_re_prefix = PhabricatorUserPreferences::PREFERENCE_RE_PREFIX;
+ $pref_vary = PhabricatorUserPreferences::PREFERENCE_VARY_SUBJECT;
$pref_no_self_mail = PhabricatorUserPreferences::PREFERENCE_NO_SELF_MAIL;
$errors = array();
if ($request->isFormPost()) {
- if ($request->getStr($pref_re_prefix) == 'default') {
- $preferences->unsetPreference($pref_re_prefix);
- } else {
- $preferences->setPreference(
- $pref_re_prefix,
- $request->getBool($pref_re_prefix));
+ if (PhabricatorMetaMTAMail::shouldMultiplexAllMail()) {
+ if ($request->getStr($pref_re_prefix) == 'default') {
+ $preferences->unsetPreference($pref_re_prefix);
+ } else {
+ $preferences->setPreference(
+ $pref_re_prefix,
+ $request->getBool($pref_re_prefix));
+ }
+
+ if ($request->getStr($pref_vary) == 'default') {
+ $preferences->unsetPreference($pref_vary);
+ } else {
+ $preferences->setPreference(
+ $pref_vary,
+ $request->getBool($pref_vary));
+ }
}
$preferences->setPreference(
$pref_no_self_mail,
$request->getStr($pref_no_self_mail));
-
$new_tags = $request->getArr('mailtags');
$mailtags = $preferences->getPreference('mailtags', array());
foreach ($this->getMailTags() as $key => $label) {
$mailtags[$key] = (bool)idx($new_tags, $key, false);
}
$preferences->setPreference('mailtags', $mailtags);
$preferences->save();
return id(new AphrontRedirectResponse())
->setURI('/settings/page/emailpref/?saved=true');
}
$notice = null;
if (!$errors) {
if ($request->getStr('saved')) {
$notice = new AphrontErrorView();
$notice->setSeverity(AphrontErrorView::SEVERITY_NOTICE);
$notice->setTitle('Changes Saved');
$notice->appendChild('Your changes have been saved.
');
}
} else {
$notice = new AphrontErrorView();
$notice->setTitle('Form Errors');
$notice->setErrors($errors);
}
$re_prefix_default = PhabricatorEnv::getEnvConfig('metamta.re-prefix')
? 'Enabled'
: 'Disabled';
+ $vary_default = PhabricatorEnv::getEnvConfig('metamta.vary-subjects')
+ ? 'Vary'
+ : 'Do Not Vary';
+
$re_prefix_value = $preferences->getPreference($pref_re_prefix);
if ($re_prefix_value === null) {
- $re_prefix_value = 'defualt';
+ $re_prefix_value = 'default';
} else {
$re_prefix_value = $re_prefix_value
? 'true'
: 'false';
}
+ $vary_value = $preferences->getPreference($pref_vary);
+ if ($vary_value === null) {
+ $vary_value = 'default';
+ } else {
+ $vary_value = $vary_value
+ ? 'true'
+ : 'false';
+ }
+
$form = new AphrontFormView();
$form
->setUser($user)
->appendChild(
id(new AphrontFormSelectControl())
->setLabel('Self Actions')
->setName($pref_no_self_mail)
->setOptions(
array(
'0' => 'Send me an email when I take an action',
'1' => 'Do not send me an email when I take an action',
))
->setCaption('You can disable email about your own actions.')
- ->setValue($preferences->getPreference($pref_no_self_mail, 0)))
- ->appendChild(
- id(new AphrontFormSelectControl())
- ->setLabel('Add "Re:" Prefix')
- ->setName($pref_re_prefix)
- ->setCaption(
- 'Enable this option to fix threading in Mail.app on OS X Lion, '.
- 'or if you like "Re:" in your email subjects.')
- ->setOptions(
- array(
- 'default' => 'Use Server Default ('.$re_prefix_default.')',
- 'true' => 'Enable "Re:" prefix',
- 'false' => 'Disable "Re:" prefix',
- ))
- ->setValue($re_prefix_value));
+ ->setValue($preferences->getPreference($pref_no_self_mail, 0)));
+
+ if (PhabricatorMetaMTAMail::shouldMultiplexAllMail()) {
+ $form
+ ->appendChild(
+ id(new AphrontFormSelectControl())
+ ->setLabel('Add "Re:" Prefix')
+ ->setName($pref_re_prefix)
+ ->setCaption(
+ 'Enable this option to fix threading in Mail.app on OS X Lion, '.
+ 'or if you like "Re:" in your email subjects.')
+ ->setOptions(
+ array(
+ 'default' => 'Use Server Default ('.$re_prefix_default.')',
+ 'true' => 'Enable "Re:" prefix',
+ 'false' => 'Disable "Re:" prefix',
+ ))
+ ->setValue($re_prefix_value))
+ ->appendChild(
+ id(new AphrontFormSelectControl())
+ ->setLabel('Vary Subjects')
+ ->setName($pref_vary)
+ ->setCaption(
+ 'This option adds more information email subjects, but may '.
+ 'break threading in some clients.')
+ ->setOptions(
+ array(
+ 'default' => 'Use Server Default ('.$vary_default.')',
+ 'true' => 'Vary Subjects',
+ 'false' => 'Do Not Vary Subjects',
+ ))
+ ->setValue($vary_value));
+ }
$form
->appendChild(
'
'.
''.
'You can customize what mail you receive from Phabricator here.'.
'
'.
''.
'NOTE: If an update makes several changes (like '.
'adding CCs to a task, closing it, and adding a comment) you will '.
'still receive an email as long as at least one of the changes '.
'is set to notify you.'.
'
'
);
$mailtags = $preferences->getPreference('mailtags', array());
$form
->appendChild(
$this->buildMailTagCheckboxes(
$this->getDifferentialMailTags(),
$mailtags)
->setLabel('Differential'))
->appendChild(
$this->buildMailTagCheckboxes(
$this->getManiphestMailTags(),
$mailtags)
->setLabel('Maniphest'));
$form
->appendChild(
id(new AphrontFormSubmitControl())
->setValue('Save Preferences'));
$panel = new AphrontPanelView();
$panel->setHeader('Email Preferences');
$panel->setWidth(AphrontPanelView::WIDTH_FORM);
$panel->appendChild($form);
return id(new AphrontNullView())
->appendChild(
array(
$notice,
$panel,
));
}
private function getMailTags() {
return array(
MetaMTANotificationType::TYPE_DIFFERENTIAL_CC =>
"Send me email when a revision's CCs change.",
MetaMTANotificationType::TYPE_DIFFERENTIAL_COMMITTED =>
"Send me email when a revision is committed.",
MetaMTANotificationType::TYPE_MANIPHEST_PROJECTS =>
"Send me email when a task's associated projects change.",
MetaMTANotificationType::TYPE_MANIPHEST_PRIORITY =>
"Send me email when a task's priority changes.",
MetaMTANotificationType::TYPE_MANIPHEST_CC =>
"Send me email when a task's CCs change.",
);
}
private function getManiphestMailTags() {
return array_select_keys(
$this->getMailTags(),
array(
MetaMTANotificationType::TYPE_MANIPHEST_PROJECTS,
MetaMTANotificationType::TYPE_MANIPHEST_PRIORITY,
MetaMTANotificationType::TYPE_MANIPHEST_CC,
));
}
private function getDifferentialMailTags() {
return array_select_keys(
$this->getMailTags(),
array(
MetaMTANotificationType::TYPE_DIFFERENTIAL_CC,
MetaMTANotificationType::TYPE_DIFFERENTIAL_COMMITTED,
));
}
private function buildMailTagCheckboxes(
array $tags,
array $prefs) {
$control = new AphrontFormCheckboxControl();
foreach ($tags as $key => $label) {
$control->addCheckbox(
'mailtags['.$key.']',
1,
$label,
idx($prefs, $key, 1));
}
return $control;
}
}
diff --git a/src/applications/people/controller/settings/panels/emailpref/__init__.php b/src/applications/people/controller/settings/panels/emailpref/__init__.php
index c6bf80fd0..21869c2bd 100644
--- a/src/applications/people/controller/settings/panels/emailpref/__init__.php
+++ b/src/applications/people/controller/settings/panels/emailpref/__init__.php
@@ -1,25 +1,26 @@
array(
'preferences' => self::SERIALIZATION_JSON,
),
self::CONFIG_TIMESTAMPS => false,
) + parent::getConfiguration();
}
public function getPreference($key, $default = null) {
return idx($this->preferences, $key, $default);
}
public function setPreference($key, $value) {
$this->preferences[$key] = $value;
return $this;
}
public function unsetPreference($key) {
unset($this->preferences[$key]);
return $this;
}
}
diff --git a/src/applications/repository/worker/herald/PhabricatorRepositoryCommitHeraldWorker.php b/src/applications/repository/worker/herald/PhabricatorRepositoryCommitHeraldWorker.php
index ad35e89df..203dee9e3 100644
--- a/src/applications/repository/worker/herald/PhabricatorRepositoryCommitHeraldWorker.php
+++ b/src/applications/repository/worker/herald/PhabricatorRepositoryCommitHeraldWorker.php
@@ -1,273 +1,277 @@
loadOneWhere(
'commitID = %d',
$commit->getID());
if (!$data) {
// TODO: Permanent failure.
return;
}
$rules = HeraldRule::loadAllByContentTypeWithFullData(
HeraldContentTypeConfig::CONTENT_TYPE_COMMIT,
$commit->getPHID());
$adapter = new HeraldCommitAdapter(
$repository,
$commit,
$data);
$engine = new HeraldEngine();
$effects = $engine->applyRules($rules, $adapter);
$engine->applyEffects($effects, $adapter, $rules);
$audit_phids = $adapter->getAuditMap();
if ($audit_phids) {
$this->createAudits($commit, $audit_phids, $rules);
}
$this->createAuditsFromCommitMessage($commit, $data);
$email_phids = $adapter->getEmailPHIDs();
if (!$email_phids) {
return;
}
if ($repository->getDetail('herald-disabled')) {
// This just means "disable email"; audits are (mostly) idempotent.
return;
}
$xscript = $engine->getTranscript();
$revision = $adapter->loadDifferentialRevision();
if ($revision) {
$name = $revision->getTitle();
} else {
$name = $data->getSummary();
}
$author_phid = $data->getCommitDetail('authorPHID');
$reviewer_phid = $data->getCommitDetail('reviewerPHID');
$phids = array_filter(
array(
$author_phid,
$reviewer_phid,
$commit->getPHID(),
));
$handles = id(new PhabricatorObjectHandleData($phids))->loadHandles();
$commit_handle = $handles[$commit->getPHID()];
$commit_name = $commit_handle->getName();
if ($author_phid) {
$author_name = $handles[$author_phid]->getName();
} else {
$author_name = $data->getAuthorName();
}
if ($reviewer_phid) {
$reviewer_name = $handles[$reviewer_phid]->getName();
} else {
$reviewer_name = null;
}
$who = implode(', ', array_filter(array($author_name, $reviewer_name)));
$description = $data->getCommitMessage();
$commit_uri = PhabricatorEnv::getProductionURI($commit_handle->getURI());
$differential = $revision
? PhabricatorEnv::getProductionURI('/D'.$revision->getID())
: 'No revision.';
$files = $adapter->loadAffectedPaths();
sort($files);
$files = implode("\n ", $files);
$xscript_id = $xscript->getID();
$manage_uri = PhabricatorEnv::getProductionURI('/herald/view/commits/');
$why_uri = PhabricatorEnv::getProductionURI(
'/herald/transcript/'.$xscript_id.'/');
$reply_handler = PhabricatorAuditCommentEditor::newReplyHandlerForCommit(
$commit);
$reply_instructions = $reply_handler->getReplyHandlerInstructions();
if ($reply_instructions) {
$reply_instructions =
"\n".
"REPLY HANDLER ACTIONS\n".
" ".$reply_instructions."\n";
}
$body = <<getPHID());
list($thread_id, $thread_topic) = $threading;
$template = new PhabricatorMetaMTAMail();
$template->setRelatedPHID($commit->getPHID());
$template->setSubject($subject);
+ $template->setVarySubject($subject);
$template->setBody($body);
$template->setThreadID($thread_id, $is_new = true);
$template->addHeader('Thread-Topic', $thread_topic);
$template->setIsBulk(true);
$template->addHeader('X-Herald-Rules', $xscript->getXHeraldRulesHeader());
if ($author_phid) {
$template->setFrom($author_phid);
}
$mails = $reply_handler->multiplexMail(
$template,
id(new PhabricatorObjectHandleData($email_phids))->loadHandles(),
array());
foreach ($mails as $mail) {
$mail->saveAndSend();
}
}
private function createAudits(
PhabricatorRepositoryCommit $commit,
array $map,
array $rules) {
assert_instances_of($rules, 'HeraldRule');
$requests = id(new PhabricatorRepositoryAuditRequest())->loadAllWhere(
'commitPHID = %s',
$commit->getPHID());
$requests = mpull($requests, null, 'getAuditorPHID');
$rules = mpull($rules, null, 'getID');
foreach ($map as $phid => $rule_ids) {
$request = idx($requests, $phid);
if ($request) {
continue;
}
$reasons = array();
foreach ($rule_ids as $id) {
$rule_name = '?';
if ($rules[$id]) {
$rule_name = $rules[$id]->getName();
}
$reasons[] = 'Herald Rule #'.$id.' "'.$rule_name.'" Triggered Audit';
}
$request = new PhabricatorRepositoryAuditRequest();
$request->setCommitPHID($commit->getPHID());
$request->setAuditorPHID($phid);
$request->setAuditStatus(PhabricatorAuditStatusConstants::AUDIT_REQUIRED);
$request->setAuditReasons($reasons);
$request->save();
}
$commit->updateAuditStatus($requests);
$commit->save();
}
/**
* Find audit requests in the "Auditors" field if it is present and trigger
* explicit audit requests.
*/
private function createAuditsFromCommitMessage(
PhabricatorRepositoryCommit $commit,
PhabricatorRepositoryCommitData $data) {
$message = $data->getCommitMessage();
$matches = null;
if (!preg_match('/^Auditors:\s*(.*)$/im', $message, $matches)) {
return;
}
$phids = DifferentialFieldSpecification::parseCommitMessageObjectList(
$matches[1],
$include_mailables = false,
$allow_partial = true);
if (!$phids) {
return;
}
$requests = id(new PhabricatorRepositoryAuditRequest())->loadAllWhere(
'commitPHID = %s',
$commit->getPHID());
$requests = mpull($requests, null, 'getAuditorPHID');
foreach ($phids as $phid) {
if (isset($requests[$phid])) {
continue;
}
$request = new PhabricatorRepositoryAuditRequest();
$request->setCommitPHID($commit->getPHID());
$request->setAuditorPHID($phid);
$request->setAuditStatus(
PhabricatorAuditStatusConstants::AUDIT_REQUESTED);
$request->setAuditReasons(
array(
'Requested by Author',
));
$request->save();
$requests[$phid] = $request;
}
$commit->updateAuditStatus($requests);
$commit->save();
}
}