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