Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
freebsd
GitHub Repository: freebsd/phabricator
Path: blob/master/src/applications/metamta/engine/PhabricatorMailEmailEngine.php
12256 views
1
<?php
2
3
final class PhabricatorMailEmailEngine
4
extends PhabricatorMailMessageEngine {
5
6
public function newMessage() {
7
$mailer = $this->getMailer();
8
$mail = $this->getMail();
9
10
$message = new PhabricatorMailEmailMessage();
11
12
$from_address = $this->newFromEmailAddress();
13
$message->setFromAddress($from_address);
14
15
$reply_address = $this->newReplyToEmailAddress();
16
if ($reply_address) {
17
$message->setReplyToAddress($reply_address);
18
}
19
20
$to_addresses = $this->newToEmailAddresses();
21
$cc_addresses = $this->newCCEmailAddresses();
22
23
if (!$to_addresses && !$cc_addresses) {
24
$mail->setMessage(
25
pht(
26
'Message has no valid recipients: all To/CC are disabled, '.
27
'invalid, or configured not to receive this mail.'));
28
return null;
29
}
30
31
// If this email describes a mail processing error, we rate limit outbound
32
// messages to each individual address. This prevents messes where
33
// something is stuck in a loop or dumps a ton of messages on us suddenly.
34
if ($mail->getIsErrorEmail()) {
35
$all_recipients = array();
36
foreach ($to_addresses as $to_address) {
37
$all_recipients[] = $to_address->getAddress();
38
}
39
foreach ($cc_addresses as $cc_address) {
40
$all_recipients[] = $cc_address->getAddress();
41
}
42
if ($this->shouldRateLimitMail($all_recipients)) {
43
$mail->setMessage(
44
pht(
45
'This is an error email, but one or more recipients have '.
46
'exceeded the error email rate limit. Declining to deliver '.
47
'message.'));
48
return null;
49
}
50
}
51
52
// Some mailers require a valid "To:" in order to deliver mail. If we
53
// don't have any "To:", try to fill it in with a placeholder "To:".
54
// If that also fails, move the "Cc:" line to "To:".
55
if (!$to_addresses) {
56
$void_address = $this->newVoidEmailAddress();
57
$to_addresses = array($void_address);
58
}
59
60
$to_addresses = $this->getUniqueEmailAddresses($to_addresses);
61
$cc_addresses = $this->getUniqueEmailAddresses(
62
$cc_addresses,
63
$to_addresses);
64
65
$message->setToAddresses($to_addresses);
66
$message->setCCAddresses($cc_addresses);
67
68
$attachments = $this->newEmailAttachments();
69
$message->setAttachments($attachments);
70
71
$subject = $this->newEmailSubject();
72
$message->setSubject($subject);
73
74
$headers = $this->newEmailHeaders();
75
foreach ($this->newEmailThreadingHeaders($mailer) as $threading_header) {
76
$headers[] = $threading_header;
77
}
78
79
$stamps = $mail->getMailStamps();
80
if ($stamps) {
81
$headers[] = $this->newEmailHeader(
82
'X-Phabricator-Stamps',
83
implode(' ', $stamps));
84
}
85
86
$must_encrypt = $mail->getMustEncrypt();
87
88
$raw_body = $mail->getBody();
89
$body = $raw_body;
90
if ($must_encrypt) {
91
$parts = array();
92
93
$encrypt_uri = $mail->getMustEncryptURI();
94
if ($encrypt_uri === null || !strlen($encrypt_uri)) {
95
$encrypt_phid = $mail->getRelatedPHID();
96
if ($encrypt_phid) {
97
$encrypt_uri = urisprintf(
98
'/object/%s/',
99
$encrypt_phid);
100
}
101
}
102
103
if ($encrypt_uri !== null && strlen($encrypt_uri)) {
104
$parts[] = pht(
105
'This secure message is notifying you of a change to this object:');
106
$parts[] = PhabricatorEnv::getProductionURI($encrypt_uri);
107
}
108
109
$parts[] = pht(
110
'The content for this message can only be transmitted over a '.
111
'secure channel. To view the message content, follow this '.
112
'link:');
113
114
$parts[] = PhabricatorEnv::getProductionURI($mail->getURI());
115
116
$body = implode("\n\n", $parts);
117
} else {
118
$body = $raw_body;
119
}
120
121
$body_limit = PhabricatorEnv::getEnvConfig('metamta.email-body-limit');
122
123
$body = phutil_string_cast($body);
124
if (strlen($body) > $body_limit) {
125
$body = id(new PhutilUTF8StringTruncator())
126
->setMaximumBytes($body_limit)
127
->truncateString($body);
128
$body .= "\n";
129
$body .= pht('(This email was truncated at %d bytes.)', $body_limit);
130
}
131
$message->setTextBody($body);
132
$body_limit -= strlen($body);
133
134
// If we sent a different message body than we were asked to, record
135
// what we actually sent to make debugging and diagnostics easier.
136
if ($body !== $raw_body) {
137
$mail->setDeliveredBody($body);
138
}
139
140
if ($must_encrypt) {
141
$send_html = false;
142
} else {
143
$send_html = $this->shouldSendHTML();
144
}
145
146
if ($send_html) {
147
$html_body = $mail->getHTMLBody();
148
if (phutil_nonempty_string($html_body)) {
149
// NOTE: We just drop the entire HTML body if it won't fit. Safely
150
// truncating HTML is hard, and we already have the text body to fall
151
// back to.
152
if (strlen($html_body) <= $body_limit) {
153
$message->setHTMLBody($html_body);
154
$body_limit -= strlen($html_body);
155
}
156
}
157
}
158
159
// Pass the headers to the mailer, then save the state so we can show
160
// them in the web UI. If the mail must be encrypted, we remove headers
161
// which are not on a strict whitelist to avoid disclosing information.
162
$filtered_headers = $this->filterHeaders($headers, $must_encrypt);
163
$message->setHeaders($filtered_headers);
164
165
$mail->setUnfilteredHeaders($headers);
166
$mail->setDeliveredHeaders($headers);
167
168
if (PhabricatorEnv::getEnvConfig('phabricator.silent')) {
169
$mail->setMessage(
170
pht(
171
'This software is running in silent mode. See `%s` '.
172
'in the configuration to change this setting.',
173
'phabricator.silent'));
174
175
return null;
176
}
177
178
return $message;
179
}
180
181
/* -( Message Components )------------------------------------------------- */
182
183
private function newFromEmailAddress() {
184
$from_address = $this->newDefaultEmailAddress();
185
$mail = $this->getMail();
186
187
// If the mail content must be encrypted, always disguise the sender.
188
$must_encrypt = $mail->getMustEncrypt();
189
if ($must_encrypt) {
190
return $from_address;
191
}
192
193
// If we have a raw "From" address, use that.
194
$raw_from = $mail->getRawFrom();
195
if ($raw_from) {
196
list($from_email, $from_name) = $raw_from;
197
return $this->newEmailAddress($from_email, $from_name);
198
}
199
200
// Otherwise, use as much of the information for any sending entity as
201
// we can.
202
$from_phid = $mail->getFrom();
203
204
$actor = $this->getActor($from_phid);
205
if ($actor) {
206
$actor_email = $actor->getEmailAddress();
207
$actor_name = $actor->getName();
208
} else {
209
$actor_email = null;
210
$actor_name = null;
211
}
212
213
$send_as_user = PhabricatorEnv::getEnvConfig('metamta.can-send-as-user');
214
if ($send_as_user) {
215
if ($actor_email !== null) {
216
$from_address->setAddress($actor_email);
217
}
218
}
219
220
if ($actor_name !== null) {
221
$from_address->setDisplayName($actor_name);
222
}
223
224
return $from_address;
225
}
226
227
private function newReplyToEmailAddress() {
228
$mail = $this->getMail();
229
230
$reply_raw = $mail->getReplyTo();
231
if (!phutil_nonempty_string($reply_raw)) {
232
return null;
233
}
234
235
$reply_address = new PhutilEmailAddress($reply_raw);
236
237
// If we have a sending object, change the display name.
238
$from_phid = $mail->getFrom();
239
$actor = $this->getActor($from_phid);
240
if ($actor) {
241
$reply_address->setDisplayName($actor->getName());
242
}
243
244
// If we don't have a display name, fill in a default.
245
$reply_display_name = $reply_address->getDisplayName();
246
if ($reply_display_name === null || !strlen($reply_display_name)) {
247
$reply_address->setDisplayName(PlatformSymbols::getPlatformServerName());
248
}
249
250
return $reply_address;
251
}
252
253
private function newToEmailAddresses() {
254
$mail = $this->getMail();
255
256
$phids = $mail->getToPHIDs();
257
$addresses = $this->newEmailAddressesFromActorPHIDs($phids);
258
259
foreach ($mail->getRawToAddresses() as $raw_address) {
260
$addresses[] = new PhutilEmailAddress($raw_address);
261
}
262
263
return $addresses;
264
}
265
266
private function newCCEmailAddresses() {
267
$mail = $this->getMail();
268
$phids = $mail->getCcPHIDs();
269
return $this->newEmailAddressesFromActorPHIDs($phids);
270
}
271
272
private function newEmailAddressesFromActorPHIDs(array $phids) {
273
$mail = $this->getMail();
274
$phids = $mail->expandRecipients($phids);
275
276
$addresses = array();
277
foreach ($phids as $phid) {
278
$actor = $this->getActor($phid);
279
if (!$actor) {
280
continue;
281
}
282
283
if (!$actor->isDeliverable()) {
284
continue;
285
}
286
287
$addresses[] = new PhutilEmailAddress($actor->getEmailAddress());
288
}
289
290
return $addresses;
291
}
292
293
private function newEmailSubject() {
294
$mail = $this->getMail();
295
296
$is_threaded = (bool)$mail->getThreadID();
297
$must_encrypt = $mail->getMustEncrypt();
298
299
$subject = array();
300
301
if ($is_threaded) {
302
if ($this->shouldAddRePrefix()) {
303
$subject[] = 'Re:';
304
}
305
}
306
307
$subject_prefix = $mail->getSubjectPrefix();
308
$subject_prefix = phutil_string_cast($subject_prefix);
309
$subject_prefix = trim($subject_prefix);
310
311
$subject[] = $subject_prefix;
312
313
// If mail content must be encrypted, we replace the subject with
314
// a generic one.
315
if ($must_encrypt) {
316
$encrypt_subject = $mail->getMustEncryptSubject();
317
if ($encrypt_subject === null || !strlen($encrypt_subject)) {
318
$encrypt_subject = pht('Object Updated');
319
}
320
$subject[] = $encrypt_subject;
321
} else {
322
$vary_prefix = $mail->getVarySubjectPrefix();
323
if (phutil_nonempty_string($vary_prefix)) {
324
if ($this->shouldVarySubject()) {
325
$subject[] = $vary_prefix;
326
}
327
}
328
329
$subject[] = $mail->getSubject();
330
}
331
332
foreach ($subject as $key => $part) {
333
if (!phutil_nonempty_string($part)) {
334
unset($subject[$key]);
335
}
336
}
337
338
$subject = implode(' ', $subject);
339
return $subject;
340
}
341
342
private function newEmailHeaders() {
343
$mail = $this->getMail();
344
345
$headers = array();
346
347
$headers[] = $this->newEmailHeader(
348
'X-Phabricator-Sent-This-Message',
349
'Yes');
350
$headers[] = $this->newEmailHeader(
351
'X-Mail-Transport-Agent',
352
'MetaMTA');
353
354
// Some clients respect this to suppress OOF and other auto-responses.
355
$headers[] = $this->newEmailHeader(
356
'X-Auto-Response-Suppress',
357
'All');
358
359
$mailtags = $mail->getMailTags();
360
if ($mailtags) {
361
$tag_header = array();
362
foreach ($mailtags as $mailtag) {
363
$tag_header[] = '<'.$mailtag.'>';
364
}
365
$tag_header = implode(', ', $tag_header);
366
$headers[] = $this->newEmailHeader(
367
'X-Phabricator-Mail-Tags',
368
$tag_header);
369
}
370
371
$value = $mail->getHeaders();
372
foreach ($value as $pair) {
373
list($header_key, $header_value) = $pair;
374
375
// NOTE: If we have \n in a header, SES rejects the email.
376
$header_value = str_replace("\n", ' ', $header_value);
377
$headers[] = $this->newEmailHeader($header_key, $header_value);
378
}
379
380
$is_bulk = $mail->getIsBulk();
381
if ($is_bulk) {
382
$headers[] = $this->newEmailHeader('Precedence', 'bulk');
383
}
384
385
if ($mail->getMustEncrypt()) {
386
$headers[] = $this->newEmailHeader('X-Phabricator-Must-Encrypt', 'Yes');
387
}
388
389
$related_phid = $mail->getRelatedPHID();
390
if ($related_phid) {
391
$headers[] = $this->newEmailHeader('Thread-Topic', $related_phid);
392
}
393
394
$headers[] = $this->newEmailHeader(
395
'X-Phabricator-Mail-ID',
396
$mail->getID());
397
398
$unique = Filesystem::readRandomCharacters(16);
399
$headers[] = $this->newEmailHeader(
400
'X-Phabricator-Send-Attempt',
401
$unique);
402
403
return $headers;
404
}
405
406
private function newEmailThreadingHeaders() {
407
$mailer = $this->getMailer();
408
$mail = $this->getMail();
409
410
$headers = array();
411
412
$thread_id = $mail->getThreadID();
413
if (!phutil_nonempty_string($thread_id)) {
414
return $headers;
415
}
416
417
$is_first = $mail->getIsFirstMessage();
418
419
// NOTE: Gmail freaks out about In-Reply-To and References which aren't in
420
// the form "<[email protected]>"; this is also required by RFC 2822,
421
// although some clients are more liberal in what they accept.
422
$domain = $this->newMailDomain();
423
$thread_id = '<'.$thread_id.'@'.$domain.'>';
424
425
if ($is_first && $mailer->supportsMessageIDHeader()) {
426
$headers[] = $this->newEmailHeader('Message-ID', $thread_id);
427
} else {
428
$in_reply_to = $thread_id;
429
$references = array($thread_id);
430
$parent_id = $mail->getParentMessageID();
431
if ($parent_id) {
432
$in_reply_to = $parent_id;
433
// By RFC 2822, the most immediate parent should appear last
434
// in the "References" header, so this order is intentional.
435
$references[] = $parent_id;
436
}
437
$references = implode(' ', $references);
438
$headers[] = $this->newEmailHeader('In-Reply-To', $in_reply_to);
439
$headers[] = $this->newEmailHeader('References', $references);
440
}
441
$thread_index = $this->generateThreadIndex($thread_id, $is_first);
442
$headers[] = $this->newEmailHeader('Thread-Index', $thread_index);
443
444
return $headers;
445
}
446
447
private function newEmailAttachments() {
448
$mail = $this->getMail();
449
450
// If the mail content must be encrypted, don't add attachments.
451
$must_encrypt = $mail->getMustEncrypt();
452
if ($must_encrypt) {
453
return array();
454
}
455
456
return $mail->getAttachments();
457
}
458
459
/* -( Preferences )-------------------------------------------------------- */
460
461
private function shouldAddRePrefix() {
462
$preferences = $this->getPreferences();
463
464
$value = $preferences->getSettingValue(
465
PhabricatorEmailRePrefixSetting::SETTINGKEY);
466
467
return ($value == PhabricatorEmailRePrefixSetting::VALUE_RE_PREFIX);
468
}
469
470
private function shouldVarySubject() {
471
$preferences = $this->getPreferences();
472
473
$value = $preferences->getSettingValue(
474
PhabricatorEmailVarySubjectsSetting::SETTINGKEY);
475
476
return ($value == PhabricatorEmailVarySubjectsSetting::VALUE_VARY_SUBJECTS);
477
}
478
479
private function shouldSendHTML() {
480
$preferences = $this->getPreferences();
481
482
$value = $preferences->getSettingValue(
483
PhabricatorEmailFormatSetting::SETTINGKEY);
484
485
return ($value == PhabricatorEmailFormatSetting::VALUE_HTML_EMAIL);
486
}
487
488
489
/* -( Utilities )---------------------------------------------------------- */
490
491
private function newEmailHeader($name, $value) {
492
return id(new PhabricatorMailHeader())
493
->setName($name)
494
->setValue($value);
495
}
496
497
private function newEmailAddress($address, $name = null) {
498
$object = id(new PhutilEmailAddress())
499
->setAddress($address);
500
501
if ($name !== null && strlen($name)) {
502
$object->setDisplayName($name);
503
}
504
505
return $object;
506
}
507
508
public function newDefaultEmailAddress() {
509
$raw_address = PhabricatorEnv::getEnvConfig('metamta.default-address');
510
511
if ($raw_address == null || !strlen($raw_address)) {
512
$domain = $this->newMailDomain();
513
$raw_address = "noreply@{$domain}";
514
}
515
516
$address = new PhutilEmailAddress($raw_address);
517
518
if (!phutil_nonempty_string($address->getDisplayName())) {
519
$address->setDisplayName(PlatformSymbols::getPlatformServerName());
520
}
521
522
return $address;
523
}
524
525
public function newVoidEmailAddress() {
526
return $this->newDefaultEmailAddress();
527
}
528
529
private function newMailDomain() {
530
$domain = PhabricatorEnv::getEnvConfig('metamta.reply-handler-domain');
531
if ($domain !== null && strlen($domain)) {
532
return $domain;
533
}
534
535
$install_uri = PhabricatorEnv::getURI('/');
536
$install_uri = new PhutilURI($install_uri);
537
538
return $install_uri->getDomain();
539
}
540
541
private function filterHeaders(array $headers, $must_encrypt) {
542
assert_instances_of($headers, 'PhabricatorMailHeader');
543
544
if (!$must_encrypt) {
545
return $headers;
546
}
547
548
$whitelist = array(
549
'In-Reply-To',
550
'Message-ID',
551
'Precedence',
552
'References',
553
'Thread-Index',
554
'Thread-Topic',
555
556
'X-Mail-Transport-Agent',
557
'X-Auto-Response-Suppress',
558
559
'X-Phabricator-Sent-This-Message',
560
'X-Phabricator-Must-Encrypt',
561
'X-Phabricator-Mail-ID',
562
'X-Phabricator-Send-Attempt',
563
);
564
565
// NOTE: The major header we want to drop is "X-Phabricator-Mail-Tags".
566
// This header contains a significant amount of meaningful information
567
// about the object.
568
569
$whitelist_map = array();
570
foreach ($whitelist as $term) {
571
$whitelist_map[phutil_utf8_strtolower($term)] = true;
572
}
573
574
foreach ($headers as $key => $header) {
575
$name = $header->getName();
576
$name = phutil_utf8_strtolower($name);
577
578
if (!isset($whitelist_map[$name])) {
579
unset($headers[$key]);
580
}
581
}
582
583
return $headers;
584
}
585
586
private function getUniqueEmailAddresses(
587
array $addresses,
588
array $exclude = array()) {
589
assert_instances_of($addresses, 'PhutilEmailAddress');
590
assert_instances_of($exclude, 'PhutilEmailAddress');
591
592
$seen = array();
593
594
foreach ($exclude as $address) {
595
$seen[$address->getAddress()] = true;
596
}
597
598
foreach ($addresses as $key => $address) {
599
$raw_address = $address->getAddress();
600
601
if (isset($seen[$raw_address])) {
602
unset($addresses[$key]);
603
continue;
604
}
605
606
$seen[$raw_address] = true;
607
}
608
609
return array_values($addresses);
610
}
611
612
private function generateThreadIndex($seed, $is_first_mail) {
613
// When threading, Outlook ignores the 'References' and 'In-Reply-To'
614
// headers that most clients use. Instead, it uses a custom 'Thread-Index'
615
// header. The format of this header is something like this (from
616
// camel-exchange-folder.c in Evolution Exchange):
617
618
/* A new post to a folder gets a 27-byte-long thread index. (The value
619
* is apparently unique but meaningless.) Each reply to a post gets a
620
* 32-byte-long thread index whose first 27 bytes are the same as the
621
* parent's thread index. Each reply to any of those gets a
622
* 37-byte-long thread index, etc. The Thread-Index header contains a
623
* base64 representation of this value.
624
*/
625
626
// The specific implementation uses a 27-byte header for the first email
627
// a recipient receives, and a random 5-byte suffix (32 bytes total)
628
// thereafter. This means that all the replies are (incorrectly) siblings,
629
// but it would be very difficult to keep track of the entire tree and this
630
// gets us reasonable client behavior.
631
632
$base = substr(md5($seed), 0, 27);
633
if (!$is_first_mail) {
634
// Not totally sure, but it seems like outlook orders replies by
635
// thread-index rather than timestamp, so to get these to show up in the
636
// right order we use the time as the last 4 bytes.
637
$base .= ' '.pack('N', time());
638
}
639
640
return base64_encode($base);
641
}
642
643
private function shouldRateLimitMail(array $all_recipients) {
644
try {
645
PhabricatorSystemActionEngine::willTakeAction(
646
$all_recipients,
647
new PhabricatorMetaMTAErrorMailAction(),
648
1);
649
return false;
650
} catch (PhabricatorSystemActionRateLimitException $ex) {
651
return true;
652
}
653
}
654
655
}
656
657