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