PageRenderTime 62ms CodeModel.GetById 30ms RepoModel.GetById 0ms app.codeStats 1ms

/src/applications/metamta/storage/PhabricatorMetaMTAMail.php

https://gitlab.com/jforge/phabricator
PHP | 958 lines | 662 code | 145 blank | 151 comment | 65 complexity | 5a4db5acb8343aeebb0b84903e9dd32e MD5 | raw file
Possible License(s): LGPL-3.0, MIT, MPL-2.0-no-copyleft-exception, BSD-3-Clause, Apache-2.0, LGPL-2.0, LGPL-2.1
  1. <?php
  2. /**
  3. * @task recipients Managing Recipients
  4. */
  5. final class PhabricatorMetaMTAMail extends PhabricatorMetaMTADAO {
  6. const STATUS_QUEUE = 'queued';
  7. const STATUS_SENT = 'sent';
  8. const STATUS_FAIL = 'fail';
  9. const STATUS_VOID = 'void';
  10. const RETRY_DELAY = 5;
  11. protected $parameters;
  12. protected $status;
  13. protected $message;
  14. protected $relatedPHID;
  15. private $recipientExpansionMap;
  16. public function __construct() {
  17. $this->status = self::STATUS_QUEUE;
  18. $this->parameters = array();
  19. parent::__construct();
  20. }
  21. public function getConfiguration() {
  22. return array(
  23. self::CONFIG_SERIALIZATION => array(
  24. 'parameters' => self::SERIALIZATION_JSON,
  25. ),
  26. self::CONFIG_COLUMN_SCHEMA => array(
  27. 'status' => 'text32',
  28. 'relatedPHID' => 'phid?',
  29. // T6203/NULLABILITY
  30. // This should just be empty if there's no body.
  31. 'message' => 'text?',
  32. ),
  33. self::CONFIG_KEY_SCHEMA => array(
  34. 'status' => array(
  35. 'columns' => array('status'),
  36. ),
  37. 'relatedPHID' => array(
  38. 'columns' => array('relatedPHID'),
  39. ),
  40. 'key_created' => array(
  41. 'columns' => array('dateCreated'),
  42. ),
  43. ),
  44. ) + parent::getConfiguration();
  45. }
  46. protected function setParam($param, $value) {
  47. $this->parameters[$param] = $value;
  48. return $this;
  49. }
  50. protected function getParam($param, $default = null) {
  51. return idx($this->parameters, $param, $default);
  52. }
  53. /**
  54. * Set tags (@{class:MetaMTANotificationType} constants) which identify the
  55. * content of this mail in a general way. These tags are used to allow users
  56. * to opt out of receiving certain types of mail, like updates when a task's
  57. * projects change.
  58. *
  59. * @param list<const> List of @{class:MetaMTANotificationType} constants.
  60. * @return this
  61. */
  62. public function setMailTags(array $tags) {
  63. $this->setParam('mailtags', array_unique($tags));
  64. return $this;
  65. }
  66. public function getMailTags() {
  67. return $this->getParam('mailtags', array());
  68. }
  69. /**
  70. * In Gmail, conversations will be broken if you reply to a thread and the
  71. * server sends back a response without referencing your Message-ID, even if
  72. * it references a Message-ID earlier in the thread. To avoid this, use the
  73. * parent email's message ID explicitly if it's available. This overwrites the
  74. * "In-Reply-To" and "References" headers we would otherwise generate. This
  75. * needs to be set whenever an action is triggered by an email message. See
  76. * T251 for more details.
  77. *
  78. * @param string The "Message-ID" of the email which precedes this one.
  79. * @return this
  80. */
  81. public function setParentMessageID($id) {
  82. $this->setParam('parent-message-id', $id);
  83. return $this;
  84. }
  85. public function getParentMessageID() {
  86. return $this->getParam('parent-message-id');
  87. }
  88. public function getSubject() {
  89. return $this->getParam('subject');
  90. }
  91. public function addTos(array $phids) {
  92. $phids = array_unique($phids);
  93. $this->setParam('to', $phids);
  94. return $this;
  95. }
  96. public function addRawTos(array $raw_email) {
  97. // Strip addresses down to bare emails, since the MailAdapter API currently
  98. // requires we pass it just the address (like `alincoln@logcabin.org`), not
  99. // a full string like `"Abraham Lincoln" <alincoln@logcabin.org>`.
  100. foreach ($raw_email as $key => $email) {
  101. $object = new PhutilEmailAddress($email);
  102. $raw_email[$key] = $object->getAddress();
  103. }
  104. $this->setParam('raw-to', $raw_email);
  105. return $this;
  106. }
  107. public function addCCs(array $phids) {
  108. $phids = array_unique($phids);
  109. $this->setParam('cc', $phids);
  110. return $this;
  111. }
  112. public function setExcludeMailRecipientPHIDs(array $exclude) {
  113. $this->setParam('exclude', $exclude);
  114. return $this;
  115. }
  116. private function getExcludeMailRecipientPHIDs() {
  117. return $this->getParam('exclude', array());
  118. }
  119. public function getTranslation(array $objects) {
  120. $default_translation = PhabricatorEnv::getEnvConfig('translation.provider');
  121. $return = null;
  122. $recipients = array_merge(
  123. idx($this->parameters, 'to', array()),
  124. idx($this->parameters, 'cc', array()));
  125. foreach (array_select_keys($objects, $recipients) as $object) {
  126. $translation = null;
  127. if ($object instanceof PhabricatorUser) {
  128. $translation = $object->getTranslation();
  129. }
  130. if (!$translation) {
  131. $translation = $default_translation;
  132. }
  133. if ($return && $translation != $return) {
  134. return $default_translation;
  135. }
  136. $return = $translation;
  137. }
  138. if (!$return) {
  139. $return = $default_translation;
  140. }
  141. return $return;
  142. }
  143. public function addPHIDHeaders($name, array $phids) {
  144. foreach ($phids as $phid) {
  145. $this->addHeader($name, '<'.$phid.'>');
  146. }
  147. return $this;
  148. }
  149. public function addHeader($name, $value) {
  150. $this->parameters['headers'][] = array($name, $value);
  151. return $this;
  152. }
  153. public function addAttachment(PhabricatorMetaMTAAttachment $attachment) {
  154. $this->parameters['attachments'][] = $attachment->toDictionary();
  155. return $this;
  156. }
  157. public function getAttachments() {
  158. $dicts = $this->getParam('attachments');
  159. $result = array();
  160. foreach ($dicts as $dict) {
  161. $result[] = PhabricatorMetaMTAAttachment::newFromDictionary($dict);
  162. }
  163. return $result;
  164. }
  165. public function setAttachments(array $attachments) {
  166. assert_instances_of($attachments, 'PhabricatorMetaMTAAttachment');
  167. $this->setParam('attachments', mpull($attachments, 'toDictionary'));
  168. return $this;
  169. }
  170. public function setFrom($from) {
  171. $this->setParam('from', $from);
  172. return $this;
  173. }
  174. public function setReplyTo($reply_to) {
  175. $this->setParam('reply-to', $reply_to);
  176. return $this;
  177. }
  178. public function setSubject($subject) {
  179. $this->setParam('subject', $subject);
  180. return $this;
  181. }
  182. public function setSubjectPrefix($prefix) {
  183. $this->setParam('subject-prefix', $prefix);
  184. return $this;
  185. }
  186. public function setVarySubjectPrefix($prefix) {
  187. $this->setParam('vary-subject-prefix', $prefix);
  188. return $this;
  189. }
  190. public function setBody($body) {
  191. $this->setParam('body', $body);
  192. return $this;
  193. }
  194. public function setHTMLBody($html) {
  195. $this->setParam('html-body', $html);
  196. return $this;
  197. }
  198. public function getBody() {
  199. return $this->getParam('body');
  200. }
  201. public function getHTMLBody() {
  202. return $this->getParam('html-body');
  203. }
  204. public function setIsErrorEmail($is_error) {
  205. $this->setParam('is-error', $is_error);
  206. return $this;
  207. }
  208. public function getIsErrorEmail() {
  209. return $this->getParam('is-error', false);
  210. }
  211. public function getToPHIDs() {
  212. return $this->getParam('to', array());
  213. }
  214. public function getRawToAddresses() {
  215. return $this->getParam('raw-to', array());
  216. }
  217. public function getCcPHIDs() {
  218. return $this->getParam('cc', array());
  219. }
  220. /**
  221. * Force delivery of a message, even if recipients have preferences which
  222. * would otherwise drop the message.
  223. *
  224. * This is primarily intended to let users who don't want any email still
  225. * receive things like password resets.
  226. *
  227. * @param bool True to force delivery despite user preferences.
  228. * @return this
  229. */
  230. public function setForceDelivery($force) {
  231. $this->setParam('force', $force);
  232. return $this;
  233. }
  234. public function getForceDelivery() {
  235. return $this->getParam('force', false);
  236. }
  237. /**
  238. * Flag that this is an auto-generated bulk message and should have bulk
  239. * headers added to it if appropriate. Broadly, this means some flavor of
  240. * "Precedence: bulk" or similar, but is implementation and configuration
  241. * dependent.
  242. *
  243. * @param bool True if the mail is automated bulk mail.
  244. * @return this
  245. */
  246. public function setIsBulk($is_bulk) {
  247. $this->setParam('is-bulk', $is_bulk);
  248. return $this;
  249. }
  250. /**
  251. * Use this method to set an ID used for message threading. MetaMTA will
  252. * set appropriate headers (Message-ID, In-Reply-To, References and
  253. * Thread-Index) based on the capabilities of the underlying mailer.
  254. *
  255. * @param string Unique identifier, appropriate for use in a Message-ID,
  256. * In-Reply-To or References headers.
  257. * @param bool If true, indicates this is the first message in the thread.
  258. * @return this
  259. */
  260. public function setThreadID($thread_id, $is_first_message = false) {
  261. $this->setParam('thread-id', $thread_id);
  262. $this->setParam('is-first-message', $is_first_message);
  263. return $this;
  264. }
  265. /**
  266. * Save a newly created mail to the database. The mail will eventually be
  267. * delivered by the MetaMTA daemon.
  268. *
  269. * @return this
  270. */
  271. public function saveAndSend() {
  272. return $this->save();
  273. }
  274. public function save() {
  275. if ($this->getID()) {
  276. return parent::save();
  277. }
  278. // NOTE: When mail is sent from CLI scripts that run tasks in-process, we
  279. // may re-enter this method from within scheduleTask(). The implementation
  280. // is intended to avoid anything awkward if we end up reentering this
  281. // method.
  282. $this->openTransaction();
  283. // Save to generate a task ID.
  284. $result = parent::save();
  285. // Queue a task to send this mail.
  286. $mailer_task = PhabricatorWorker::scheduleTask(
  287. 'PhabricatorMetaMTAWorker',
  288. $this->getID(),
  289. PhabricatorWorker::PRIORITY_ALERTS);
  290. $this->saveTransaction();
  291. return $result;
  292. }
  293. public function buildDefaultMailer() {
  294. return PhabricatorEnv::newObjectFromConfig('metamta.mail-adapter');
  295. }
  296. /**
  297. * Attempt to deliver an email immediately, in this process.
  298. *
  299. * @param bool Try to deliver this email even if it has already been
  300. * delivered or is in backoff after a failed delivery attempt.
  301. * @param PhabricatorMailImplementationAdapter Use a specific mail adapter,
  302. * instead of the default.
  303. *
  304. * @return void
  305. */
  306. public function sendNow(
  307. $force_send = false,
  308. PhabricatorMailImplementationAdapter $mailer = null) {
  309. if ($mailer === null) {
  310. $mailer = $this->buildDefaultMailer();
  311. }
  312. if (!$force_send) {
  313. if ($this->getStatus() != self::STATUS_QUEUE) {
  314. throw new Exception('Trying to send an already-sent mail!');
  315. }
  316. }
  317. try {
  318. $params = $this->parameters;
  319. $actors = $this->loadAllActors();
  320. $deliverable_actors = $this->filterDeliverableActors($actors);
  321. $default_from = PhabricatorEnv::getEnvConfig('metamta.default-address');
  322. if (empty($params['from'])) {
  323. $mailer->setFrom($default_from);
  324. }
  325. $is_first = idx($params, 'is-first-message');
  326. unset($params['is-first-message']);
  327. $is_threaded = (bool)idx($params, 'thread-id');
  328. $reply_to_name = idx($params, 'reply-to-name', '');
  329. unset($params['reply-to-name']);
  330. $add_cc = array();
  331. $add_to = array();
  332. // Only try to use preferences if everything is multiplexed, so we
  333. // get consistent behavior.
  334. $use_prefs = self::shouldMultiplexAllMail();
  335. $prefs = null;
  336. if ($use_prefs) {
  337. // If multiplexing is enabled, some recipients will be in "Cc"
  338. // rather than "To". We'll move them to "To" later (or supply a
  339. // dummy "To") but need to look for the recipient in either the
  340. // "To" or "Cc" fields here.
  341. $target_phid = head(idx($params, 'to', array()));
  342. if (!$target_phid) {
  343. $target_phid = head(idx($params, 'cc', array()));
  344. }
  345. if ($target_phid) {
  346. $user = id(new PhabricatorUser())->loadOneWhere(
  347. 'phid = %s',
  348. $target_phid);
  349. if ($user) {
  350. $prefs = $user->loadPreferences();
  351. }
  352. }
  353. }
  354. foreach ($params as $key => $value) {
  355. switch ($key) {
  356. case 'from':
  357. $from = $value;
  358. $actor_email = null;
  359. $actor_name = null;
  360. $actor = idx($actors, $from);
  361. if ($actor) {
  362. $actor_email = $actor->getEmailAddress();
  363. $actor_name = $actor->getName();
  364. }
  365. $can_send_as_user = $actor_email &&
  366. PhabricatorEnv::getEnvConfig('metamta.can-send-as-user');
  367. if ($can_send_as_user) {
  368. $mailer->setFrom($actor_email, $actor_name);
  369. } else {
  370. $from_email = coalesce($actor_email, $default_from);
  371. $from_name = coalesce($actor_name, pht('Phabricator'));
  372. if (empty($params['reply-to'])) {
  373. $params['reply-to'] = $from_email;
  374. $params['reply-to-name'] = $from_name;
  375. }
  376. $mailer->setFrom($default_from, $from_name);
  377. }
  378. break;
  379. case 'reply-to':
  380. $mailer->addReplyTo($value, $reply_to_name);
  381. break;
  382. case 'to':
  383. $to_phids = $this->expandRecipients($value);
  384. $to_actors = array_select_keys($deliverable_actors, $to_phids);
  385. $add_to = array_merge(
  386. $add_to,
  387. mpull($to_actors, 'getEmailAddress'));
  388. break;
  389. case 'raw-to':
  390. $add_to = array_merge($add_to, $value);
  391. break;
  392. case 'cc':
  393. $cc_phids = $this->expandRecipients($value);
  394. $cc_actors = array_select_keys($deliverable_actors, $cc_phids);
  395. $add_cc = array_merge(
  396. $add_cc,
  397. mpull($cc_actors, 'getEmailAddress'));
  398. break;
  399. case 'headers':
  400. foreach ($value as $pair) {
  401. list($header_key, $header_value) = $pair;
  402. // NOTE: If we have \n in a header, SES rejects the email.
  403. $header_value = str_replace("\n", ' ', $header_value);
  404. $mailer->addHeader($header_key, $header_value);
  405. }
  406. break;
  407. case 'attachments':
  408. $value = $this->getAttachments();
  409. foreach ($value as $attachment) {
  410. $mailer->addAttachment(
  411. $attachment->getData(),
  412. $attachment->getFilename(),
  413. $attachment->getMimeType());
  414. }
  415. break;
  416. case 'subject':
  417. $subject = array();
  418. if ($is_threaded) {
  419. $add_re = PhabricatorEnv::getEnvConfig('metamta.re-prefix');
  420. if ($prefs) {
  421. $add_re = $prefs->getPreference(
  422. PhabricatorUserPreferences::PREFERENCE_RE_PREFIX,
  423. $add_re);
  424. }
  425. if ($add_re) {
  426. $subject[] = 'Re:';
  427. }
  428. }
  429. $subject[] = trim(idx($params, 'subject-prefix'));
  430. $vary_prefix = idx($params, 'vary-subject-prefix');
  431. if ($vary_prefix != '') {
  432. $use_subject = PhabricatorEnv::getEnvConfig(
  433. 'metamta.vary-subjects');
  434. if ($prefs) {
  435. $use_subject = $prefs->getPreference(
  436. PhabricatorUserPreferences::PREFERENCE_VARY_SUBJECT,
  437. $use_subject);
  438. }
  439. if ($use_subject) {
  440. $subject[] = $vary_prefix;
  441. }
  442. }
  443. $subject[] = $value;
  444. $mailer->setSubject(implode(' ', array_filter($subject)));
  445. break;
  446. case 'is-bulk':
  447. if ($value) {
  448. if (PhabricatorEnv::getEnvConfig('metamta.precedence-bulk')) {
  449. $mailer->addHeader('Precedence', 'bulk');
  450. }
  451. }
  452. break;
  453. case 'thread-id':
  454. // NOTE: Gmail freaks out about In-Reply-To and References which
  455. // aren't in the form "<string@domain.tld>"; this is also required
  456. // by RFC 2822, although some clients are more liberal in what they
  457. // accept.
  458. $domain = PhabricatorEnv::getEnvConfig('metamta.domain');
  459. $value = '<'.$value.'@'.$domain.'>';
  460. if ($is_first && $mailer->supportsMessageIDHeader()) {
  461. $mailer->addHeader('Message-ID', $value);
  462. } else {
  463. $in_reply_to = $value;
  464. $references = array($value);
  465. $parent_id = $this->getParentMessageID();
  466. if ($parent_id) {
  467. $in_reply_to = $parent_id;
  468. // By RFC 2822, the most immediate parent should appear last
  469. // in the "References" header, so this order is intentional.
  470. $references[] = $parent_id;
  471. }
  472. $references = implode(' ', $references);
  473. $mailer->addHeader('In-Reply-To', $in_reply_to);
  474. $mailer->addHeader('References', $references);
  475. }
  476. $thread_index = $this->generateThreadIndex($value, $is_first);
  477. $mailer->addHeader('Thread-Index', $thread_index);
  478. break;
  479. case 'mailtags':
  480. // Handled below.
  481. break;
  482. case 'subject-prefix':
  483. case 'vary-subject-prefix':
  484. // Handled above.
  485. break;
  486. default:
  487. // Just discard.
  488. }
  489. }
  490. $body = idx($params, 'body', '');
  491. $max = PhabricatorEnv::getEnvConfig('metamta.email-body-limit');
  492. if (strlen($body) > $max) {
  493. $body = id(new PhutilUTF8StringTruncator())
  494. ->setMaximumBytes($max)
  495. ->truncateString($body);
  496. $body .= "\n";
  497. $body .= pht('(This email was truncated at %d bytes.)', $max);
  498. }
  499. $mailer->setBody($body);
  500. $html_emails = false;
  501. if ($use_prefs && $prefs) {
  502. $html_emails = $prefs->getPreference(
  503. PhabricatorUserPreferences::PREFERENCE_HTML_EMAILS,
  504. $html_emails);
  505. }
  506. if ($html_emails && isset($params['html-body'])) {
  507. $mailer->setHTMLBody($params['html-body']);
  508. }
  509. if (!$add_to && !$add_cc) {
  510. $this->setStatus(self::STATUS_VOID);
  511. $this->setMessage(
  512. 'Message has no valid recipients: all To/Cc are disabled, invalid, '.
  513. 'or configured not to receive this mail.');
  514. return $this->save();
  515. }
  516. if ($this->getIsErrorEmail()) {
  517. $all_recipients = array_merge($add_to, $add_cc);
  518. if ($this->shouldRateLimitMail($all_recipients)) {
  519. $this->setStatus(self::STATUS_VOID);
  520. $this->setMessage(
  521. pht(
  522. 'This is an error email, but one or more recipients have '.
  523. 'exceeded the error email rate limit. Declining to deliver '.
  524. 'message.'));
  525. return $this->save();
  526. }
  527. }
  528. $mailer->addHeader('X-Phabricator-Sent-This-Message', 'Yes');
  529. $mailer->addHeader('X-Mail-Transport-Agent', 'MetaMTA');
  530. // Some clients respect this to suppress OOF and other auto-responses.
  531. $mailer->addHeader('X-Auto-Response-Suppress', 'All');
  532. // If the message has mailtags, filter out any recipients who don't want
  533. // to receive this type of mail.
  534. $mailtags = $this->getParam('mailtags');
  535. if ($mailtags) {
  536. $tag_header = array();
  537. foreach ($mailtags as $mailtag) {
  538. $tag_header[] = '<'.$mailtag.'>';
  539. }
  540. $tag_header = implode(', ', $tag_header);
  541. $mailer->addHeader('X-Phabricator-Mail-Tags', $tag_header);
  542. }
  543. // Some mailers require a valid "To:" in order to deliver mail. If we
  544. // don't have any "To:", try to fill it in with a placeholder "To:".
  545. // If that also fails, move the "Cc:" line to "To:".
  546. if (!$add_to) {
  547. $placeholder_key = 'metamta.placeholder-to-recipient';
  548. $placeholder = PhabricatorEnv::getEnvConfig($placeholder_key);
  549. if ($placeholder !== null) {
  550. $add_to = array($placeholder);
  551. } else {
  552. $add_to = $add_cc;
  553. $add_cc = array();
  554. }
  555. }
  556. $add_to = array_unique($add_to);
  557. $add_cc = array_diff(array_unique($add_cc), $add_to);
  558. $mailer->addTos($add_to);
  559. if ($add_cc) {
  560. $mailer->addCCs($add_cc);
  561. }
  562. } catch (Exception $ex) {
  563. $this
  564. ->setStatus(self::STATUS_FAIL)
  565. ->setMessage($ex->getMessage())
  566. ->save();
  567. throw $ex;
  568. }
  569. try {
  570. $ok = $mailer->send();
  571. if (!$ok) {
  572. // TODO: At some point, we should clean this up and make all mailers
  573. // throw.
  574. throw new Exception(
  575. pht('Mail adapter encountered an unexpected, unspecified failure.'));
  576. }
  577. $this->setStatus(self::STATUS_SENT);
  578. $this->save();
  579. return $this;
  580. } catch (PhabricatorMetaMTAPermanentFailureException $ex) {
  581. $this
  582. ->setStatus(self::STATUS_FAIL)
  583. ->setMessage($ex->getMessage())
  584. ->save();
  585. throw $ex;
  586. } catch (Exception $ex) {
  587. $this
  588. ->setMessage($ex->getMessage()."\n".$ex->getTraceAsString())
  589. ->save();
  590. throw $ex;
  591. }
  592. }
  593. public static function getReadableStatus($status_code) {
  594. static $readable = array(
  595. self::STATUS_QUEUE => 'Queued for Delivery',
  596. self::STATUS_FAIL => 'Delivery Failed',
  597. self::STATUS_SENT => 'Sent',
  598. self::STATUS_VOID => 'Void',
  599. );
  600. $status_code = coalesce($status_code, '?');
  601. return idx($readable, $status_code, $status_code);
  602. }
  603. private function generateThreadIndex($seed, $is_first_mail) {
  604. // When threading, Outlook ignores the 'References' and 'In-Reply-To'
  605. // headers that most clients use. Instead, it uses a custom 'Thread-Index'
  606. // header. The format of this header is something like this (from
  607. // camel-exchange-folder.c in Evolution Exchange):
  608. /* A new post to a folder gets a 27-byte-long thread index. (The value
  609. * is apparently unique but meaningless.) Each reply to a post gets a
  610. * 32-byte-long thread index whose first 27 bytes are the same as the
  611. * parent's thread index. Each reply to any of those gets a
  612. * 37-byte-long thread index, etc. The Thread-Index header contains a
  613. * base64 representation of this value.
  614. */
  615. // The specific implementation uses a 27-byte header for the first email
  616. // a recipient receives, and a random 5-byte suffix (32 bytes total)
  617. // thereafter. This means that all the replies are (incorrectly) siblings,
  618. // but it would be very difficult to keep track of the entire tree and this
  619. // gets us reasonable client behavior.
  620. $base = substr(md5($seed), 0, 27);
  621. if (!$is_first_mail) {
  622. // Not totally sure, but it seems like outlook orders replies by
  623. // thread-index rather than timestamp, so to get these to show up in the
  624. // right order we use the time as the last 4 bytes.
  625. $base .= ' '.pack('N', time());
  626. }
  627. return base64_encode($base);
  628. }
  629. public static function shouldMultiplexAllMail() {
  630. return PhabricatorEnv::getEnvConfig('metamta.one-mail-per-recipient');
  631. }
  632. /* -( Managing Recipients )------------------------------------------------ */
  633. /**
  634. * Get all of the recipients for this mail, after preference filters are
  635. * applied. This list has all objects to whom delivery will be attempted.
  636. *
  637. * @return list<phid> A list of all recipients to whom delivery will be
  638. * attempted.
  639. * @task recipients
  640. */
  641. public function buildRecipientList() {
  642. $actors = $this->loadActors(
  643. array_merge(
  644. $this->getToPHIDs(),
  645. $this->getCcPHIDs()));
  646. $actors = $this->filterDeliverableActors($actors);
  647. return mpull($actors, 'getPHID');
  648. }
  649. public function loadAllActors() {
  650. $actor_phids = array_merge(
  651. array($this->getParam('from')),
  652. $this->getToPHIDs(),
  653. $this->getCcPHIDs());
  654. $this->loadRecipientExpansions($actor_phids);
  655. $actor_phids = $this->expandRecipients($actor_phids);
  656. return $this->loadActors($actor_phids);
  657. }
  658. private function loadRecipientExpansions(array $phids) {
  659. $expansions = id(new PhabricatorMetaMTAMemberQuery())
  660. ->setViewer(PhabricatorUser::getOmnipotentUser())
  661. ->withPHIDs($phids)
  662. ->execute();
  663. $this->recipientExpansionMap = $expansions;
  664. return $this;
  665. }
  666. /**
  667. * Expand a list of recipient PHIDs (possibly including aggregate recipients
  668. * like projects) into a deaggregated list of individual recipient PHIDs.
  669. * For example, this will expand project PHIDs into a list of the project's
  670. * members.
  671. *
  672. * @param list<phid> List of recipient PHIDs, possibly including aggregate
  673. * recipients.
  674. * @return list<phid> Deaggregated list of mailable recipients.
  675. */
  676. private function expandRecipients(array $phids) {
  677. if ($this->recipientExpansionMap === null) {
  678. throw new Exception(
  679. pht(
  680. 'Call loadRecipientExpansions() before expandRecipients()!'));
  681. }
  682. $results = array();
  683. foreach ($phids as $phid) {
  684. if (!isset($this->recipientExpansionMap[$phid])) {
  685. $results[$phid] = $phid;
  686. } else {
  687. foreach ($this->recipientExpansionMap[$phid] as $recipient_phid) {
  688. $results[$recipient_phid] = $recipient_phid;
  689. }
  690. }
  691. }
  692. return array_keys($results);
  693. }
  694. private function filterDeliverableActors(array $actors) {
  695. assert_instances_of($actors, 'PhabricatorMetaMTAActor');
  696. $deliverable_actors = array();
  697. foreach ($actors as $phid => $actor) {
  698. if ($actor->isDeliverable()) {
  699. $deliverable_actors[$phid] = $actor;
  700. }
  701. }
  702. return $deliverable_actors;
  703. }
  704. private function loadActors(array $actor_phids) {
  705. $actor_phids = array_filter($actor_phids);
  706. $viewer = PhabricatorUser::getOmnipotentUser();
  707. $actors = id(new PhabricatorMetaMTAActorQuery())
  708. ->setViewer($viewer)
  709. ->withPHIDs($actor_phids)
  710. ->execute();
  711. if (!$actors) {
  712. return array();
  713. }
  714. if ($this->getForceDelivery()) {
  715. // If we're forcing delivery, skip all the opt-out checks.
  716. return $actors;
  717. }
  718. // Exclude explicit recipients.
  719. foreach ($this->getExcludeMailRecipientPHIDs() as $phid) {
  720. $actor = idx($actors, $phid);
  721. if (!$actor) {
  722. continue;
  723. }
  724. $actor->setUndeliverable(
  725. pht(
  726. 'This message is a response to another email message, and this '.
  727. 'recipient received the original email message, so we are not '.
  728. 'sending them this substantially similar message (for example, '.
  729. 'the sender used "Reply All" instead of "Reply" in response to '.
  730. 'mail from Phabricator).'));
  731. }
  732. // Exclude the actor if their preferences are set.
  733. $from_phid = $this->getParam('from');
  734. $from_actor = idx($actors, $from_phid);
  735. if ($from_actor) {
  736. $from_user = id(new PhabricatorPeopleQuery())
  737. ->setViewer($viewer)
  738. ->withPHIDs(array($from_phid))
  739. ->execute();
  740. $from_user = head($from_user);
  741. if ($from_user) {
  742. $pref_key = PhabricatorUserPreferences::PREFERENCE_NO_SELF_MAIL;
  743. $exclude_self = $from_user
  744. ->loadPreferences()
  745. ->getPreference($pref_key);
  746. if ($exclude_self) {
  747. $from_actor->setUndeliverable(
  748. pht(
  749. 'This recipient is the user whose actions caused delivery of '.
  750. 'this message, but they have set preferences so they do not '.
  751. 'receive mail about their own actions (Settings > Email '.
  752. 'Preferences > Self Actions).'));
  753. }
  754. }
  755. }
  756. $all_prefs = id(new PhabricatorUserPreferences())->loadAllWhere(
  757. 'userPHID in (%Ls)',
  758. $actor_phids);
  759. $all_prefs = mpull($all_prefs, null, 'getUserPHID');
  760. // Exclude recipients who don't want any mail.
  761. foreach ($all_prefs as $phid => $prefs) {
  762. $exclude = $prefs->getPreference(
  763. PhabricatorUserPreferences::PREFERENCE_NO_MAIL,
  764. false);
  765. if ($exclude) {
  766. $actors[$phid]->setUndeliverable(
  767. pht(
  768. 'This recipient has disabled all email notifications '.
  769. '(Settings > Email Preferences > Email Notifications).'));
  770. }
  771. }
  772. $value_email = PhabricatorUserPreferences::MAILTAG_PREFERENCE_EMAIL;
  773. // Exclude all recipients who have set preferences to not receive this type
  774. // of email (for example, a user who says they don't want emails about task
  775. // CC changes).
  776. $tags = $this->getParam('mailtags');
  777. if ($tags) {
  778. foreach ($all_prefs as $phid => $prefs) {
  779. $user_mailtags = $prefs->getPreference(
  780. PhabricatorUserPreferences::PREFERENCE_MAILTAGS,
  781. array());
  782. // The user must have elected to receive mail for at least one
  783. // of the mailtags.
  784. $send = false;
  785. foreach ($tags as $tag) {
  786. if (((int)idx($user_mailtags, $tag, $value_email)) == $value_email) {
  787. $send = true;
  788. break;
  789. }
  790. }
  791. if (!$send) {
  792. $actors[$phid]->setUndeliverable(
  793. pht(
  794. 'This mail has tags which control which users receive it, and '.
  795. 'this recipient has not elected to receive mail with any of '.
  796. 'the tags on this message (Settings > Email Preferences).'));
  797. }
  798. }
  799. }
  800. return $actors;
  801. }
  802. private function shouldRateLimitMail(array $all_recipients) {
  803. try {
  804. PhabricatorSystemActionEngine::willTakeAction(
  805. $all_recipients,
  806. new PhabricatorMetaMTAErrorMailAction(),
  807. 1);
  808. return false;
  809. } catch (PhabricatorSystemActionRateLimitException $ex) {
  810. return true;
  811. }
  812. }
  813. }