Merge pull request #14222 from mfb/debug-var
[civicrm-core.git] / CRM / Utils / Mail / EmailProcessor.php
CommitLineData
6a488035
TO
1<?php
2/*
3 +--------------------------------------------------------------------+
fee14197 4 | CiviCRM version 5 |
6a488035 5 +--------------------------------------------------------------------+
6b83d5bd 6 | Copyright CiviCRM LLC (c) 2004-2019 |
6a488035
TO
7 +--------------------------------------------------------------------+
8 | This file is a part of CiviCRM. |
9 | |
10 | CiviCRM is free software; you can copy, modify, and distribute it |
11 | under the terms of the GNU Affero General Public License |
12 | Version 3, 19 November 2007 and the CiviCRM Licensing Exception. |
13 | |
14 | CiviCRM is distributed in the hope that it will be useful, but |
15 | WITHOUT ANY WARRANTY; without even the implied warranty of |
16 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. |
17 | See the GNU Affero General Public License for more details. |
18 | |
19 | You should have received a copy of the GNU Affero General Public |
20 | License and the CiviCRM Licensing Exception along |
21 | with this program; if not, contact CiviCRM LLC |
22 | at info[AT]civicrm[DOT]org. If you have questions about the |
23 | GNU Affero General Public License or the licensing of CiviCRM, |
24 | see the CiviCRM license FAQ at http://civicrm.org/licensing |
25 +--------------------------------------------------------------------+
d25dd0ee 26 */
6a488035
TO
27
28/**
29 *
30 * @package CRM
6b83d5bd 31 * @copyright CiviCRM LLC (c) 2004-2019
6a488035
TO
32 */
33
34// we should consider moving these to the settings table
35// before the 4.1 release
36define('EMAIL_ACTIVITY_TYPE_ID', NULL);
37define('MAIL_BATCH_SIZE', 50);
5bc392e6
EM
38
39/**
b8c71ffa 40 * Class CRM_Utils_Mail_EmailProcessor.
5bc392e6 41 */
6a488035
TO
42class CRM_Utils_Mail_EmailProcessor {
43
01467aec
EE
44 const MIME_MAX_RECURSION = 10;
45
6a488035
TO
46 /**
47 * Process the default mailbox (ie. that is used by civiMail for the bounce)
48 *
3ff238ae 49 * @param bool $is_create_activities
50 * Should activities be created
6a488035 51 */
3ff238ae 52 public static function processBounces($is_create_activities) {
ae5ffbb7 53 $dao = new CRM_Core_DAO_MailSettings();
353ffa53 54 $dao->domain_id = CRM_Core_Config::domainID();
6a488035
TO
55 $dao->is_default = TRUE;
56 $dao->find();
57
58 while ($dao->fetch()) {
3ff238ae 59 self::_process(TRUE, $dao, $is_create_activities);
6a488035 60 }
6a488035
TO
61 }
62
63 /**
b8c71ffa 64 * Delete old files from a given directory (recursively).
6a488035 65 *
77855840
TO
66 * @param string $dir
67 * Directory to cleanup.
68 * @param int $age
69 * Files older than this many seconds will be deleted (default: 60 days).
6a488035 70 */
00be9182 71 public static function cleanupDir($dir, $age = 5184000) {
6a488035
TO
72 // return early if we can’t read/write the dir
73 if (!is_writable($dir) or !is_readable($dir) or !is_dir($dir)) {
74 return;
75 }
76
77 foreach (scandir($dir) as $file) {
78
79 // don’t go up the directory stack and skip new files/dirs
80 if ($file == '.' or $file == '..') {
81 continue;
82 }
83 if (filemtime("$dir/$file") > time() - $age) {
84 continue;
85 }
86
87 // it’s an old file/dir, so delete/recurse
88 is_dir("$dir/$file") ? self::cleanupDir("$dir/$file", $age) : unlink("$dir/$file");
89 }
90 }
91
92 /**
b8c71ffa 93 * Process the mailboxes that aren't default (ie. that aren't used by civiMail for the bounce).
6a488035 94 */
00be9182 95 public static function processActivities() {
ae5ffbb7 96 $dao = new CRM_Core_DAO_MailSettings();
353ffa53 97 $dao->domain_id = CRM_Core_Config::domainID();
6a488035
TO
98 $dao->is_default = FALSE;
99 $dao->find();
100 $found = FALSE;
101 while ($dao->fetch()) {
102 $found = TRUE;
2c2409c3 103 self::_process(FALSE, $dao, TRUE);
6a488035
TO
104 }
105 if (!$found) {
106 CRM_Core_Error::fatal(ts('No mailboxes have been configured for Email to Activity Processing'));
107 }
108 return $found;
109 }
110
111 /**
fe482240 112 * Process the mailbox for all the settings from civicrm_mail_settings.
6a488035 113 *
fd31fa4c 114 * @param bool|string $civiMail if true, processing is done in CiviMail context, or Activities otherwise.
6a488035 115 */
00be9182 116 public static function process($civiMail = TRUE) {
ae5ffbb7 117 $dao = new CRM_Core_DAO_MailSettings();
6a488035
TO
118 $dao->domain_id = CRM_Core_Config::domainID();
119 $dao->find();
120
121 while ($dao->fetch()) {
122 self::_process($civiMail, $dao);
123 }
124 }
125
5bc392e6
EM
126 /**
127 * @param $civiMail
040073c9 128 * @param CRM_Core_DAO_MailSettings $dao
3ff238ae 129 * @param bool $is_create_activities
130 * Create activities.
5bc392e6
EM
131 *
132 * @throws Exception
133 */
3ff238ae 134 public static function _process($civiMail, $dao, $is_create_activities) {
6a488035
TO
135 // 0 = activities; 1 = bounce;
136 $usedfor = $dao->is_default;
137
ae5ffbb7 138 $emailActivityTypeId
d15a97f4 139 = (defined('EMAIL_ACTIVITY_TYPE_ID') && EMAIL_ACTIVITY_TYPE_ID)
140 ? EMAIL_ACTIVITY_TYPE_ID
70a32eb8 141 : CRM_Core_PseudoConstant::getKey('CRM_Activity_BAO_Activity', 'activity_type_id', 'Inbound Email');
6a488035
TO
142
143 if (!$emailActivityTypeId) {
144 CRM_Core_Error::fatal(ts('Could not find a valid Activity Type ID for Inbound Email'));
145 }
146
353ffa53
TO
147 $config = CRM_Core_Config::singleton();
148 $verpSeperator = preg_quote($config->verpSeparator);
6a488035 149 $twoDigitStringMin = $verpSeperator . '(\d+)' . $verpSeperator . '(\d+)';
353ffa53
TO
150 $twoDigitString = $twoDigitStringMin . $verpSeperator;
151 $threeDigitString = $twoDigitString . '(\d+)' . $verpSeperator;
6a488035
TO
152
153 // FIXME: legacy regexen to handle CiviCRM 2.1 address patterns, with domain id and possible VERP part
154 $commonRegex = '/^' . preg_quote($dao->localpart) . '(b|bounce|c|confirm|o|optOut|r|reply|re|e|resubscribe|u|unsubscribe)' . $threeDigitString . '([0-9a-f]{16})(-.*)?@' . preg_quote($dao->domain) . '$/';
155 $subscrRegex = '/^' . preg_quote($dao->localpart) . '(s|subscribe)' . $twoDigitStringMin . '@' . preg_quote($dao->domain) . '$/';
156
157 // a common-for-all-actions regex to handle CiviCRM 2.2 address patterns
158 $regex = '/^' . preg_quote($dao->localpart) . '(b|c|e|o|r|u)' . $twoDigitString . '([0-9a-f]{16})@' . preg_quote($dao->domain) . '$/';
159
160 // a tighter regex for finding bounce info in soft bounces’ mail bodies
619526f6 161 $rpRegex = '/Return-Path:\s*' . preg_quote($dao->localpart) . '(b)' . $twoDigitString . '([0-9a-f]{16})@' . preg_quote($dao->domain) . '/';
6a488035 162
1a4d92b6 163 // a regex for finding bound info X-Header
08523e94
O
164 $rpXheaderRegex = '/X-CiviMail-Bounce: ' . preg_quote($dao->localpart) . '(b)' . $twoDigitString . '([0-9a-f]{16})@' . preg_quote($dao->domain) . '/i';
165 // CiviMail in regex and Civimail in header !!!
1a4d92b6 166
6a488035
TO
167 // retrieve the emails
168 try {
169 $store = CRM_Mailing_MailStore::getStore($dao->name);
170 }
353ffa53 171 catch (Exception$e) {
86bfa4f6 172 $message = ts('Could not connect to MailStore for ') . $dao->username . '@' . $dao->server . '<p>';
6a488035
TO
173 $message .= ts('Error message: ');
174 $message .= '<pre>' . $e->getMessage() . '</pre><p>';
175 CRM_Core_Error::fatal($message);
176 }
177
6a488035
TO
178 // process fifty at a time, CRM-4002
179 while ($mails = $store->fetchNext(MAIL_BATCH_SIZE)) {
180 foreach ($mails as $key => $mail) {
181
182 // for every addressee: match address elements if it's to CiviMail
be2fb01f 183 $matches = [];
6a488035
TO
184 $action = NULL;
185
186 if ($usedfor == 1) {
187 foreach ($mail->to as $address) {
188 if (preg_match($regex, $address->email, $matches)) {
189 list($match, $action, $job, $queue, $hash) = $matches;
190 break;
191 // FIXME: the below elseifs should be dropped when we drop legacy support
192 }
193 elseif (preg_match($commonRegex, $address->email, $matches)) {
194 list($match, $action, $_, $job, $queue, $hash) = $matches;
195 break;
196 }
197 elseif (preg_match($subscrRegex, $address->email, $matches)) {
198 list($match, $action, $_, $job) = $matches;
199 break;
200 }
201 }
202
203 // CRM-5471: if $matches is empty, it still might be a soft bounce sent
204 // to another address, so scan the body for ‘Return-Path: …bounce-pattern…’
205 if (!$matches and preg_match($rpRegex, $mail->generateBody(), $matches)) {
206 list($match, $action, $job, $queue, $hash) = $matches;
207 }
208
1a4d92b6
DL
209 // if $matches is still empty, look for the X-CiviMail-Bounce header
210 // CRM-9855
211 if (!$matches and preg_match($rpXheaderRegex, $mail->generateBody(), $matches)) {
212 list($match, $action, $job, $queue, $hash) = $matches;
213 }
08523e94
O
214 // With Mandrilla, the X-CiviMail-Bounce header is produced by generateBody
215 // is base64 encoded
216 // Check all parts
217 if (!$matches) {
6ac3485f 218 $all_parts = $mail->fetchParts();
08523e94
O
219 foreach ($all_parts as $k_part => $v_part) {
220 if ($v_part instanceof ezcMailFile) {
221 $p_file = $v_part->__get('fileName');
6ac3485f 222 $c_file = file_get_contents($p_file);
08523e94 223 if (preg_match($rpXheaderRegex, $c_file, $matches)) {
08523e94
O
224 list($match, $action, $job, $queue, $hash) = $matches;
225 }
226 }
227 }
228 }
229
6a488035
TO
230 // if all else fails, check Delivered-To for possible pattern
231 if (!$matches and preg_match($regex, $mail->getHeader('Delivered-To'), $matches)) {
232 list($match, $action, $job, $queue, $hash) = $matches;
233 }
234 }
235
236 // preseve backward compatibility
3ff238ae 237 if ($usedfor == 0 || $is_create_activities) {
6a488035 238 // if its the activities that needs to be processed ..
5fdd5f80 239 try {
83cd2236 240 $mailParams = CRM_Utils_Mail_Incoming::parseMailingObject($mail);
fad9031a 241 }
83cd2236
DH
242 catch (Exception $e) {
243 echo $e->getMessage();
244 $store->markIgnored($key);
245 continue;
246 }
6a488035 247
69c9ffb3 248 require_once 'CRM/Utils/DeprecatedUtils.php';
6a488035
TO
249 $params = _civicrm_api3_deprecated_activity_buildmailparams($mailParams, $emailActivityTypeId);
250
251 $params['version'] = 3;
040073c9
CW
252 if (!empty($dao->activity_status)) {
253 $params['status_id'] = $dao->activity_status;
254 }
6a488035
TO
255 $result = civicrm_api('activity', 'create', $params);
256
257 if ($result['is_error']) {
258 $matches = FALSE;
259 echo "Failed Processing: {$mail->subject}. Reason: {$result['error_message']}\n";
260 }
261 else {
262 $matches = TRUE;
7349cf08 263 CRM_Utils_Hook::emailProcessor('activity', $params, $mail, $result);
6a488035
TO
264 echo "Processed as Activity: {$mail->subject}\n";
265 }
6a488035
TO
266 }
267
268 // if $matches is empty, this email is not CiviMail-bound
269 if (!$matches) {
270 $store->markIgnored($key);
271 continue;
272 }
273
274 // get $replyTo from either the Reply-To header or from From
275 // FIXME: make sure it works with Reply-Tos containing non-email stuff
276 $replyTo = $mail->getHeader('Reply-To') ? $mail->getHeader('Reply-To') : $mail->from->email;
277
278 // handle the action by passing it to the proper API call
279 // FIXME: leave only one-letter cases when dropping legacy support
280 if (!empty($action)) {
281 $result = NULL;
282
283 switch ($action) {
284 case 'b':
285 case 'bounce':
286 $text = '';
287 if ($mail->body instanceof ezcMailText) {
288 $text = $mail->body->text;
289 }
290 elseif ($mail->body instanceof ezcMailMultipart) {
01467aec 291 $text = self::getTextFromMultipart($mail->body);
6a488035 292 }
9dcd46ef 293 elseif ($mail->body instanceof ezcMailFile) {
294 $text = file_get_contents($mail->body->__get('fileName'));
295 }
6a488035
TO
296
297 if (
9b9a8713 298 empty($text) &&
6a488035
TO
299 $mail->subject == "Delivery Status Notification (Failure)"
300 ) {
301 // Exchange error - CRM-9361
302 foreach ($mail->body->getParts() as $part) {
303 if ($part instanceof ezcMailDeliveryStatus) {
304 foreach ($part->recipients as $rec) {
305 if ($rec["Status"] == "5.1.1") {
9b9a8713
SL
306 if (isset($rec["Description"])) {
307 $text = $rec["Description"];
308 }
309 else {
0addad12 310 $text = $rec["Status"] . " Delivery to the following recipients failed";
9b9a8713 311 }
6a488035
TO
312 break;
313 }
314 }
315 }
316 }
317 }
318
319 if (empty($text)) {
320 // If bounce processing fails, just take the raw body. Cf. CRM-11046
321 $text = $mail->generateBody();
322
323 // if text is still empty, lets fudge a blank text so the api call below will succeed
6ac9d864
DL
324 if (empty($text)) {
325 $text = ts('We could not extract the mail body from this bounce message.');
326 }
6a488035
TO
327 }
328
be2fb01f 329 $params = [
6a488035
TO
330 'job_id' => $job,
331 'event_queue_id' => $queue,
332 'hash' => $hash,
333 'body' => $text,
334 'version' => 3,
cdc5c450 335 // Setting is_transactional means it will rollback if
336 // it crashes part way through creating the bounce.
337 // If the api were standard & had a create this would be the
338 // default. Adding the standard api & deprecating this one
339 // would probably be the
340 // most consistent way to address this - but this is
341 // a quick hack.
342 'is_transactional' => 1,
be2fb01f 343 ];
6a488035
TO
344 $result = civicrm_api('Mailing', 'event_bounce', $params);
345 break;
346
347 case 'c':
348 case 'confirm':
349 // CRM-7921
be2fb01f 350 $params = [
6a488035
TO
351 'contact_id' => $job,
352 'subscribe_id' => $queue,
353 'hash' => $hash,
354 'version' => 3,
be2fb01f 355 ];
6a488035
TO
356 $result = civicrm_api('Mailing', 'event_confirm', $params);
357 break;
358
359 case 'o':
360 case 'optOut':
be2fb01f 361 $params = [
6a488035
TO
362 'job_id' => $job,
363 'event_queue_id' => $queue,
364 'hash' => $hash,
365 'version' => 3,
be2fb01f 366 ];
6a488035
TO
367 $result = civicrm_api('MailingGroup', 'event_domain_unsubscribe', $params);
368 break;
369
370 case 'r':
371 case 'reply':
372 // instead of text and HTML parts (4th and 6th params) send the whole email as the last param
be2fb01f 373 $params = [
6a488035
TO
374 'job_id' => $job,
375 'event_queue_id' => $queue,
376 'hash' => $hash,
377 'bodyTxt' => NULL,
378 'replyTo' => $replyTo,
379 'bodyHTML' => NULL,
380 'fullEmail' => $mail->generate(),
381 'version' => 3,
be2fb01f 382 ];
6a488035
TO
383 $result = civicrm_api('Mailing', 'event_reply', $params);
384 break;
385
386 case 'e':
387 case 're':
388 case 'resubscribe':
be2fb01f 389 $params = [
6a488035
TO
390 'job_id' => $job,
391 'event_queue_id' => $queue,
392 'hash' => $hash,
393 'version' => 3,
be2fb01f 394 ];
6a488035
TO
395 $result = civicrm_api('MailingGroup', 'event_resubscribe', $params);
396 break;
397
398 case 's':
399 case 'subscribe':
be2fb01f 400 $params = [
6a488035
TO
401 'email' => $mail->from->email,
402 'group_id' => $job,
403 'version' => 3,
be2fb01f 404 ];
6a488035
TO
405 $result = civicrm_api('MailingGroup', 'event_subscribe', $params);
406 break;
407
408 case 'u':
409 case 'unsubscribe':
be2fb01f 410 $params = [
6a488035
TO
411 'job_id' => $job,
412 'event_queue_id' => $queue,
413 'hash' => $hash,
414 'version' => 3,
be2fb01f 415 ];
6a488035
TO
416 $result = civicrm_api('MailingGroup', 'event_unsubscribe', $params);
417 break;
418 }
419
420 if ($result['is_error']) {
421 echo "Failed Processing: {$mail->subject}, Action: $action, Job ID: $job, Queue ID: $queue, Hash: $hash. Reason: {$result['error_message']}\n";
422 }
423 else {
424 CRM_Utils_Hook::emailProcessor('mailing', $params, $mail, $result, $action);
425 }
426 }
427
428 $store->markProcessed($key);
429 }
430 // CRM-7356 – used by IMAP only
431 $store->expunge();
432 }
433 }
96025800 434
01467aec
EE
435 /**
436 * @param \ezcMailMultipart $multipart
437 * @param int $recursionLevel
438 *
439 * @return array
440 */
441 protected static function getTextFromMultipart($multipart, $recursionLevel = 0) {
442 if ($recursionLevel >= self::MIME_MAX_RECURSION) {
443 return NULL;
444 }
445 $recursionLevel += 1;
446 $text = NULL;
447 if ($multipart instanceof ezcMailMultipartReport) {
448 $text = self::getTextFromMulipartReport($multipart, $recursionLevel);
449 }
450 elseif ($multipart instanceof ezcMailMultipartRelated) {
451 $text = self::getTextFromMultipartRelated($multipart, $recursionLevel);
452 }
453 else {
454 foreach ($multipart->getParts() as $part) {
455 if (isset($part->subType) and $part->subType === 'plain') {
456 $text = $part->text;
457 }
458 elseif ($part instanceof ezcMailMultipart) {
459 $text = self::getTextFromMultipart($part, $recursionLevel);
460 }
461 if ($text) {
462 break;
463 }
464 }
465 }
466 return $text;
467 }
468
469 /**
470 * @param \ezcMailMultipartRelated $related
471 * @param int $recursionLevel
472 *
473 * @return array
474 */
475 protected static function getTextFromMultipartRelated($related, $recursionLevel) {
476 $text = NULL;
477 foreach ($related->getRelatedParts() as $part) {
478 if (isset($part->subType) and $part->subType === 'plain') {
479 $text = $part->text;
480 }
481 elseif ($part instanceof ezcMailMultipart) {
482 $text = self::getTextFromMultipart($part, $recursionLevel);
483 }
484 if ($text) {
485 break;
486 }
487 }
488 return $text;
489 }
490
491 /**
492 * @param \ezcMailMultipartReport $multipart
493 * @param $recursionLevel
494 *
495 * @return array
496 */
497 protected static function getTextFromMulipartReport($multipart, $recursionLevel) {
498 $text = NULL;
499 $part = $multipart->getMachinePart();
500 if ($part instanceof ezcMailDeliveryStatus) {
501 foreach ($part->recipients as $rec) {
502 if (isset($rec["Diagnostic-Code"])) {
503 $text = $rec["Diagnostic-Code"];
504 break;
505 }
506 elseif (isset($rec["Description"])) {
507 $text = $rec["Description"];
508 break;
509 }
510 // no diagnostic info present - try getting the human readable part
511 elseif (isset($rec["Status"])) {
512 $text = $rec["Status"];
513 $textpart = $multipart->getReadablePart();
514 if ($textpart !== NULL and isset($textpart->text)) {
515 $text .= " " . $textpart->text;
516 }
517 else {
518 $text .= " Delivery failed but no diagnostic code or description.";
519 }
520 break;
521 }
522 }
523 }
524 elseif ($part !== NULL and isset($part->text)) {
525 $text = $part->text;
526 }
527 elseif (($part = $multipart->getReadablePart()) !== NULL) {
528 if (isset($part->text)) {
529 $text = $part->text;
530 }
531 elseif ($part instanceof ezcMailMultipart) {
532 $text = self::getTextFromMultipart($part, $recursionLevel);
533 }
534 }
535 return $text;
536 }
537
6a488035 538}