3 +--------------------------------------------------------------------+
5 +--------------------------------------------------------------------+
6 | Copyright CiviCRM LLC (c) 2004-2018 |
7 +--------------------------------------------------------------------+
8 | This file is a part of CiviCRM. |
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. |
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. |
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 +--------------------------------------------------------------------+
31 * @copyright CiviCRM LLC (c) 2004-2018
34 // we should consider moving these to the settings table
35 // before the 4.1 release
36 define('EMAIL_ACTIVITY_TYPE_ID', NULL);
37 define('MAIL_BATCH_SIZE', 50);
40 * Class CRM_Utils_Mail_EmailProcessor.
42 class CRM_Utils_Mail_EmailProcessor
{
44 const MIME_MAX_RECURSION
= 10;
47 * Process the default mailbox (ie. that is used by civiMail for the bounce)
49 * @param bool $is_create_activities
50 * Should activities be created
52 public static function processBounces($is_create_activities) {
53 $dao = new CRM_Core_DAO_MailSettings();
54 $dao->domain_id
= CRM_Core_Config
::domainID();
55 $dao->is_default
= TRUE;
58 while ($dao->fetch()) {
59 self
::_process(TRUE, $dao, $is_create_activities);
64 * Delete old files from a given directory (recursively).
67 * Directory to cleanup.
69 * Files older than this many seconds will be deleted (default: 60 days).
71 public static function cleanupDir($dir, $age = 5184000) {
72 // return early if we can’t read/write the dir
73 if (!is_writable($dir) or !is_readable($dir) or !is_dir($dir)) {
77 foreach (scandir($dir) as $file) {
79 // don’t go up the directory stack and skip new files/dirs
80 if ($file == '.' or $file == '..') {
83 if (filemtime("$dir/$file") > time() - $age) {
87 // it’s an old file/dir, so delete/recurse
88 is_dir("$dir/$file") ? self
::cleanupDir("$dir/$file", $age) : unlink("$dir/$file");
93 * Process the mailboxes that aren't default (ie. that aren't used by civiMail for the bounce).
95 public static function processActivities() {
96 $dao = new CRM_Core_DAO_MailSettings();
97 $dao->domain_id
= CRM_Core_Config
::domainID();
98 $dao->is_default
= FALSE;
101 while ($dao->fetch()) {
103 self
::_process(FALSE, $dao, TRUE);
106 CRM_Core_Error
::fatal(ts('No mailboxes have been configured for Email to Activity Processing'));
112 * Process the mailbox for all the settings from civicrm_mail_settings.
114 * @param bool|string $civiMail if true, processing is done in CiviMail context, or Activities otherwise.
116 public static function process($civiMail = TRUE) {
117 $dao = new CRM_Core_DAO_MailSettings();
118 $dao->domain_id
= CRM_Core_Config
::domainID();
121 while ($dao->fetch()) {
122 self
::_process($civiMail, $dao);
128 * @param CRM_Core_DAO_MailSettings $dao
129 * @param bool $is_create_activities
134 public static function _process($civiMail, $dao, $is_create_activities) {
135 // 0 = activities; 1 = bounce;
136 $usedfor = $dao->is_default
;
139 = (defined('EMAIL_ACTIVITY_TYPE_ID') && EMAIL_ACTIVITY_TYPE_ID
)
140 ? EMAIL_ACTIVITY_TYPE_ID
141 : CRM_Core_PseudoConstant
::getKey('CRM_Activity_BAO_Activity', 'activity_type_id', 'Inbound Email');
143 if (!$emailActivityTypeId) {
144 CRM_Core_Error
::fatal(ts('Could not find a valid Activity Type ID for Inbound Email'));
147 $config = CRM_Core_Config
::singleton();
148 $verpSeperator = preg_quote($config->verpSeparator
);
149 $twoDigitStringMin = $verpSeperator . '(\d+)' . $verpSeperator . '(\d+)';
150 $twoDigitString = $twoDigitStringMin . $verpSeperator;
151 $threeDigitString = $twoDigitString . '(\d+)' . $verpSeperator;
153 // FIXME: legacy regexen to handle CiviCRM 2.1 address patterns, with domain id and possible VERP part
154 $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
) . '$/';
155 $subscrRegex = '/^' . preg_quote($dao->localpart
) . '(s|subscribe)' . $twoDigitStringMin . '@' . preg_quote($dao->domain
) . '$/';
157 // a common-for-all-actions regex to handle CiviCRM 2.2 address patterns
158 $regex = '/^' . preg_quote($dao->localpart
) . '(b|c|e|o|r|u)' . $twoDigitString . '([0-9a-f]{16})@' . preg_quote($dao->domain
) . '$/';
160 // a tighter regex for finding bounce info in soft bounces’ mail bodies
161 $rpRegex = '/Return-Path:\s*' . preg_quote($dao->localpart
) . '(b)' . $twoDigitString . '([0-9a-f]{16})@' . preg_quote($dao->domain
) . '/';
163 // a regex for finding bound info X-Header
164 $rpXheaderRegex = '/X-CiviMail-Bounce: ' . preg_quote($dao->localpart
) . '(b)' . $twoDigitString . '([0-9a-f]{16})@' . preg_quote($dao->domain
) . '/i';
165 // CiviMail in regex and Civimail in header !!!
167 // retrieve the emails
169 $store = CRM_Mailing_MailStore
::getStore($dao->name
);
171 catch (Exception
$e) {
172 $message = ts('Could not connect to MailStore for ') . $dao->username
. '@' . $dao->server
. '<p>';
173 $message .= ts('Error message: ');
174 $message .= '<pre>' . $e->getMessage() . '</pre><p>';
175 CRM_Core_Error
::fatal($message);
178 // process fifty at a time, CRM-4002
179 while ($mails = $store->fetchNext(MAIL_BATCH_SIZE
)) {
180 foreach ($mails as $key => $mail) {
182 // for every addressee: match address elements if it's to CiviMail
187 foreach ($mail->to
as $address) {
188 if (preg_match($regex, $address->email
, $matches)) {
189 list($match, $action, $job, $queue, $hash) = $matches;
191 // FIXME: the below elseifs should be dropped when we drop legacy support
193 elseif (preg_match($commonRegex, $address->email
, $matches)) {
194 list($match, $action, $_, $job, $queue, $hash) = $matches;
197 elseif (preg_match($subscrRegex, $address->email
, $matches)) {
198 list($match, $action, $_, $job) = $matches;
203 // CRM-5471: if $matches is empty, it still might be a soft bounce sent
204 // to another address, so scan the body for ‘Return-Path: …bounce-pattern…’
205 if (!$matches and preg_match($rpRegex, $mail->generateBody(), $matches)) {
206 list($match, $action, $job, $queue, $hash) = $matches;
209 // if $matches is still empty, look for the X-CiviMail-Bounce header
211 if (!$matches and preg_match($rpXheaderRegex, $mail->generateBody(), $matches)) {
212 list($match, $action, $job, $queue, $hash) = $matches;
214 // With Mandrilla, the X-CiviMail-Bounce header is produced by generateBody
218 $all_parts = $mail->fetchParts();
219 foreach ($all_parts as $k_part => $v_part) {
220 if ($v_part instanceof ezcMailFile
) {
221 $p_file = $v_part->__get('fileName');
222 $c_file = file_get_contents($p_file);
223 if (preg_match($rpXheaderRegex, $c_file, $matches)) {
224 list($match, $action, $job, $queue, $hash) = $matches;
230 // if all else fails, check Delivered-To for possible pattern
231 if (!$matches and preg_match($regex, $mail->getHeader('Delivered-To'), $matches)) {
232 list($match, $action, $job, $queue, $hash) = $matches;
236 // preseve backward compatibility
237 if ($usedfor == 0 ||
$is_create_activities) {
238 // if its the activities that needs to be processed ..
240 $mailParams = CRM_Utils_Mail_Incoming
::parseMailingObject($mail);
242 catch (Exception
$e) {
243 echo $e->getMessage();
244 $store->markIgnored($key);
248 require_once 'CRM/Utils/DeprecatedUtils.php';
249 $params = _civicrm_api3_deprecated_activity_buildmailparams($mailParams, $emailActivityTypeId);
251 $params['version'] = 3;
252 if (!empty($dao->activity_status
)) {
253 $params['status_id'] = $dao->activity_status
;
255 $result = civicrm_api('activity', 'create', $params);
257 if ($result['is_error']) {
259 echo "Failed Processing: {$mail->subject}. Reason: {$result['error_message']}\n";
263 CRM_Utils_Hook
::emailProcessor('activity', $params, $mail, $result);
264 echo "Processed as Activity: {$mail->subject}\n";
268 // if $matches is empty, this email is not CiviMail-bound
270 $store->markIgnored($key);
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
;
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)) {
287 if ($mail->body
instanceof ezcMailText
) {
288 $text = $mail->body
->text
;
290 elseif ($mail->body
instanceof ezcMailMultipart
) {
291 $text = self
::getTextFromMultipart($mail->body
);
296 $mail->subject
== "Delivery Status Notification (Failure)"
298 // Exchange error - CRM-9361
299 foreach ($mail->body
->getParts() as $part) {
300 if ($part instanceof ezcMailDeliveryStatus
) {
301 foreach ($part->recipients
as $rec) {
302 if ($rec["Status"] == "5.1.1") {
303 if (isset($rec["Description"])) {
304 $text = $rec["Description"];
307 $text = $rec["Status"] . " Delivery to the following recipients failed";
317 // If bounce processing fails, just take the raw body. Cf. CRM-11046
318 $text = $mail->generateBody();
320 // if text is still empty, lets fudge a blank text so the api call below will succeed
322 $text = ts('We could not extract the mail body from this bounce message.');
328 'event_queue_id' => $queue,
332 // Setting is_transactional means it will rollback if
333 // it crashes part way through creating the bounce.
334 // If the api were standard & had a create this would be the
335 // default. Adding the standard api & deprecating this one
336 // would probably be the
337 // most consistent way to address this - but this is
339 'is_transactional' => 1,
341 $result = civicrm_api('Mailing', 'event_bounce', $params);
348 'contact_id' => $job,
349 'subscribe_id' => $queue,
353 $result = civicrm_api('Mailing', 'event_confirm', $params);
360 'event_queue_id' => $queue,
364 $result = civicrm_api('MailingGroup', 'event_domain_unsubscribe', $params);
369 // instead of text and HTML parts (4th and 6th params) send the whole email as the last param
372 'event_queue_id' => $queue,
375 'replyTo' => $replyTo,
377 'fullEmail' => $mail->generate(),
380 $result = civicrm_api('Mailing', 'event_reply', $params);
388 'event_queue_id' => $queue,
392 $result = civicrm_api('MailingGroup', 'event_resubscribe', $params);
398 'email' => $mail->from
->email
,
402 $result = civicrm_api('MailingGroup', 'event_subscribe', $params);
409 'event_queue_id' => $queue,
413 $result = civicrm_api('MailingGroup', 'event_unsubscribe', $params);
417 if ($result['is_error']) {
418 echo "Failed Processing: {$mail->subject}, Action: $action, Job ID: $job, Queue ID: $queue, Hash: $hash. Reason: {$result['error_message']}\n";
421 CRM_Utils_Hook
::emailProcessor('mailing', $params, $mail, $result, $action);
425 $store->markProcessed($key);
427 // CRM-7356 – used by IMAP only
433 * @param \ezcMailMultipart $multipart
434 * @param int $recursionLevel
438 protected static function getTextFromMultipart($multipart, $recursionLevel = 0) {
439 if ($recursionLevel >= self
::MIME_MAX_RECURSION
) {
442 $recursionLevel +
= 1;
444 if ($multipart instanceof ezcMailMultipartReport
) {
445 $text = self
::getTextFromMulipartReport($multipart, $recursionLevel);
447 elseif ($multipart instanceof ezcMailMultipartRelated
) {
448 $text = self
::getTextFromMultipartRelated($multipart, $recursionLevel);
451 foreach ($multipart->getParts() as $part) {
452 if (isset($part->subType
) and $part->subType
=== 'plain') {
455 elseif ($part instanceof ezcMailMultipart
) {
456 $text = self
::getTextFromMultipart($part, $recursionLevel);
467 * @param \ezcMailMultipartRelated $related
468 * @param int $recursionLevel
472 protected static function getTextFromMultipartRelated($related, $recursionLevel) {
474 foreach ($related->getRelatedParts() as $part) {
475 if (isset($part->subType
) and $part->subType
=== 'plain') {
478 elseif ($part instanceof ezcMailMultipart
) {
479 $text = self
::getTextFromMultipart($part, $recursionLevel);
489 * @param \ezcMailMultipartReport $multipart
490 * @param $recursionLevel
494 protected static function getTextFromMulipartReport($multipart, $recursionLevel) {
496 $part = $multipart->getMachinePart();
497 if ($part instanceof ezcMailDeliveryStatus
) {
498 foreach ($part->recipients
as $rec) {
499 if (isset($rec["Diagnostic-Code"])) {
500 $text = $rec["Diagnostic-Code"];
503 elseif (isset($rec["Description"])) {
504 $text = $rec["Description"];
507 // no diagnostic info present - try getting the human readable part
508 elseif (isset($rec["Status"])) {
509 $text = $rec["Status"];
510 $textpart = $multipart->getReadablePart();
511 if ($textpart !== NULL and isset($textpart->text
)) {
512 $text .= " " . $textpart->text
;
515 $text .= " Delivery failed but no diagnostic code or description.";
521 elseif ($part !== NULL and isset($part->text
)) {
524 elseif (($part = $multipart->getReadablePart()) !== NULL) {
525 if (isset($part->text
)) {
528 elseif ($part instanceof ezcMailMultipart
) {
529 $text = self
::getTextFromMultipart($part, $recursionLevel);