Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
freebsd
GitHub Repository: freebsd/phabricator
Path: blob/master/src/applications/metamta/storage/PhabricatorMetaMTAReceivedMail.php
12256 views
1
<?php
2
3
final class PhabricatorMetaMTAReceivedMail extends PhabricatorMetaMTADAO {
4
5
protected $headers = array();
6
protected $bodies = array();
7
protected $attachments = array();
8
protected $status = '';
9
10
protected $relatedPHID;
11
protected $authorPHID;
12
protected $message;
13
protected $messageIDHash = '';
14
15
protected function getConfiguration() {
16
return array(
17
self::CONFIG_SERIALIZATION => array(
18
'headers' => self::SERIALIZATION_JSON,
19
'bodies' => self::SERIALIZATION_JSON,
20
'attachments' => self::SERIALIZATION_JSON,
21
),
22
self::CONFIG_COLUMN_SCHEMA => array(
23
'relatedPHID' => 'phid?',
24
'authorPHID' => 'phid?',
25
'message' => 'text?',
26
'messageIDHash' => 'bytes12',
27
'status' => 'text32',
28
),
29
self::CONFIG_KEY_SCHEMA => array(
30
'relatedPHID' => array(
31
'columns' => array('relatedPHID'),
32
),
33
'authorPHID' => array(
34
'columns' => array('authorPHID'),
35
),
36
'key_messageIDHash' => array(
37
'columns' => array('messageIDHash'),
38
),
39
'key_created' => array(
40
'columns' => array('dateCreated'),
41
),
42
),
43
) + parent::getConfiguration();
44
}
45
46
public function setHeaders(array $headers) {
47
// Normalize headers to lowercase.
48
$normalized = array();
49
foreach ($headers as $name => $value) {
50
$name = $this->normalizeMailHeaderName($name);
51
if ($name == 'message-id') {
52
$this->setMessageIDHash(PhabricatorHash::digestForIndex($value));
53
}
54
$normalized[$name] = $value;
55
}
56
$this->headers = $normalized;
57
return $this;
58
}
59
60
public function getHeader($key, $default = null) {
61
$key = $this->normalizeMailHeaderName($key);
62
return idx($this->headers, $key, $default);
63
}
64
65
private function normalizeMailHeaderName($name) {
66
return strtolower($name);
67
}
68
69
public function getMessageID() {
70
return $this->getHeader('Message-ID');
71
}
72
73
public function getSubject() {
74
return $this->getHeader('Subject');
75
}
76
77
public function getCCAddresses() {
78
return $this->getRawEmailAddresses(idx($this->headers, 'cc'));
79
}
80
81
public function getToAddresses() {
82
return $this->getRawEmailAddresses(idx($this->headers, 'to'));
83
}
84
85
public function newTargetAddresses() {
86
$raw_addresses = array();
87
88
foreach ($this->getToAddresses() as $raw_address) {
89
$raw_addresses[] = $raw_address;
90
}
91
92
foreach ($this->getCCAddresses() as $raw_address) {
93
$raw_addresses[] = $raw_address;
94
}
95
96
$raw_addresses = array_unique($raw_addresses);
97
98
$addresses = array();
99
foreach ($raw_addresses as $raw_address) {
100
$addresses[] = new PhutilEmailAddress($raw_address);
101
}
102
103
return $addresses;
104
}
105
106
public function loadAllRecipientPHIDs() {
107
$addresses = $this->newTargetAddresses();
108
109
// See T13317. Don't allow reserved addresses (like "noreply@...") to
110
// match user PHIDs.
111
foreach ($addresses as $key => $address) {
112
if (PhabricatorMailUtil::isReservedAddress($address)) {
113
unset($addresses[$key]);
114
}
115
}
116
117
if (!$addresses) {
118
return array();
119
}
120
121
$address_strings = array();
122
foreach ($addresses as $address) {
123
$address_strings[] = phutil_string_cast($address->getAddress());
124
}
125
126
// See T13317. If a verified email address is in the "To" or "Cc" line,
127
// we'll count the user who owns that address as a recipient.
128
129
// We require the address be verified because we'll trigger behavior (like
130
// adding subscribers) based on the recipient list, and don't want to add
131
// Alice as a subscriber if she adds an unverified "internal-bounces@"
132
// address to her account and this address gets caught in the crossfire.
133
// In the best case this is confusing; in the worst case it could
134
// some day give her access to objects she can't see.
135
136
$recipients = id(new PhabricatorUserEmail())
137
->loadAllWhere(
138
'address IN (%Ls) AND isVerified = 1',
139
$address_strings);
140
141
$recipient_phids = mpull($recipients, 'getUserPHID');
142
143
return $recipient_phids;
144
}
145
146
public function processReceivedMail() {
147
$viewer = $this->getViewer();
148
149
$sender = null;
150
try {
151
$this->dropMailFromPhabricator();
152
$this->dropMailAlreadyReceived();
153
$this->dropEmptyMail();
154
155
$sender = $this->loadSender();
156
if ($sender) {
157
$this->setAuthorPHID($sender->getPHID());
158
159
// If we've identified the sender, mark them as the author of any
160
// attached files. We do this before we validate them (below), since
161
// they still authored these files even if their account is not allowed
162
// to interact via email.
163
164
$attachments = $this->getAttachments();
165
if ($attachments) {
166
$files = id(new PhabricatorFileQuery())
167
->setViewer($viewer)
168
->withPHIDs($attachments)
169
->execute();
170
foreach ($files as $file) {
171
$file->setAuthorPHID($sender->getPHID())->save();
172
}
173
}
174
175
$this->validateSender($sender);
176
}
177
178
$receivers = id(new PhutilClassMapQuery())
179
->setAncestorClass('PhabricatorMailReceiver')
180
->setFilterMethod('isEnabled')
181
->execute();
182
183
$reserved_recipient = null;
184
$targets = $this->newTargetAddresses();
185
foreach ($targets as $key => $target) {
186
// Never accept any reserved address as a mail target. This prevents
187
// security issues around "hostmaster@" and bad behavior with
188
// "noreply@".
189
if (PhabricatorMailUtil::isReservedAddress($target)) {
190
if (!$reserved_recipient) {
191
$reserved_recipient = $target;
192
}
193
unset($targets[$key]);
194
continue;
195
}
196
197
// See T13234. Don't process mail if a user has attached this address
198
// to their account.
199
if (PhabricatorMailUtil::isUserAddress($target)) {
200
unset($targets[$key]);
201
continue;
202
}
203
}
204
205
$any_accepted = false;
206
$receiver_exception = null;
207
foreach ($receivers as $receiver) {
208
$receiver = id(clone $receiver)
209
->setViewer($viewer);
210
211
if ($sender) {
212
$receiver->setSender($sender);
213
}
214
215
foreach ($targets as $target) {
216
try {
217
if (!$receiver->canAcceptMail($this, $target)) {
218
continue;
219
}
220
221
$any_accepted = true;
222
223
$receiver->receiveMail($this, $target);
224
} catch (Exception $ex) {
225
// If receivers raise exceptions, we'll keep the first one in hope
226
// that it points at a root cause.
227
if (!$receiver_exception) {
228
$receiver_exception = $ex;
229
}
230
}
231
}
232
}
233
234
if ($receiver_exception) {
235
throw $receiver_exception;
236
}
237
238
239
if (!$any_accepted) {
240
if ($reserved_recipient) {
241
// If nothing accepted the mail, we normally raise an error to help
242
// users who mistakenly send mail to "barges@" instead of "bugs@".
243
244
// However, if the recipient list included a reserved recipient, we
245
// don't bounce the mail with an error.
246
247
// The intent here is that if a user does a "Reply All" and includes
248
// "From: noreply@phabricator" in the receipient list, we just want
249
// to drop the mail rather than send them an unhelpful bounce message.
250
251
throw new PhabricatorMetaMTAReceivedMailProcessingException(
252
MetaMTAReceivedMailStatus::STATUS_RESERVED,
253
pht(
254
'No application handled this mail. This mail was sent to a '.
255
'reserved recipient ("%s") so bounces are suppressed.',
256
(string)$reserved_recipient));
257
} else if (!$sender) {
258
// NOTE: Currently, we'll always drop this mail (since it's headed to
259
// an unverified recipient). See T12237. These details are still
260
// useful because they'll appear in the mail logs and Mail web UI.
261
262
throw new PhabricatorMetaMTAReceivedMailProcessingException(
263
MetaMTAReceivedMailStatus::STATUS_UNKNOWN_SENDER,
264
pht(
265
'This email was sent from an email address ("%s") that is not '.
266
'associated with a registered user account. To interact via '.
267
'email, add this address to your account.',
268
(string)$this->newFromAddress()));
269
} else {
270
throw new PhabricatorMetaMTAReceivedMailProcessingException(
271
MetaMTAReceivedMailStatus::STATUS_NO_RECEIVERS,
272
pht(
273
'This mail can not be processed because no application '.
274
'knows how to handle it. Check that the address you sent it to '.
275
'is correct.'));
276
}
277
}
278
} catch (PhabricatorMetaMTAReceivedMailProcessingException $ex) {
279
switch ($ex->getStatusCode()) {
280
case MetaMTAReceivedMailStatus::STATUS_DUPLICATE:
281
case MetaMTAReceivedMailStatus::STATUS_FROM_PHABRICATOR:
282
// Don't send an error email back in these cases, since they're
283
// very unlikely to be the sender's fault.
284
break;
285
case MetaMTAReceivedMailStatus::STATUS_RESERVED:
286
// This probably is the sender's fault, but it's likely an accident
287
// that we received the mail at all.
288
break;
289
case MetaMTAReceivedMailStatus::STATUS_EMPTY_IGNORED:
290
// This error is explicitly ignored.
291
break;
292
default:
293
$this->sendExceptionMail($ex, $sender);
294
break;
295
}
296
297
$this
298
->setStatus($ex->getStatusCode())
299
->setMessage($ex->getMessage())
300
->save();
301
return $this;
302
} catch (Exception $ex) {
303
$this->sendExceptionMail($ex, $sender);
304
305
$this
306
->setStatus(MetaMTAReceivedMailStatus::STATUS_UNHANDLED_EXCEPTION)
307
->setMessage(pht('Unhandled Exception: %s', $ex->getMessage()))
308
->save();
309
310
throw $ex;
311
}
312
313
return $this->setMessage('OK')->save();
314
}
315
316
public function getCleanTextBody() {
317
$body = $this->getRawTextBody();
318
$parser = new PhabricatorMetaMTAEmailBodyParser();
319
return $parser->stripTextBody($body);
320
}
321
322
public function parseBody() {
323
$body = $this->getRawTextBody();
324
$parser = new PhabricatorMetaMTAEmailBodyParser();
325
return $parser->parseBody($body);
326
}
327
328
public function getRawTextBody() {
329
return idx($this->bodies, 'text');
330
}
331
332
/**
333
* Strip an email address down to the actual [email protected] part if
334
* necessary, since sometimes it will have formatting like
335
* '"Abraham Lincoln" <[email protected]>'.
336
*/
337
private function getRawEmailAddress($address) {
338
$matches = null;
339
$ok = preg_match('/<(.*)>/', $address, $matches);
340
if ($ok) {
341
$address = $matches[1];
342
}
343
return $address;
344
}
345
346
private function getRawEmailAddresses($addresses) {
347
$raw_addresses = array();
348
349
if (phutil_nonempty_string($addresses)) {
350
foreach (explode(',', $addresses) as $address) {
351
$raw_addresses[] = $this->getRawEmailAddress($address);
352
}
353
}
354
355
return array_filter($raw_addresses);
356
}
357
358
/**
359
* If Phabricator sent the mail, always drop it immediately. This prevents
360
* loops where, e.g., the public bug address is also a user email address
361
* and creating a bug sends them an email, which loops.
362
*/
363
private function dropMailFromPhabricator() {
364
if (!$this->getHeader('x-phabricator-sent-this-message')) {
365
return;
366
}
367
368
throw new PhabricatorMetaMTAReceivedMailProcessingException(
369
MetaMTAReceivedMailStatus::STATUS_FROM_PHABRICATOR,
370
pht(
371
"Ignoring email with '%s' header to avoid loops.",
372
'X-Phabricator-Sent-This-Message'));
373
}
374
375
/**
376
* If this mail has the same message ID as some other mail, and isn't the
377
* first mail we we received with that message ID, we drop it as a duplicate.
378
*/
379
private function dropMailAlreadyReceived() {
380
$message_id_hash = $this->getMessageIDHash();
381
if (!$message_id_hash) {
382
// No message ID hash, so we can't detect duplicates. This should only
383
// happen with very old messages.
384
return;
385
}
386
387
$messages = $this->loadAllWhere(
388
'messageIDHash = %s ORDER BY id ASC LIMIT 2',
389
$message_id_hash);
390
$messages_count = count($messages);
391
if ($messages_count <= 1) {
392
// If we only have one copy of this message, we're good to process it.
393
return;
394
}
395
396
$first_message = reset($messages);
397
if ($first_message->getID() == $this->getID()) {
398
// If this is the first copy of the message, it is okay to process it.
399
// We may not have been able to to process it immediately when we received
400
// it, and could may have received several copies without processing any
401
// yet.
402
return;
403
}
404
405
$message = pht(
406
'Ignoring email with "Message-ID" hash "%s" that has been seen %d '.
407
'times, including this message.',
408
$message_id_hash,
409
$messages_count);
410
411
throw new PhabricatorMetaMTAReceivedMailProcessingException(
412
MetaMTAReceivedMailStatus::STATUS_DUPLICATE,
413
$message);
414
}
415
416
private function dropEmptyMail() {
417
$body = $this->getCleanTextBody();
418
$attachments = $this->getAttachments();
419
420
if (strlen($body) || $attachments) {
421
return;
422
}
423
424
// Only send an error email if the user is talking to just Phabricator.
425
// We can assume if there is only one "To" address it is a Phabricator
426
// address since this code is running and everything.
427
$is_direct_mail = (count($this->getToAddresses()) == 1) &&
428
(count($this->getCCAddresses()) == 0);
429
430
if ($is_direct_mail) {
431
$status_code = MetaMTAReceivedMailStatus::STATUS_EMPTY;
432
} else {
433
$status_code = MetaMTAReceivedMailStatus::STATUS_EMPTY_IGNORED;
434
}
435
436
throw new PhabricatorMetaMTAReceivedMailProcessingException(
437
$status_code,
438
pht(
439
'Your message does not contain any body text or attachments, so '.
440
'this server can not do anything useful with it. Make sure comment '.
441
'text appears at the top of your message: quoted replies, inline '.
442
'text, and signatures are discarded and ignored.'));
443
}
444
445
private function sendExceptionMail(
446
Exception $ex,
447
PhabricatorUser $viewer = null) {
448
449
// If we've failed to identify a legitimate sender, we don't send them
450
// an error message back. We want to avoid sending mail to unverified
451
// addresses. See T12491.
452
if (!$viewer) {
453
return;
454
}
455
456
if ($ex instanceof PhabricatorMetaMTAReceivedMailProcessingException) {
457
$status_code = $ex->getStatusCode();
458
$status_name = MetaMTAReceivedMailStatus::getHumanReadableName(
459
$status_code);
460
461
$title = pht('Error Processing Mail (%s)', $status_name);
462
$description = $ex->getMessage();
463
} else {
464
$title = pht('Error Processing Mail (%s)', get_class($ex));
465
$description = pht('%s: %s', get_class($ex), $ex->getMessage());
466
}
467
468
// TODO: Since headers don't necessarily have unique names, this may not
469
// really be all the headers. It would be nice to pass the raw headers
470
// through from the upper layers where possible.
471
472
// On the MimeMailParser pathway, we arrive here with a list value for
473
// headers that appeared multiple times in the original mail. Be
474
// accommodating until header handling gets straightened out.
475
476
$headers = array();
477
foreach ($this->headers as $key => $values) {
478
if (!is_array($values)) {
479
$values = array($values);
480
}
481
foreach ($values as $value) {
482
$headers[] = pht('%s: %s', $key, $value);
483
}
484
}
485
$headers = implode("\n", $headers);
486
487
$body = pht(<<<EOBODY
488
Your email to %s was not processed, because an error occurred while
489
trying to handle it:
490
491
%s
492
493
-- Original Message Body -----------------------------------------------------
494
495
%s
496
497
-- Original Message Headers --------------------------------------------------
498
499
%s
500
501
EOBODY
502
,
503
PlatformSymbols::getPlatformServerName(),
504
wordwrap($description, 78),
505
$this->getRawTextBody(),
506
$headers);
507
508
$mail = id(new PhabricatorMetaMTAMail())
509
->setIsErrorEmail(true)
510
->setSubject($title)
511
->addTos(array($viewer->getPHID()))
512
->setBody($body)
513
->saveAndSend();
514
}
515
516
public function newContentSource() {
517
return PhabricatorContentSource::newForSource(
518
PhabricatorEmailContentSource::SOURCECONST,
519
array(
520
'id' => $this->getID(),
521
));
522
}
523
524
public function newFromAddress() {
525
$raw_from = $this->getHeader('From');
526
527
if (strlen($raw_from)) {
528
return new PhutilEmailAddress($raw_from);
529
}
530
531
return null;
532
}
533
534
private function getViewer() {
535
return PhabricatorUser::getOmnipotentUser();
536
}
537
538
/**
539
* Identify the sender's user account for a piece of received mail.
540
*
541
* Note that this method does not validate that the sender is who they say
542
* they are, just that they've presented some credential which corresponds
543
* to a recognizable user.
544
*/
545
private function loadSender() {
546
$viewer = $this->getViewer();
547
548
// Try to identify the user based on their "From" address.
549
$from_address = $this->newFromAddress();
550
if ($from_address) {
551
$user = id(new PhabricatorPeopleQuery())
552
->setViewer($viewer)
553
->withEmails(array($from_address->getAddress()))
554
->executeOne();
555
if ($user) {
556
return $user;
557
}
558
}
559
560
return null;
561
}
562
563
private function validateSender(PhabricatorUser $sender) {
564
$failure_reason = null;
565
if ($sender->getIsDisabled()) {
566
$failure_reason = pht(
567
'Your account ("%s") is disabled, so you can not interact with '.
568
'over email.',
569
$sender->getUsername());
570
} else if ($sender->getIsStandardUser()) {
571
if (!$sender->getIsApproved()) {
572
$failure_reason = pht(
573
'Your account ("%s") has not been approved yet. You can not '.
574
'interact over email until your account is approved.',
575
$sender->getUsername());
576
} else if (PhabricatorUserEmail::isEmailVerificationRequired() &&
577
!$sender->getIsEmailVerified()) {
578
$failure_reason = pht(
579
'You have not verified the email address for your account ("%s"). '.
580
'You must verify your email address before you can interact over '.
581
'email.',
582
$sender->getUsername());
583
}
584
}
585
586
if ($failure_reason) {
587
throw new PhabricatorMetaMTAReceivedMailProcessingException(
588
MetaMTAReceivedMailStatus::STATUS_DISABLED_SENDER,
589
$failure_reason);
590
}
591
}
592
593
}
594
595