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