PageRenderTime 57ms CodeModel.GetById 26ms RepoModel.GetById 0ms app.codeStats 0ms

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

http://github.com/facebook/phabricator
PHP | 544 lines | 375 code | 71 blank | 98 comment | 48 complexity | 61c9600f338c9647b6ff14911a6e59c9 MD5 | raw file
Possible License(s): JSON, MPL-2.0-no-copyleft-exception, Apache-2.0, BSD-3-Clause, LGPL-2.0, MIT, LGPL-2.1, LGPL-3.0
  1. <?php
  2. /*
  3. * Copyright 2012 Facebook, Inc.
  4. *
  5. * Licensed under the Apache License, Version 2.0 (the "License");
  6. * you may not use this file except in compliance with the License.
  7. * You may obtain a copy of the License at
  8. *
  9. * http://www.apache.org/licenses/LICENSE-2.0
  10. *
  11. * Unless required by applicable law or agreed to in writing, software
  12. * distributed under the License is distributed on an "AS IS" BASIS,
  13. * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  14. * See the License for the specific language governing permissions and
  15. * limitations under the License.
  16. */
  17. /**
  18. * See #394445 for an explanation of why this thing even exists.
  19. */
  20. class PhabricatorMetaMTAMail extends PhabricatorMetaMTADAO {
  21. const STATUS_QUEUE = 'queued';
  22. const STATUS_SENT = 'sent';
  23. const STATUS_FAIL = 'fail';
  24. const STATUS_VOID = 'void';
  25. const MAX_RETRIES = 250;
  26. const RETRY_DELAY = 5;
  27. protected $parameters;
  28. protected $status;
  29. protected $message;
  30. protected $retryCount;
  31. protected $nextRetry;
  32. protected $relatedPHID;
  33. public function __construct() {
  34. $this->status = self::STATUS_QUEUE;
  35. $this->retryCount = 0;
  36. $this->nextRetry = time();
  37. $this->parameters = array();
  38. parent::__construct();
  39. }
  40. public function getConfiguration() {
  41. return array(
  42. self::CONFIG_SERIALIZATION => array(
  43. 'parameters' => self::SERIALIZATION_JSON,
  44. ),
  45. ) + parent::getConfiguration();
  46. }
  47. protected function setParam($param, $value) {
  48. $this->parameters[$param] = $value;
  49. return $this;
  50. }
  51. protected function getParam($param) {
  52. return idx($this->parameters, $param);
  53. }
  54. /**
  55. * In Gmail, conversations will be broken if you reply to a thread and the
  56. * server sends back a response without referencing your Message-ID, even if
  57. * it references a Message-ID earlier in the thread. To avoid this, use the
  58. * parent email's message ID explicitly if it's available. This overwrites the
  59. * "In-Reply-To" and "References" headers we would otherwise generate. This
  60. * needs to be set whenever an action is triggered by an email message. See
  61. * T251 for more details.
  62. *
  63. * @param string The "Message-ID" of the email which precedes this one.
  64. * @return this
  65. */
  66. public function setParentMessageID($id) {
  67. $this->setParam('parent-message-id', $id);
  68. return $this;
  69. }
  70. public function getParentMessageID() {
  71. return $this->getParam('parent-message-id');
  72. }
  73. public function getSubject() {
  74. return $this->getParam('subject');
  75. }
  76. public function addTos(array $phids) {
  77. $phids = array_unique($phids);
  78. $this->setParam('to', $phids);
  79. return $this;
  80. }
  81. public function addCCs(array $phids) {
  82. $phids = array_unique($phids);
  83. $this->setParam('cc', $phids);
  84. return $this;
  85. }
  86. public function addHeader($name, $value) {
  87. $this->parameters['headers'][$name] = $value;
  88. return $this;
  89. }
  90. public function addAttachment(PhabricatorMetaMTAAttachment $attachment) {
  91. $this->parameters['attachments'][] = $attachment;
  92. return $this;
  93. }
  94. public function getAttachments() {
  95. return $this->getParam('attachments');
  96. }
  97. public function setAttachments(array $attachments) {
  98. $this->setParam('attachments', $attachments);
  99. return $this;
  100. }
  101. public function setFrom($from) {
  102. $this->setParam('from', $from);
  103. return $this;
  104. }
  105. public function setReplyTo($reply_to) {
  106. $this->setParam('reply-to', $reply_to);
  107. return $this;
  108. }
  109. public function setSubject($subject) {
  110. $this->setParam('subject', $subject);
  111. return $this;
  112. }
  113. public function setBody($body) {
  114. $this->setParam('body', $body);
  115. return $this;
  116. }
  117. public function getBody() {
  118. return $this->getParam('body');
  119. }
  120. public function setIsHTML($html) {
  121. $this->setParam('is-html', $html);
  122. return $this;
  123. }
  124. public function getSimulatedFailureCount() {
  125. return nonempty($this->getParam('simulated-failures'), 0);
  126. }
  127. public function setSimulatedFailureCount($count) {
  128. $this->setParam('simulated-failures', $count);
  129. return $this;
  130. }
  131. /**
  132. * Flag that this is an auto-generated bulk message and should have bulk
  133. * headers added to it if appropriate. Broadly, this means some flavor of
  134. * "Precedence: bulk" or similar, but is implementation and configuration
  135. * dependent.
  136. *
  137. * @param bool True if the mail is automated bulk mail.
  138. * @return this
  139. */
  140. public function setIsBulk($is_bulk) {
  141. $this->setParam('is-bulk', $is_bulk);
  142. return $this;
  143. }
  144. /**
  145. * Use this method to set an ID used for message threading. MetaMTA will
  146. * set appropriate headers (Message-ID, In-Reply-To, References and
  147. * Thread-Index) based on the capabilities of the underlying mailer.
  148. *
  149. * @param string Unique identifier, appropriate for use in a Message-ID,
  150. * In-Reply-To or References headers.
  151. * @param bool If true, indicates this is the first message in the thread.
  152. * @return this
  153. */
  154. public function setThreadID($thread_id, $is_first_message = false) {
  155. $this->setParam('thread-id', $thread_id);
  156. $this->setParam('is-first-message', $is_first_message);
  157. return $this;
  158. }
  159. /**
  160. * Save a newly created mail to the database and attempt to send it
  161. * immediately if the server is configured for immediate sends. When
  162. * applications generate new mail they should generally use this method to
  163. * deliver it. If the server doesn't use immediate sends, this has the same
  164. * effect as calling save(): the mail will eventually be delivered by the
  165. * MetaMTA daemon.
  166. *
  167. * @return this
  168. */
  169. public function saveAndSend() {
  170. $ret = null;
  171. if (PhabricatorEnv::getEnvConfig('metamta.send-immediately')) {
  172. $ret = $this->sendNow();
  173. } else {
  174. $ret = $this->save();
  175. }
  176. return $ret;
  177. }
  178. public function buildDefaultMailer() {
  179. $class_name = PhabricatorEnv::getEnvConfig('metamta.mail-adapter');
  180. PhutilSymbolLoader::loadClass($class_name);
  181. return newv($class_name, array());
  182. }
  183. /**
  184. * Attempt to deliver an email immediately, in this process.
  185. *
  186. * @param bool Try to deliver this email even if it has already been
  187. * delivered or is in backoff after a failed delivery attempt.
  188. * @param PhabricatorMailImplementationAdapter Use a specific mail adapter,
  189. * instead of the default.
  190. *
  191. * @return void
  192. */
  193. public function sendNow(
  194. $force_send = false,
  195. PhabricatorMailImplementationAdapter $mailer = null) {
  196. if ($mailer === null) {
  197. $mailer = $this->buildDefaultMailer();
  198. }
  199. if (!$force_send) {
  200. if ($this->getStatus() != self::STATUS_QUEUE) {
  201. throw new Exception("Trying to send an already-sent mail!");
  202. }
  203. if (time() < $this->getNextRetry()) {
  204. throw new Exception("Trying to send an email before next retry!");
  205. }
  206. }
  207. try {
  208. $parameters = $this->parameters;
  209. $phids = array();
  210. foreach ($parameters as $key => $value) {
  211. switch ($key) {
  212. case 'from':
  213. case 'to':
  214. case 'cc':
  215. if (!is_array($value)) {
  216. $value = array($value);
  217. }
  218. foreach (array_filter($value) as $phid) {
  219. $phids[] = $phid;
  220. }
  221. break;
  222. }
  223. }
  224. $handles = id(new PhabricatorObjectHandleData($phids))
  225. ->loadHandles();
  226. $exclude = array();
  227. $params = $this->parameters;
  228. $default = PhabricatorEnv::getEnvConfig('metamta.default-address');
  229. if (empty($params['from'])) {
  230. $mailer->setFrom($default);
  231. } else {
  232. $from = $params['from'];
  233. // If the user has set their preferences to not send them email about
  234. // things they do, exclude them from being on To or Cc.
  235. $from_user = id(new PhabricatorUser())->loadOneWhere(
  236. 'phid = %s',
  237. $from);
  238. if ($from_user) {
  239. $pref_key = PhabricatorUserPreferences::PREFERENCE_NO_SELF_MAIL;
  240. $exclude_self = $from_user
  241. ->loadPreferences()
  242. ->getPreference($pref_key);
  243. if ($exclude_self) {
  244. $exclude[$from] = true;
  245. }
  246. }
  247. if (!PhabricatorEnv::getEnvConfig('metamta.can-send-as-user')) {
  248. $handle = $handles[$from];
  249. if (empty($params['reply-to'])) {
  250. $params['reply-to'] = $handle->getEmail();
  251. $params['reply-to-name'] = $handle->getFullName();
  252. }
  253. $mailer->setFrom(
  254. $default,
  255. $handle->getFullName());
  256. unset($params['from']);
  257. }
  258. }
  259. $is_first = idx($params, 'is-first-message');
  260. unset($params['is-first-message']);
  261. $is_threaded = (bool)idx($params, 'thread-id');
  262. $reply_to_name = idx($params, 'reply-to-name', '');
  263. unset($params['reply-to-name']);
  264. $add_cc = null;
  265. $add_to = null;
  266. foreach ($params as $key => $value) {
  267. switch ($key) {
  268. case 'from':
  269. $mailer->setFrom($handles[$value]->getEmail());
  270. break;
  271. case 'reply-to':
  272. $mailer->addReplyTo($value, $reply_to_name);
  273. break;
  274. case 'to':
  275. $emails = $this->getDeliverableEmailsFromHandles(
  276. $value,
  277. $handles,
  278. $exclude);
  279. if ($emails) {
  280. $add_to = $emails;
  281. }
  282. break;
  283. case 'cc':
  284. $emails = $this->getDeliverableEmailsFromHandles(
  285. $value,
  286. $handles,
  287. $exclude);
  288. if ($emails) {
  289. $add_cc = $emails;
  290. }
  291. break;
  292. case 'headers':
  293. foreach ($value as $header_key => $header_value) {
  294. // NOTE: If we have \n in a header, SES rejects the email.
  295. $header_value = str_replace("\n", " ", $header_value);
  296. $mailer->addHeader($header_key, $header_value);
  297. }
  298. break;
  299. case 'attachments':
  300. foreach ($value as $attachment) {
  301. $mailer->addAttachment(
  302. $attachment->getData(),
  303. $attachment->getFilename(),
  304. $attachment->getMimeType()
  305. );
  306. }
  307. break;
  308. case 'body':
  309. $mailer->setBody($value);
  310. break;
  311. case 'subject':
  312. if ($is_threaded) {
  313. $add_re = PhabricatorEnv::getEnvConfig('metamta.re-prefix');
  314. // If this message has a single recipient, respect their "Re:"
  315. // preference. Otherwise, use the global setting.
  316. $to = idx($params, 'to', array());
  317. $cc = idx($params, 'cc', array());
  318. if (count($to) == 1 && count($cc) == 0) {
  319. $user = id(new PhabricatorUser())->loadOneWhere(
  320. 'phid = %s',
  321. head($to));
  322. if ($user) {
  323. $prefs = $user->loadPreferences();
  324. $pref_key = PhabricatorUserPreferences::PREFERENCE_RE_PREFIX;
  325. $add_re = $prefs->getPreference($pref_key, $add_re);
  326. }
  327. }
  328. if ($add_re) {
  329. $value = 'Re: '.$value;
  330. }
  331. }
  332. $mailer->setSubject($value);
  333. break;
  334. case 'is-html':
  335. if ($value) {
  336. $mailer->setIsHTML(true);
  337. }
  338. break;
  339. case 'is-bulk':
  340. if ($value) {
  341. if (PhabricatorEnv::getEnvConfig('metamta.precedence-bulk')) {
  342. $mailer->addHeader('Precedence', 'bulk');
  343. }
  344. }
  345. break;
  346. case 'thread-id':
  347. if ($is_first && $mailer->supportsMessageIDHeader()) {
  348. $mailer->addHeader('Message-ID', $value);
  349. } else {
  350. $in_reply_to = $value;
  351. $references = array($value);
  352. $parent_id = $this->getParentMessageID();
  353. if ($parent_id) {
  354. $in_reply_to = $parent_id;
  355. // By RFC 2822, the most immediate parent should appear last
  356. // in the "References" header, so this order is intentional.
  357. $references[] = $parent_id;
  358. }
  359. $references = implode(' ', $references);
  360. $mailer->addHeader('In-Reply-To', $in_reply_to);
  361. $mailer->addHeader('References', $references);
  362. }
  363. $thread_index = $this->generateThreadIndex($value, $is_first);
  364. $mailer->addHeader('Thread-Index', $thread_index);
  365. break;
  366. default:
  367. // Just discard.
  368. }
  369. }
  370. $mailer->addHeader('X-Mail-Transport-Agent', 'MetaMTA');
  371. if ($add_to) {
  372. $mailer->addTos($add_to);
  373. if ($add_cc) {
  374. $mailer->addCCs($add_cc);
  375. }
  376. } else if ($add_cc) {
  377. // If we have CC addresses but no "to" address, promote the CCs to
  378. // "to".
  379. $mailer->addTos($add_cc);
  380. } else {
  381. $this->setStatus(self::STATUS_VOID);
  382. $this->setMessage(
  383. "Message has no valid recipients: all To/CC are disabled or ".
  384. "configured not to receive this mail.");
  385. return $this->save();
  386. }
  387. } catch (Exception $ex) {
  388. $this->setStatus(self::STATUS_FAIL);
  389. $this->setMessage($ex->getMessage());
  390. return $this->save();
  391. }
  392. if ($this->getRetryCount() < $this->getSimulatedFailureCount()) {
  393. $ok = false;
  394. $error = 'Simulated failure.';
  395. } else {
  396. try {
  397. $ok = $mailer->send();
  398. $error = null;
  399. } catch (Exception $ex) {
  400. $ok = false;
  401. $error = $ex->getMessage()."\n".$ex->getTraceAsString();
  402. }
  403. }
  404. if (!$ok) {
  405. $this->setMessage($error);
  406. if ($this->getRetryCount() > self::MAX_RETRIES) {
  407. $this->setStatus(self::STATUS_FAIL);
  408. } else {
  409. $this->setRetryCount($this->getRetryCount() + 1);
  410. $next_retry = time() + ($this->getRetryCount() * self::RETRY_DELAY);
  411. $this->setNextRetry($next_retry);
  412. }
  413. } else {
  414. $this->setStatus(self::STATUS_SENT);
  415. }
  416. return $this->save();
  417. }
  418. public static function getReadableStatus($status_code) {
  419. static $readable = array(
  420. self::STATUS_QUEUE => "Queued for Delivery",
  421. self::STATUS_FAIL => "Delivery Failed",
  422. self::STATUS_SENT => "Sent",
  423. self::STATUS_VOID => "Void",
  424. );
  425. $status_code = coalesce($status_code, '?');
  426. return idx($readable, $status_code, $status_code);
  427. }
  428. private function generateThreadIndex($seed, $is_first_mail) {
  429. // When threading, Outlook ignores the 'References' and 'In-Reply-To'
  430. // headers that most clients use. Instead, it uses a custom 'Thread-Index'
  431. // header. The format of this header is something like this (from
  432. // camel-exchange-folder.c in Evolution Exchange):
  433. /* A new post to a folder gets a 27-byte-long thread index. (The value
  434. * is apparently unique but meaningless.) Each reply to a post gets a
  435. * 32-byte-long thread index whose first 27 bytes are the same as the
  436. * parent's thread index. Each reply to any of those gets a
  437. * 37-byte-long thread index, etc. The Thread-Index header contains a
  438. * base64 representation of this value.
  439. */
  440. // The specific implementation uses a 27-byte header for the first email
  441. // a recipient receives, and a random 5-byte suffix (32 bytes total)
  442. // thereafter. This means that all the replies are (incorrectly) siblings,
  443. // but it would be very difficult to keep track of the entire tree and this
  444. // gets us reasonable client behavior.
  445. $base = substr(md5($seed), 0, 27);
  446. if (!$is_first_mail) {
  447. // Not totally sure, but it seems like outlook orders replies by
  448. // thread-index rather than timestamp, so to get these to show up in the
  449. // right order we use the time as the last 4 bytes.
  450. $base .= ' '.pack('N', time());
  451. }
  452. return base64_encode($base);
  453. }
  454. private function getDeliverableEmailsFromHandles(
  455. array $phids,
  456. array $handles,
  457. array $exclude) {
  458. $emails = array();
  459. foreach ($phids as $phid) {
  460. if ($handles[$phid]->isDisabled()) {
  461. continue;
  462. }
  463. if (!$handles[$phid]->isComplete()) {
  464. continue;
  465. }
  466. if (isset($exclude[$phid])) {
  467. continue;
  468. }
  469. $emails[] = $handles[$phid]->getEmail();
  470. }
  471. return $emails;
  472. }
  473. }