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