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