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