PluginProbe
AcyMailing – An Ultimate Newsletter Plugin and Marketing Automation Solution for WordPress / trunk
AcyMailing – An Ultimate Newsletter Plugin and Marketing Automation Solution for WordPress vtrunk
11.1.1 11.1.0 11.0.5 11.0.4 11.0.3 11.0.2 11.0.1 11.0.0 10.11.1 10.11.0 10.10.2 10.10.1 10.10.0 10.9.1 trunk 10.0.0 10.0.1 10.1.0 10.1.1 10.1.2 10.1.3 10.1.4 10.2.0 10.2.1 10.2.2 All 62 releases
acymailing / back / Helpers / BounceHelper.php

BounceHelper.php in AcyMailing – An Ultimate Newsletter Plugin and Marketing Automation Solution for WordPress trunk, at back/Helpers/BounceHelper.php

1,616 lines 65.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace AcyMailing\Helpers;
4
5 use AcyMailerPhp\OAuth;
6 use AcyMailing\Classes\HistoryClass;
7 use AcyMailing\Classes\ListClass;
8 use AcyMailing\Classes\MailClass;
9 use AcyMailing\Classes\RuleClass;
10 use AcyMailing\Classes\UserClass;
11 use AcyMailing\Core\AcymObject;
12 use AcyMailing\Libraries\Pear\Pop3;
13 use AcyMailing\Types\CharsetType;
14
15 class BounceHelper extends AcymObject
16 {
17 const HOSTS_NEEDING_OAUTH = [
18 'imap.gmail.com',
19 'outlook.office365.com',
20 ];
21
22 const MESSAGE_TYPE_SUCCESS = 0;
23 const MESSAGE_TYPE_INFO = 1;
24 const MESSAGE_TYPE_ERROR = 2;
25
26 // Needed information for the connection
27 private string $server;
28 private string $username;
29 private string $password;
30 private string $port;
31 private string $connectMethod;
32 private string $secureMethod;
33 private bool $selfSigned;
34 private int $timeout;
35 private string $oAuthToken;
36
37 // Allowed extensions for uploaded files (attachments)
38 private array $allowed_extensions = [];
39 protected int $nbMessages = 0;
40 public bool $report = false;
41 private MailerHelper $mailer;
42 private $mailbox;
43 public $_message;
44 private UserClass $userClass;
45 private array $blockedUsers = [];
46 private array $deletedUsers = [];
47 private array $bounceMessages = [];
48 private bool $usePear = false;
49 private string $detectEmail;
50 private string $detectEmail2 = '/(([a-z0-9\-]+\.)+[a-z0-9]{2,8})\/([a-z0-9!#$%&\'*+\/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&\'*+\/=?^_`{|}~-]+)*)/i';
51 public array $messages = [];
52 //Max execution time minus 4 seconds... we need to stop the process before this date.
53 public int $stoptime = 0;
54 //Check apache module "mod_security" to avoid flush issue
55 protected bool $mod_security2 = false;
56 //Number of ob_end_flush used in the process...
57 public int $obend = 0;
58 // Correspond to mailbox entity in database
59 public object $action;
60 public array $attachments = [];
61
62 private array $inlineImages = [];
63 private array $allCharsets;
64 private bool $errorDuringActions = false;
65
66 private RuleClass $ruleClass;
67 private MailClass $mailClass;
68 private HistoryClass $historyClass;
69 private EncodingHelper $encodingHelper;
70
71 private array $allLists;
72
73 public function __construct()
74 {
75 parent::__construct();
76
77 $this->mailer = new MailerHelper();
78 $this->ruleClass = new RuleClass();
79 $this->userClass = new UserClass();
80 $this->mailClass = new MailClass();
81 $this->historyClass = new HistoryClass();
82 $this->encodingHelper = new EncodingHelper();
83
84 $this->mailer->report = false;
85 $this->mailer->alreadyCheckedAddresses = true;
86
87 $charsetType = new CharsetType();
88 $this->allCharsets = $charsetType->charsets;
89
90 $this->allowed_extensions = explode(',', $this->config->get('allowed_files', ''));
91 $this->detectEmail = '/'.acym_getEmailRegex(false, true).'/i';
92 }
93
94 public function init(array $config = []): bool
95 {
96 if (empty($config)) {
97 $config = [
98 'server' => $this->config->get('bounce_server'),
99 'username' => $this->config->get('bounce_username'),
100 'password' => $this->config->get('bounce_password'),
101 'port' => $this->config->get('bounce_port'),
102 'connect_method' => $this->config->get('bounce_connection'),
103 'secure_method' => $this->config->get('bounce_secured'),
104 'self_signed' => $this->config->get('bounce_certif', false),
105 'timeout' => $this->config->get('bounce_timeout', 10),
106 'bounce_access_token' => $this->config->get('bounce_access_token'),
107 ];
108 }
109
110 $this->server = $config['server'];
111 $this->username = $config['username'];
112 $this->password = $config['password'];
113 $this->port = $config['port'];
114 $this->connectMethod = $config['connect_method'];
115 $this->secureMethod = $config['secure_method'];
116 $this->selfSigned = $config['self_signed'];
117 $this->timeout = intval($config['timeout']);
118 $this->oAuthToken = $config['bounce_access_token'];
119
120 if ($this->connectMethod === 'pear') {
121 $this->usePear = true;
122 include_once ACYM_LIBRARIES.'Pear'.DS.'Pop3.php';
123
124 return true;
125 }
126
127 require_once ACYM_LIBRARIES.'Imap2'.DS.'bootstrap.php';
128
129 if (extension_loaded('imap') || function_exists('imap_open')) {
130 return true;
131 }
132
133 if ($this->report) {
134 $prefix = PHP_SHLIB_SUFFIX === 'dll' ? 'php_' : '';
135 $extension = $prefix.'imap.'.PHP_SHLIB_SUFFIX;
136
137 acym_display(
138 'The extension "'.$extension.'" could not be loaded, please change your PHP configuration to enable it or use the pop3 method without imap extension',
139 'error'
140 );
141 }
142
143 return false;
144 }
145
146 public function connect(): bool
147 {
148 if ($this->usePear) {
149 return $this->connectPear();
150 }
151
152 return $this->connectImap();
153 }
154
155 private function connectPear(): bool
156 {
157 ob_start();
158
159 $this->mailbox = new Pop3();
160
161 $timeout = $this->timeout;
162 if (!empty($timeout)) {
163 $this->mailbox->_timeout = $timeout;
164 }
165
166 $port = intval($this->port);
167 $secure = $this->secureMethod;
168 if (empty($port)) {
169 if ($secure === 'ssl') {
170 $port = '995';
171 } else {
172 $port = '110/pop3/notls';
173 }
174 }
175
176 $serverName = trim($this->server);
177
178 //We don't add back the ssl:// or tls:// if it's already there
179 if (!empty($secure) && !strpos($serverName, '://')) {
180 $serverName = $secure.'://'.$serverName;
181 }
182
183 if (!$this->mailbox->connect($serverName, $port)) {
184 $warnings = ob_get_clean();
185 if ($this->report) {
186 $notification = [
187 'name' => 'error_connecting',
188 'removable' => 1,
189 ];
190 acym_enqueueMessage(acym_translationSprintf('ACYM_ERROR_CONNECTING', $this->server.' : '.$port), 'error', true, [$notification]);
191 }
192 if (!empty($warnings) && $this->report) {
193 acym_display($warnings, 'warning');
194 }
195
196 return false;
197 }
198
199 $login = $this->mailbox->login(trim($this->username), trim($this->password), 'USER');
200 if (empty($login) || isset($login->code)) {
201 $warnings = ob_get_clean();
202 if ($this->report) {
203 acym_enqueueMessage(acym_translationSprintf('ACYM_ERROR_LOGIN', $this->username), 'error');
204 }
205 if (!empty($warnings) && $this->report) {
206 acym_display($warnings, 'warning');
207 }
208
209 return false;
210 }
211
212 ob_end_clean();
213
214 return true;
215 }
216
217 private function connectImap(): bool
218 {
219 if (empty($this->server)) {
220 acym_enqueueMessage(acym_translation('ACYM_CONFIGURE_BOUNCE'), 'warning');
221
222 return false;
223 }
224
225 ob_start();
226 //First we reset the buffer or errors and warnings
227 $buff = $this->callImapFunction('imap_alerts', []);
228 $buff = $this->callImapFunction('imap_errors', []);
229
230 $timeout = intval($this->timeout);
231 if (!empty($timeout)) {
232 $this->callImapFunction('imap_timeout', [IMAP_OPENTIMEOUT, $timeout]);
233 }
234
235 $port = intval($this->port);
236 $secure = $this->secureMethod;
237 $protocol = $this->connectMethod;
238 $serverName = '{'.trim($this->server);
239 if (empty($port)) {
240 if ($protocol === 'imap') {
241 $port = $secure === 'ssl' ? '993' : '143';
242 } elseif ($protocol === 'pop3') {
243 $port = $secure === 'ssl' ? '995' : '110';
244 }
245 }
246
247 if (!empty($port)) {
248 $serverName .= ':'.$port;
249 }
250
251 if ($this->server === 'outlook.office365.com') {
252 $serverName .= '/imap';
253 }
254
255 //Add the secure protocol (TLS or SSL)
256 if (!empty($secure)) {
257 $serverName .= '/'.$secure;
258 }
259
260 // Test if string contains '\'
261 $email = trim($this->username);
262 if (strpos($email, '\\') !== false) {
263 [$user, $authuser] = explode('\\', $email);
264 [$x, $domain] = explode('@', $user);
265 $serverName .= '/authuser='.$user.'/user='.$authuser.'@'.$domain;
266 }
267
268 if ($this->selfSigned) {
269 $serverName .= '/novalidate-cert';
270 }
271
272 //Add the method (imap by default) ex : pop3
273 if (!empty($protocol) && $this->server !== 'outlook.office365.com') {
274 $serverName .= '/service='.$protocol;
275 }
276 $serverName .= '}';
277 if (in_array($this->server, self::HOSTS_NEEDING_OAUTH)) {
278 $this->refreshToken();
279 if (empty($this->oAuthToken)) {
280 return false;
281 }
282 $this->mailbox = imap2_open($serverName, trim($this->username), $this->oAuthToken, OP_XOAUTH2);
283 } else {
284 $this->mailbox = imap_open($serverName, trim($this->username), trim($this->password), OP_SILENT);
285 }
286 $warnings = ob_get_clean();
287
288 if ($this->report) {
289 if (!$this->mailbox) {
290 $notification = [
291 'name' => 'error_connecting',
292 'removable' => 1,
293 ];
294 acym_enqueueMessage(acym_translationSprintf('ACYM_ERROR_CONNECTING', $serverName), 'error', true, [$notification]);
295 }
296 if (!empty($warnings)) {
297 acym_enqueueMessage($warnings, 'warning');
298 }
299 }
300
301 return (bool)$this->mailbox;
302 }
303
304 public function getNBMessages(): int
305 {
306 if ($this->usePear) {
307 $this->nbMessages = intval($this->mailbox->numMsg());
308 } else {
309 $this->nbMessages = intval($this->callImapFunction('imap_num_msg', [$this->mailbox]));
310 }
311
312 return $this->nbMessages;
313 }
314
315 protected function getMessage(int $msgNB)
316 {
317 if ($this->usePear) {
318 $message = new \stdClass();
319 $message->headerString = $this->mailbox->getRawHeaders($msgNB);
320 if (empty($message->headerString)) {
321 return false;
322 }
323 } else {
324 $message = $this->callImapFunction('imap_headerinfo', [$this->mailbox, $msgNB]);
325 }
326
327 return $message;
328 }
329
330 protected function deleteMessage($msgNB): void
331 {
332 if ($this->usePear) {
333 $this->mailbox->deleteMsg($msgNB);
334 } else {
335 $this->callImapFunction('imap_delete', [$this->mailbox, $msgNB]);
336 $this->callImapFunction('imap_expunge', [$this->mailbox]);
337 }
338 }
339
340 public function close(): void
341 {
342 if ($this->usePear) {
343 $this->mailbox->disconnect();
344 } else {
345 $this->callImapFunction('imap_close', [$this->mailbox]);
346 }
347 }
348
349 public function decodeMessage(bool $attachments = false): bool
350 {
351 try {
352 if ($this->usePear) {
353 return $this->decodeMessagePear($attachments);
354 } else {
355 return $this->decodeMessageImap($attachments);
356 }
357 } catch (\Throwable $e) {
358 $subject = is_string($this->_message->subject ?? '') ? $this->_message->subject : '';
359 $this->display(
360 acym_translationSprintf('ACYM_ERROR_DECODING_MESSAGE_X_X', acym_stripTags($this->decodeHeader($subject)), $e->getMessage()),
361 self::MESSAGE_TYPE_ERROR
362 );
363
364 return false;
365 }
366 }
367
368 private function decodeMessagePear(bool $attachments): bool
369 {
370 $this->_message->headerinfo = $this->mailbox->getParsedHeaders($this->_message->messageNB);
371 if (empty($this->_message->headerinfo['subject'])) {
372 if (empty($this->_message->headerinfo['Subject'])) {
373 return false;
374 }
375 $this->_message->headerinfo['subject'] = $this->_message->headerinfo['Subject'];
376 }
377 $this->_message->text = '';
378 $this->_message->html = $this->mailbox->getBody($this->_message->messageNB);
379
380 if (!empty($this->_message->headerinfo['content-type']) && strpos($this->_message->headerinfo['content-type'], 'boundary') !== false) {
381 $matches = [];
382 preg_match('#boundary="([^"]+)"#i', $this->_message->headerinfo['content-type'], $matches);
383
384 if (!empty($matches[1]) && strpos($this->_message->html, $matches[1]) !== false) {
385 $inlineImages = [];
386
387 $segments = explode('--'.$matches[1], $this->_message->html);
388 foreach ($segments as $segment) {
389 // Find the segment containing the html and text version
390 if (strpos($segment, 'Content-Type: text/plain') !== false) {
391 // Check if there is another boundary
392 $matches = [];
393 preg_match('#boundary="([^"]+)"#i', $segment, $matches);
394
395 if (!empty($matches[1])) {
396 $parts = explode('--'.$matches[1], $segment);
397
398 foreach ($parts as $onePart) {
399 $content = trim(preg_replace('#(charset=".*?\r\n)|Content-(Type|ID|Disposition|Transfer-Encoding):.*?\r\n#is', "", $onePart));
400
401 if (strpos($onePart, 'Content-Transfer-Encoding') !== false) {
402 preg_match('#Content-Transfer-Encoding: (.+)#i', $onePart, $encoding);
403 $encoding = trim($encoding[1]);
404 // Someone has an issue when the $encoding is equal to 'Content-Type:' only
405 if (!empty($encoding) && $encoding !== 'Content-Type:') {
406 $content = mb_convert_encoding($content, 'UTF-8', $encoding);
407 }
408 }
409
410 if (strpos($onePart, "Content-Type: text/plain") !== false) {
411 $this->_message->text .= ' '.$content;
412 }
413 if (strpos($onePart, "Content-Type: text/html") !== false) {
414 $this->_message->html .= ' '.$content;
415 }
416 }
417 } else {
418 $this->_message->html .= ' '.trim(preg_replace('#(charset=".*?\r\n)|Content-(Type|ID|Disposition|Transfer-Encoding):.*?\r\n#is', "", $segment));
419 }
420 } elseif (preg_match("#Content-Type: .*?/(png|jpg|jpeg|gif)#i", $segment) === 1) {
421 preg_match('#name="([^"]+)"#i', $segment, $filename);
422 if (empty($filename) || empty($filename[1])) {
423 continue;
424 }
425 $name = trim($filename[1]);
426 $filename = $name;
427 $extensionPos = strrpos($filename, '.');
428
429 if ($extensionPos === false) {
430 continue;
431 }
432
433 $extension = substr($filename, $extensionPos + 1);
434
435 if (!in_array(strtolower($extension), $this->allowed_extensions) || acym_isForbiddenFileExtension($filename)) {
436 continue;
437 }
438
439 // Make sure there is no double extension or space
440 $filename = preg_replace('#[^a-zA-Z0-9]#Uis', '_', substr($filename, 0, $extensionPos));
441
442 // Get the upload folder and create it if needed
443 $uploadFolder = str_replace(['/', '\\'], DS, acym_getFilesFolder());
444 $pathToUpload = ACYM_ROOT.trim($uploadFolder, DS).DS;
445
446 // Rename if needed
447 if (file_exists($pathToUpload.$filename.'.'.$extension)) {
448 $fileNumber = 1;
449 while (file_exists($pathToUpload.$filename.'_('.$fileNumber.').'.$extension)) {
450 $fileNumber++;
451 }
452 $filename = $filename.'_('.$fileNumber.')';
453 }
454
455 if ($attachments && preg_match("#Content-Disposition: attachment;#i", $segment) === 1) {
456 $data = trim(substr($segment, strpos($segment, "\r\n\r\n")));
457 if (strpos($segment, 'Content-Transfer-Encoding: base64') !== false) {
458 $data = base64_decode($data);
459 }
460
461 try {
462 if (acym_writeFile($pathToUpload.$filename.'.'.$extension, $data)) {
463 $attachment = new \stdClass();
464 $attachment->filename = str_replace(DS, '/', trim($uploadFolder, '\\/')).'/'.$filename.'.'.$extension;
465 $attachment->size = filesize(ACYM_ROOT.$attachment->filename);
466 $this->attachments[] = $attachment;
467 }
468 } catch (\Exception $e) {
469 $completeFilename = $filename.'.'.$extension;
470 $this->display(acym_translationSprintf('ACYM_ERROR_UPLOADING_ATTACHMENT_X_TO_X', $completeFilename, $e->getMessage()), self::MESSAGE_TYPE_ERROR);
471 }
472 } else {
473 preg_match('#Content-ID: <([^>]+)>#i', $segment, $contentID);
474 if (empty($contentID) || empty($contentID[1])) {
475 continue;
476 }
477 $contentID = trim($contentID[1]);
478
479 $data = trim(substr($segment, strpos($segment, "\r\n\r\n")));
480 if (strpos($segment, 'Content-Transfer-Encoding: base64') !== false) {
481 $data = base64_decode($data);
482 }
483
484
485 try {
486 if (acym_writeFile($pathToUpload.$filename.'.'.$extension, $data)) {
487 $inlineImages['cid:'.$contentID] = acym_rootURI().$uploadFolder.'/'.$filename.'.'.$extension;
488 }
489 } catch (\Exception $e) {
490 $this->display(acym_translationSprintf('ACYM_ERROR_UPLOAD_ATTACHMENT', $filename.'.'.$extension, $e->getMessage()), self::MESSAGE_TYPE_ERROR);
491 }
492 }
493 }
494 }
495
496 if (!empty($inlineImages)) {
497 $this->_message->html = str_replace(array_keys($inlineImages), $inlineImages, $this->_message->html);
498 }
499 }
500 }
501
502 $this->_message->subject = $this->decodeHeader($this->_message->headerinfo['subject']);
503 if (empty($this->_message->header)) {
504 $this->_message->header = new \stdClass();
505 }
506 $this->_message->header->sender_email = @$this->_message->headerinfo['return-path'];
507 if (is_array($this->_message->header->sender_email)) {
508 $this->_message->header->sender_email = reset($this->_message->header->sender_email);
509 }
510 if (!empty($this->_message->header->sender_email) && preg_match($this->detectEmail, $this->_message->header->sender_email, $results)) {
511 $this->_message->header->sender_email = $results[0];
512 }
513 $this->_message->header->sender_name = acym_stripTags(@$this->_message->headerinfo['from'] ?? '');
514 $this->_message->header->reply_to_email = $this->_message->header->sender_email;
515 $this->_message->header->reply_to_name = $this->_message->header->sender_name;
516 $this->_message->header->from_email = $this->_message->header->sender_email;
517 $this->_message->header->from_name = $this->_message->header->sender_name;
518
519 return true;
520 }
521
522 private function decodeMessageImap(bool $attachments): bool
523 {
524 try {
525 $this->_message->structure = $this->callImapFunction('imap_fetchstructure', [$this->mailbox, $this->_message->messageNB]);
526 } catch (\Throwable $e) {
527 $this->_message->structure = null;
528 $this->display(acym_translationSprintf('ACYM_BOUNCE_STRUCTURE_ERROR_X', $e->getMessage()), self::MESSAGE_TYPE_INFO);
529 }
530
531 $this->_message->headerinfo = $this->callImapFunction('imap_fetchheader', [$this->mailbox, $this->_message->messageNB]);
532
533 $this->_message->html = '';
534 $this->_message->text = '';
535
536 // We could not read the structure of the message, we analyse its raw content instead
537 if (empty($this->_message->structure)) {
538 $body = $this->callImapFunction('imap_body', [$this->mailbox, $this->_message->messageNB]);
539 if (empty($body)) {
540 return false;
541 }
542
543 $this->_message->contentType = 0;
544 $this->_message->text = substr($body, 0, 100000);
545 } elseif ($this->_message->structure->type == 1) {
546 // Multipart message : type == 1
547 $this->_message->contentType = 2;
548 if ($this->_message->structure->subtype === 'MIXED') {
549 $allParts = $this->explodeBodyMixed($this->_message->structure);
550 } else {
551 $allParts = $this->explodeBody($this->_message->structure);
552 }
553
554 $text = '';
555 foreach ($allParts as $num => $onePart) {
556 $content = $this->callImapFunction('imap_fetchbody', [$this->mailbox, $this->_message->messageNB, $num]);
557 $decodedContent = empty($content) ? '' : $this->decodeContent($content, $onePart);
558 if ($onePart->subtype === 'HTML') {
559 $this->_message->html .= $decodedContent;
560 } else {
561 if ($onePart->subtype === 'PLAIN') {
562 $text .= $decodedContent."\n";
563 }
564 $this->_message->text .= $decodedContent."\n\n- - -\n\n";
565 }
566
567 // Only for mailbox actions
568 if (!empty($this->action) && $attachments) {
569 if ((isset($onePart->parameters) && is_array($onePart->parameters)) || (isset($onePart->dparameters) && is_array($onePart->dparameters))) {
570 $this->uploadAttachment($onePart, $num);
571 } elseif (isset($onePart->parts) && is_array($onePart->parts)) {
572 foreach ($onePart->parts as $num2 => $innerPart) {
573 $this->uploadAttachment($innerPart, $num2);
574 }
575 }
576 }
577 }
578
579 if (!empty($this->action) && empty($this->_message->html)) {
580 $this->_message->html = $text."\n";
581 }
582 if (!empty($this->inlineImages)) {
583 $this->_message->html = str_replace(array_keys($this->inlineImages), $this->inlineImages, $this->_message->html);
584 }
585 } else {
586 $body = $this->callImapFunction('imap_body', [$this->mailbox, $this->_message->messageNB]);
587 $decodedBody = empty($body) ? '' : $this->decodeContent($body, $this->_message->structure);
588 if ($this->_message->structure->subtype === 'HTML') {
589 $this->_message->contentType = 1;
590 $this->_message->html = $decodedBody;
591 } else {
592 $this->_message->contentType = 0;
593 $this->_message->text = $decodedBody;
594 }
595 }
596
597 //Decode the subject
598 if (!empty($this->_message->subject)) {
599 $this->_message->subject = $this->decodeHeader($this->_message->subject);
600 } else {
601 $this->_message->subject = '';
602 }
603
604 $this->decodeAddressImap('sender');
605 $this->decodeAddressImap('from');
606 $this->decodeAddressImap('reply_to');
607 $this->decodeAddressImap('to');
608
609 return true;
610 }
611
612 private function uploadAttachment($attachment, $num): void
613 {
614 // It may be a real attachment, or an inline image
615 if (!isset($attachment->disposition) || (strtolower($attachment->disposition) !== 'attachment' && strtolower($attachment->disposition) !== 'inline')) {
616 return;
617 }
618
619 $name = empty($attachment->dparameters[0]->value) ? $attachment->parameters[0]->value : $attachment->dparameters[0]->value;
620 $filename = $this->decodeHeader($name);
621 $extensionPos = strrpos($filename, '.');
622
623 if ($extensionPos === false) {
624 foreach ($attachment->parameters as $key => $oneParam) {
625 if (strtolower($oneParam->attribute) === 'name') {
626 $filename = $this->decodeHeader($attachment->parameters[$key]->value);
627 $extensionPos = strrpos($filename, '.');
628 break;
629 }
630 }
631 }
632
633 // No extension => no upload
634 if ($extensionPos === false) {
635 return;
636 }
637
638 // Upload only allowed extensions in the configuration, and never executable ones
639 $extension = substr($filename, $extensionPos + 1);
640 if (!in_array(strtolower($extension), $this->allowed_extensions) || acym_isForbiddenFileExtension($filename)) {
641 return;
642 }
643
644 // Make sure there is no double extension or space
645 $filename = preg_replace('#[^a-zA-Z0-9]#Uis', '_', substr($filename, 0, $extensionPos));
646
647 // Get the upload folder and create it if needed
648 $uploadFolder = str_replace(['/', '\\'], DS, acym_getFilesFolder());
649 $pathToUpload = ACYM_ROOT.trim($uploadFolder, DS).DS;
650
651 // Rename if needed
652 if (file_exists($pathToUpload.$filename.'.'.$extension)) {
653 $fileNumber = 1;
654 while (file_exists($pathToUpload.$filename.'_('.$fileNumber.').'.$extension)) {
655 $fileNumber++;
656 }
657 $filename = $filename.'_('.$fileNumber.')';
658 }
659
660 $content = $this->callImapFunction('imap_fetchbody', [$this->mailbox, $this->_message->messageNB, $num]);
661
662 // We could not fetch the part, there is nothing to upload
663 if (empty($content)) {
664 return;
665 }
666
667 $data = $this->decodeContent($content, $attachment);
668
669 try {
670 if (acym_writeFile($pathToUpload.$filename.'.'.$extension, $data)) {
671 if (
672 strtolower($attachment->disposition) !== 'attachment'
673 && !empty($attachment->id)
674 && in_array(strtolower($attachment->subtype), acym_getImageFileExtensions())
675 ) {
676 $this->inlineImages['cid:'.trim($attachment->id, '<>')] = acym_rootURI().$uploadFolder.'/'.$filename.'.'.$extension;
677 } else {
678 $attachment = new \stdClass();
679 $attachment->filename = str_replace(DS, '/', trim($uploadFolder, '\\/')).'/'.$filename.'.'.$extension;
680 $attachment->size = filesize(ACYM_ROOT.$attachment->filename);
681 $this->attachments[] = $attachment;
682 }
683 }
684 } catch (\Exception $e) {
685 $completeFilename = $filename.'.'.$extension;
686 $this->display(acym_translationSprintf('ACYM_ERROR_UPLOADING_ATTACHMENT_X_TO_X', $completeFilename, $e->getMessage()), self::MESSAGE_TYPE_ERROR);
687 }
688 }
689
690 public function handleMessages(): void
691 {
692 $maxMessages = min($this->nbMessages, $this->config->get('bounce_max', 0));
693 //500 messages maximum at once
694 if (empty($maxMessages)) {
695 $maxMessages = min($this->nbMessages, 500);
696 }
697
698 if ($this->report) {
699 if (function_exists('apache_get_modules')) {
700 $modules = apache_get_modules();
701 $this->mod_security2 = in_array('mod_security2', $modules);
702 }
703
704 /*This is to avoid the blank page... and it apparently works! ;) */
705 // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Disabled to prevent blank output pages.
706 @ini_set('output_buffering', 'off');
707 // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Disabled to prevent blank output pages.
708 @ini_set('zlib.output_compression', 0);
709
710
711 if (!headers_sent()) {
712 while (ob_get_level() > 0 && $this->obend++ < 3) {
713 @ob_end_flush();
714 }
715 }
716
717 ?>
718 <div style="position:fixed; top:3px;left:3px;background-color : white;border : 1px solid grey; padding : 3px;font-size:14px">
719 <?php echo esc_html(acym_translation('ACYM_BOUNCE_HANDLING')); ?>
720 : <span id="counter">0</span> / <?php echo intval($maxMessages); ?>
721 </div>
722 <script type="text/javascript" language="javascript">
723 function setCounter(val) {
724 document.getElementById('counter').innerHTML = val;
725 }
726 </script>
727 <?php
728 if (function_exists('ob_flush')) {
729 @ob_flush();
730 }
731 if (!$this->mod_security2) {
732 @flush();
733 }
734 }
735
736 //We load all the published rules
737 $rules = $this->ruleClass->getAll();
738 $rules = array_filter($rules, function ($rule) {
739 return $rule->active;
740 });
741
742 $msgNB = $maxMessages;
743 $listClass = new ListClass();
744 $this->allLists = $listClass->getAll('id');
745
746 //Exclude some email addresses...
747 $replyemail = $this->config->get('reply_email');
748 $fromemail = $this->config->get('from_email');
749 $bouncemail = $this->config->get('bounce_email');
750 $removeEmails = '#('.str_replace(['%'], ['@'], $this->config->get('bounce_username'));
751 if (!empty($bouncemail)) {
752 $removeEmails .= '|'.$bouncemail;
753 }
754 if (!empty($fromemail)) {
755 $removeEmails .= '|'.$fromemail;
756 }
757 if (!empty($replyemail)) {
758 $removeEmails .= '|'.$replyemail;
759 }
760 $removeEmails .= ')#i';
761
762 while (($msgNB > 0) && ($this->_message = $this->getMessage($msgNB))) {
763 if ($this->report) {
764 echo '<script type="text/javascript" language="javascript">setCounter('.esc_html($maxMessages - $msgNB + 1).')</script>';
765 if (function_exists('ob_flush')) {
766 @ob_flush();
767 }
768 if (!$this->mod_security2) {
769 @flush();
770 }
771 }
772 $this->_message->messageNB = $msgNB;
773 $msgNB--;
774
775 //We could not retrieve the message... we continue with the next message
776 if (!$this->decodeMessage()) {
777 $this->display(acym_translation('ACYM_ERROR_RETRIEVING_MESSAGE'), self::MESSAGE_TYPE_ERROR, $maxMessages - $this->_message->messageNB + 1);
778 continue;
779 }
780
781 if (empty($this->_message->subject)) {
782 $this->_message->subject = '';
783 }
784
785 $this->_message->analyseText = $this->_message->html.' '.$this->_message->text;
786 //We add the from in the list of possible e-mail address to check
787 if (!empty($this->_message->header->from_email)) {
788 $this->_message->analyseText .= ' '.$this->_message->header->from_email;
789 }
790 $this->display(
791 '<b>'.acym_translation('ACYM_EMAIL_SUBJECT').' : '.acym_stripTags($this->_message->subject).'</b>',
792 self::MESSAGE_TYPE_SUCCESS,
793 $maxMessages - $this->_message->messageNB + 1
794 );
795
796 //Identify the user and the e-mail...
797 preg_match('#AC([0-9]+)Y([0-9]+)BA#i', $this->_message->analyseText, $resultsVars);
798 if (!empty($resultsVars[1])) {
799 $this->_message->userid = $resultsVars[1];
800 }
801 if (!empty($resultsVars[2])) {
802 $this->_message->mailid = $resultsVars[2];
803 }
804
805 if (empty($this->_message->userid)) {
806 //We will need the e-mail itself in that case... :p
807 preg_match_all($this->detectEmail, $this->_message->analyseText, $results);
808
809 //Still no result? We try to find others
810 if (empty($results[0])) {
811 preg_match_all($this->detectEmail2, $this->_message->analyseText, $results2);
812 for ($i = 0; $i < count($results2[0]); $i++) {
813 $results[0][] = $results2[3][$i].'@'.$results2[1][$i];
814 }
815 }
816
817 if (!empty($results[0])) {
818 $alreadyChecked = [];
819 foreach ($results[0] as $oneEmail) {
820 //We will find the e-mail if it's not in the list of incorrect e-mail addresses
821 if (!preg_match($removeEmails, $oneEmail)) {
822 //We will keep this one, so we make sure it's strtolower
823 $this->_message->subemail = strtolower($oneEmail);
824 //We already checked this e-mail address... no need to try it a second time
825 if (!empty($alreadyChecked[$this->_message->subemail])) {
826 continue;
827 }
828
829 $user = $this->userClass->getOneByEmail($this->_message->subemail);
830 if (!empty($user)) $this->_message->userid = $user->id;
831
832 if (empty($this->_message->userid) && preg_match('#(&|\?)id=([0-9]+)&#ims', $this->_message->subemail, $subid)) {
833 $user = $this->userClass->getOneById($subid[2]);
834 $this->_message->userid = $user->id;
835 $this->_message->subemail = $user->email;
836 }
837
838 $alreadyChecked[$this->_message->subemail] = true;
839 if (!empty($this->_message->userid)) {
840 break;
841 }
842 }
843 }
844 }
845 }
846
847 if (empty($this->_message->mailid) && !empty($this->_message->userid)) {
848 //We can check if we have a user and only one e-mail sent for this user, it's obviously the e-mail we just sent!!
849 $this->_message->mailid = acym_loadResult(
850 'SELECT `mail_id` FROM #__acym_user_stat WHERE `user_id` = '.intval($this->_message->userid).' ORDER BY `send_date` DESC'
851 );
852 }
853
854 foreach ($rules as $oneRule) {
855 //We stop as soon as we find a good rule...
856 if ($this->handleRule($oneRule)) {
857 break;
858 }
859 }
860
861 if ($msgNB % 50 == 0) {
862 $this->userActions();
863 }
864
865 //We don't have time to finish the process? Ok, we stop it now!
866 if (!empty($this->stoptime) && time() > $this->stoptime) {
867 break;
868 }
869 }
870
871 $this->userActions();
872 $this->close();
873 }
874
875 /**
876 * Execute actions on the subscriber... and record the statistics
877 * We group them to not have performance issues
878 */
879 private function userActions(): void
880 {
881 if (!empty($this->deletedUsers)) {
882 acym_arrayToInteger($this->deletedUsers);
883 $this->userClass->delete($this->deletedUsers);
884 $this->deletedUsers = [];
885 }
886
887 if (!empty($this->blockedUsers)) {
888 acym_arrayToInteger($this->blockedUsers);
889 $this->userClass->deactivate($this->blockedUsers);
890 //We delete any other e-mail from the queue as well
891 $allUsersId = implode(',', $this->blockedUsers);
892 acym_query('DELETE FROM `#__acym_queue` WHERE `user_id` IN ('.$allUsersId.')');
893 $this->blockedUsers = [];
894 }
895
896 if (!empty($this->bounceMessages)) {
897 foreach ($this->bounceMessages as $mailid => $bouncedata) {
898 //Do we have some bounce details to update?
899 //If so, we will load the current bouncedetails and update it properly.
900 //bouncedetails is an array of bounceRule => nbTimes used
901
902 $updateBounceDetails = '';
903 if (!empty($bouncedata['bouncedetails'])) {
904 $bouncedetails = acym_loadResult('SELECT `bounce_details` FROM #__acym_mail_stat WHERE mail_id = '.intval($mailid));
905 if (!empty($bouncedetails)) {
906 $bouncedetails = unserialize($bouncedetails);
907 } else {
908 $bouncedetails = [];
909 }
910
911 foreach ($bouncedata['bouncedetails'] as $ruleName => $nbTimes) {
912 if (empty($bouncedetails[$ruleName])) {
913 $bouncedetails[$ruleName] = 0;
914 }
915 $bouncedetails[$ruleName] += $nbTimes;
916 }
917
918 $updateBounceDetails = ' , `bounce_details` = '.acym_escapeDB(serialize($bouncedetails));
919 }
920
921 if (!empty($bouncedata['userids'])) {
922 $valueInsert = [];
923 foreach ($bouncedata['ruletriggered'] as $userid => $rulename) {
924 $valueInsert[] = '('.intval($mailid).','.intval($userid).',1,'.acym_escapeDB($rulename).')';
925 }
926 acym_query(
927 'INSERT INTO #__acym_user_stat (mail_id,user_id,bounce,bounce_rule) VALUES '.implode(
928 ',',
929 $valueInsert
930 ).' ON DUPLICATE KEY UPDATE `bounce` = `bounce` + 1, bounce_rule=VALUES(bounce_rule)'
931 );
932 //We updated some profiles... let's make sure we really handle only unique bounces then and don't count it twice
933 acym_arrayToInteger($bouncedata['userids']);
934 $realUniqueBounces = acym_loadResult(
935 'SELECT COUNT(*)
936 FROM #__acym_user_stat
937 WHERE `bounce` = 1
938 AND `user_id` IN ('.implode(',', $bouncedata['userids']).')
939 AND `mail_id` = '.intval($mailid)
940 );
941 $bouncedata['nbbounces'] = $bouncedata['nbbounces'] - count($bouncedata['userids']) + $realUniqueBounces;
942 }
943
944 acym_query(
945 'UPDATE #__acym_mail_stat SET `bounce_unique` = `bounce_unique` + '.intval($bouncedata['nbbounces']).$updateBounceDetails.'
946 WHERE `mail_id` = '.intval($mailid).'
947 LIMIT 1'
948 );
949 }
950 $this->bounceMessages = [];
951 }
952 }
953
954 private function handleRule(object &$oneRule): bool
955 {
956 $regex = $oneRule->regex;
957 if (empty($regex)) {
958 return false;
959 }
960
961 //Do it based on the config of the rule...
962
963 $analyseText = '';
964 if (in_array('senderInfo', $oneRule->executed_on)) {
965 $analyseText .= ' ';
966 if (isset($this->_message->header->sender_name)) {
967 $analyseText .= $this->_message->header->sender_name;
968 }
969 if (isset($this->_message->header->sender_email)) {
970 $analyseText .= $this->_message->header->sender_email;
971 }
972 }
973
974 if (in_array('subject', $oneRule->executed_on)) {
975 $analyseText .= ' '.$this->_message->subject;
976 }
977
978 if (in_array('body', $oneRule->executed_on)) {
979 if (!empty($this->_message->html)) {
980 $analyseText .= ' '.$this->_message->html;
981 }
982 if (!empty($this->_message->text)) {
983 $analyseText .= ' '.$this->_message->text;
984 }
985 }
986
987 //Because it's easier to handle it that way... for multilines.
988 $analyseText = str_replace(["\n", "\r", "\t"], ' ', $analyseText);
989
990 if (!preg_match('#'.$regex.'#ims', $analyseText)) {
991 return false;
992 }
993
994 $message = acym_translation('ACYM_BOUNCE_RULE').' ['.acym_translation('ACYM_ID').' '.$oneRule->id.'] '.acym_translation($oneRule->name).' : ';
995 $this->errorDuringActions = false;
996 $message .= $this->actionUser($oneRule);
997 $message .= $this->actionMessage($oneRule);
998
999 $type = self::MESSAGE_TYPE_SUCCESS;
1000 if ($this->errorDuringActions) {
1001 $type = self::MESSAGE_TYPE_ERROR;
1002 }
1003
1004 $this->display($message, $type);
1005
1006 return true;
1007 }
1008
1009 private function actionUser(object &$oneRule): string
1010 {
1011 $commonListsMailUser = [];
1012 $message = '';
1013
1014 if (empty($this->_message->userid)) {
1015 $message .= 'user not identified';
1016 if (!empty($this->_message->subemail)) {
1017 $message .= ' ( '.$this->_message->subemail.' ) ';
1018 }
1019
1020 return $message;
1021 }
1022
1023 //To display nice error messages...
1024 if (
1025 in_array('delete_user_subscription', $oneRule->action_user)
1026 || in_array('unsubscribe_user', $oneRule->action_user)
1027 || in_array('subscribe_user', $oneRule->action_user)
1028 ) {
1029 if (empty($this->_message->subemail)) {
1030 $currentUser = $this->userClass->getOneById($this->_message->userid);
1031 if (!empty($currentUser->email)) {
1032 $this->_message->subemail = $currentUser->email;
1033 }
1034 }
1035
1036 $userSubscription = $this->userClass->getSubscriptionStatus($this->_message->userid);
1037
1038 $bounceContent = '';
1039 if (!empty($this->_message->html)) {
1040 $bounceContent .= ' '.$this->_message->html;
1041 }
1042 if (!empty($this->_message->text)) {
1043 $bounceContent .= ' '.$this->_message->text;
1044 }
1045
1046 $emailLists = [];
1047 preg_match('#Please unsubscribe user ID \d+ from list\(s\) ([^.]+)\.#Uis', $bounceContent, $emailLists);
1048 if (!empty($emailLists[1])) {
1049 $currentEmailLists = explode(',', $emailLists[1]);
1050 foreach ($currentEmailLists as $oneListId) {
1051 if (!empty($userSubscription[$oneListId]) && $userSubscription[$oneListId]->status == 1) {
1052 $commonListsMailUser[] = $oneListId;
1053 }
1054 }
1055 }
1056
1057 if (empty($commonListsMailUser)) {
1058 $commonListsMailUser = array_keys($userSubscription);
1059 }
1060 }
1061
1062 if (empty($this->_message->subemail)) {
1063 $this->_message->subemail = $this->_message->userid;
1064 }
1065
1066
1067 //handle this rule in the stats
1068 if (!empty($this->_message->mailid)) {
1069 $mail = $this->mailClass->getOneById($this->_message->mailid);
1070 } else {
1071 $mail = null;
1072 }
1073
1074 if ($oneRule->increment_stats && !empty($this->_message->mailid) && !empty($mail)) {
1075 //Init the stats...
1076 if (empty($this->bounceMessages[$this->_message->mailid])) {
1077 $this->bounceMessages[$this->_message->mailid] = [];
1078 $this->bounceMessages[$this->_message->mailid]['nbbounces'] = 0;
1079 $this->bounceMessages[$this->_message->mailid]['userids'] = [];
1080 $this->bounceMessages[$this->_message->mailid]['bouncedetails'] = [];
1081 $this->bounceMessages[$this->_message->mailid]['ruletriggered'] = [];
1082 }
1083
1084 //Increment the global stats...
1085 $this->bounceMessages[$this->_message->mailid]['nbbounces']++;
1086
1087 //Increment the detailed stats...
1088 $ruleName = $oneRule->name.' [ID '.$oneRule->id.'] ';
1089 //We add a @ just in case the rule was not already defined...
1090 if (empty($this->bounceMessages[$this->_message->mailid]['bouncedetails'][$ruleName])) {
1091 $this->bounceMessages[$this->_message->mailid]['bouncedetails'][$ruleName] = 1;
1092 } else {
1093 $this->bounceMessages[$this->_message->mailid]['bouncedetails'][$ruleName]++;
1094 }
1095
1096 $user = $this->userClass->getOneById($this->_message->userid);
1097 if (!empty($this->_message->userid) && !in_array('delete_user', $oneRule->action_user) && !empty($user)) {
1098 //Increment the bounce number in the user stat table but only if we don't delete the subscriber
1099 $this->bounceMessages[$this->_message->mailid]['userids'][] = intval($this->_message->userid);
1100 $this->bounceMessages[$this->_message->mailid]['ruletriggered'][intval($this->_message->userid)] = $oneRule->name.' ['.acym_translation(
1101 'ACYM_ID'
1102 ).' '.$oneRule->id.']';
1103 }
1104 }
1105
1106 //Make sure we have enough messages to really execute this
1107 if (!empty($oneRule->execute_action_after) && $oneRule->execute_action_after > 1) {
1108 //Let's load the number of bounces the user has and then exit or not...
1109 if (empty($this->_message->mailid)) {
1110 $this->_message->mailid = 0;
1111 }
1112 $nb = intval(
1113 acym_loadResult(
1114 'SELECT COUNT(mail_id) FROM #__acym_user_stat WHERE bounce > 0 AND user_id = '.intval($this->_message->userid).' AND mail_id != '.intval(
1115 $this->_message->mailid
1116 )
1117 )
1118 ) + 1;
1119
1120 if ($nb < $oneRule->execute_action_after) {
1121 $message .= ' | '.acym_translationSprintf('ACYM_BOUNCE_RECEIVED', $nb, $this->_message->subemail).' | '.acym_translationSprintf(
1122 'ACYM_BOUNCE_MIN_EXEC',
1123 $oneRule->execute_action_after
1124 );
1125
1126 return $message;
1127 }
1128 }
1129
1130 //If we delete the subscriber, it's the last action we execute
1131 if (in_array('delete_user', $oneRule->action_user)) {
1132 $message .= ' | '.acym_translationSprintf('ACYM_USER_X_DELETED', $this->_message->subemail);
1133 $this->deletedUsers[] = intval($this->_message->userid);
1134
1135 return $message;
1136 }
1137
1138 //We will need a default listid after...
1139 $listId = 0;
1140 if (in_array('subscribe_user', $oneRule->action_user) && !empty($oneRule->action_user['subscribe_user_list'])) {
1141 $listId = $oneRule->action_user['subscribe_user_list'];
1142
1143 $listName = empty($this->allLists[$listId]->name) ? $listId : $this->allLists[$listId]->name;
1144 $message .= ' | ';
1145 if (isset($userSubscription[$listId])) {
1146 $message .= acym_translationSprintf('ACYM_USER_X_NOT_SUBSCRIBED_TO', $this->_message->subemail, $listName);
1147 if ($userSubscription[$listId]->status == 1) {
1148 $message .= acym_translation('ACYM_USER_ALREADY_SUBSCRIBED');
1149 } elseif ($userSubscription[$listId]->status == 0) {
1150 $message .= acym_translation('ACYM_USER_ALREADY_UNSUBSCRIBED');
1151 }
1152 } else {
1153 $this->userClass->subscribe([$this->_message->userid], [$listId]);
1154 $message .= acym_translationSprintf('ACYM_USER_X_SUBSCRIBED_TO', $this->_message->subemail, $listName);
1155 }
1156 }
1157
1158 if (in_array('delete_user_subscription', $oneRule->action_user)) {
1159 $removeLists = array_diff($commonListsMailUser, [$listId]);
1160 if (!empty($removeLists)) {
1161 $listNames = [];
1162 foreach ($removeLists as $oneListId) {
1163 if (!empty($this->allLists[$oneListId]->name)) {
1164 $listNames[] = $this->allLists[$oneListId]->name;
1165 } else {
1166 $listNames[] = $oneListId;
1167 }
1168 }
1169 $message .= ' | '.acym_translationSprintf('ACYM_USER_X_REMOVED_FROM', $this->_message->subemail, implode(', ', $listNames));
1170 $this->userClass->removeSubscription([$this->_message->userid], $removeLists);
1171 } else {
1172 $message .= ' | '.acym_translationSprintf('ACYM_USER_X_NOT_SUBSCRIBED', $this->_message->subemail);
1173 }
1174 }
1175
1176 if (in_array('unsubscribe_user', $oneRule->action_user)) {
1177 $unsubLists = array_diff($commonListsMailUser, [$listId]);
1178 if (!empty($unsubLists)) {
1179 $listNames = [];
1180 foreach ($unsubLists as $oneListId) {
1181 if (!empty($this->allLists[$oneListId]->name)) {
1182 $listNames[] = $this->allLists[$oneListId]->name;
1183 } else {
1184 $listNames[] = $oneListId;
1185 }
1186 }
1187 $this->userClass->unsubscribe([$this->_message->userid], $unsubLists);
1188 $message .= ' | '.acym_translationSprintf('ACYM_USER_X_UNSUBSCRIBED_FROM', $this->_message->subemail, implode(', ', $listNames));
1189 } else {
1190 $message .= ' | '.acym_translationSprintf('ACYM_USER_X_NOT_SUBSCRIBED', $this->_message->subemail);
1191 }
1192 }
1193
1194 if (in_array('block_user', $oneRule->action_user)) {
1195 $message .= ' | '.acym_translationSprintf('ACYM_USER_X_BLOCKED', $this->_message->subemail.' ( '.acym_translation('ACYM_ID').' '.intval($this->_message->userid).' )');
1196 $this->blockedUsers[] = intval($this->_message->userid);
1197 }
1198
1199 if (in_array('empty_queue_user', $oneRule->action_user)) {
1200 $affected = acym_query('DELETE FROM #__acym_queue WHERE user_id = '.intval($this->_message->userid));
1201 $message .= ' | '.acym_translationSprintf(
1202 'ACYM_USER_X_QUEUE',
1203 $this->_message->subemail.' ( '.acym_translation('ACYM_ID').' '.intval($this->_message->userid).' )',
1204 acym_translationSprintf('ACYM_SUCC_DELETE_ELEMENTS', $affected)
1205 );
1206 }
1207
1208 return $message;
1209 }
1210
1211 private function actionMessage(object &$oneRule): string
1212 {
1213 $message = '';
1214
1215 //Fix the rule if needed... when the forwarded user is the same as the bounce e-mail address...
1216 if (!empty($oneRule->action_message['forward_to'])) {
1217 if (
1218 strtolower($oneRule->action_message['forward_to']) === strtolower($this->config->get('bounce_username'))
1219 || strtolower($oneRule->action_message['forward_to']) === strtolower($this->config->get('bounce_email'))
1220 ) {
1221 //We don't forward it
1222 $oneRule->action_message['forward_to'] = '';
1223 //We don't delete it
1224 unset($oneRule->action_message['delete_message']);
1225 $message .= ' | '.acym_translation('ACYM_BOUNCE_NOT_FORWARD');
1226 }
1227 }
1228
1229 //Handle actions on the message itself
1230
1231 // We only delete the message if the forward didn't fail and the save in history succeeded
1232 $donotdelete = false;
1233
1234 if (in_array('save_message', $oneRule->action_message) && !empty($this->_message->userid) && !in_array('delete_user', $oneRule->action_user)) {
1235 //We have a userid, should we save the message in the database?
1236 $data = [];
1237 $data[] = 'SUBJECT::'.@htmlentities($this->_message->subject, ENT_COMPAT, 'UTF-8');
1238 $data[] = 'ACY_RULE::'.$oneRule->id.' '.$oneRule->name;
1239 $data[] = 'REPLYTO_ADDRESS::'.$this->_message->header->reply_to_name.' ( '.$this->_message->header->reply_to_email.' )';
1240 $data[] = 'FROM_ADDRESS::'.$this->_message->header->from_name.' ( '.$this->_message->header->from_email.' )';
1241 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r -- Logging information.
1242 $data[] = @htmlentities(print_r($this->_message->headerinfo, true), ENT_COMPAT, 'UTF-8');
1243 if (empty($this->_message->mailid)) {
1244 $this->_message->mailid = 0;
1245 }
1246 try {
1247 $this->historyClass->insert($this->_message->userid, 'bounce', $data, $this->_message->mailid);
1248 $message .= ' | '.acym_translationSprintf('ACYM_BOUNCE_MESSAGE_SAVED', $this->_message->userid);
1249 } catch (\Exception $e) {
1250 $message .= ' | '.acym_translationSprintf(
1251 'ACYM_BOUNCE_SAVE_ERROR_USER_MAIL_RULE',
1252 $this->_message->userid,
1253 $this->_message->mailid,
1254 $oneRule->id,
1255 $e->getMessage()
1256 );
1257 $donotdelete = true;
1258 }
1259 }
1260
1261
1262 //We don't forward the message if it's the same mailbox!
1263 if (!empty($oneRule->action_message['forward_to'])) {
1264 //Get the forward address :
1265 $this->mailer->clearAll();
1266 $this->mailer->Subject = 'BOUNCE FORWARD : '.$this->_message->subject;
1267
1268 if (substr_count($oneRule->action_message['forward_to'], '@') > 1) {
1269 $forwardAddresses = explode(';', str_replace([';', ','], ';', $oneRule->action_message['forward_to']));
1270 } else {
1271 $forwardAddresses = [$oneRule->action_message['forward_to']];
1272 }
1273
1274 foreach ($forwardAddresses as $oneForwardAddress) {
1275 $this->mailer->AddAddress($this->mailer->cleanText($oneForwardAddress));
1276 }
1277
1278 //Add the rule at the top so we know why the message has been forwarded.
1279 $info = acym_translation('ACYM_BOUNCE_RULE').' ['.acym_translation('ACYM_ID').' '.$oneRule->id.'] '.acym_translation($oneRule->name);
1280 if (!empty($this->_message->html)) {
1281 $this->mailer->isHTML();
1282 if (!empty($this->_message->userid)) {
1283 $info .= ' ('.acym_translation('ACYM_USER_ID').': '.$this->_message->userid.')';
1284 } elseif (!empty($this->_message->subemail)) {
1285 $info .= ' ('.acym_translation('ACYM_USER_EMAIL').': '.$this->_message->subemail.')';
1286 }
1287
1288 $this->mailer->Body = $info.'<br />'.$this->_message->html;
1289 if (!empty($this->_message->text)) {
1290 $this->mailer->Body .= '<br /><br />-------<br />'.nl2br($this->_message->text);
1291 }
1292 } else {
1293 $this->mailer->isHTML(false);
1294 $this->mailer->Body = $info."\n".$this->_message->text;
1295 }
1296
1297 //We add all other extra information just in case of we could use them...
1298 //original-rcpt-to ? http://tools.ietf.org/html/rfc5965
1299 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r -- Logging information.
1300 $this->mailer->Body .= print_r($this->_message->headerinfo, true);
1301 $replyAddress = trim(@$this->_message->header->reply_to_email, '<> ');
1302 if (!empty($replyAddress)) {
1303 if ($this->config->get('mailer_method') === \plgAcymAcymailer::SENDING_METHOD_ID) {
1304 $this->mailer->AddReplyTo($this->config->get('replyto_email'), $this->config->get('replyto_name'));
1305 } else {
1306 $this->mailer->AddReplyTo(trim($this->_message->header->reply_to_email, '<> '), $this->_message->header->reply_to_name);
1307 }
1308 }
1309
1310 $this->mailer->isForward = true;
1311 if ($this->mailer->send()) {
1312 $message .= ' | '.acym_translationSprintf('ACYM_FORWARDED_TO_X', $oneRule->action_message['forward_to']);
1313 } else {
1314 $message .= ' | '.acym_translationSprintf('ACYM_NOT_FORWARDED_TO_X', $oneRule->action_message['forward_to'], $this->mailer->reportMessage);
1315 $this->errorDuringActions = true;
1316 }
1317 }
1318
1319 if (in_array('delete_message', $oneRule->action_message) && !$this->errorDuringActions) {
1320 $message .= ' | '.acym_translation('ACYM_MESSAGE_DELETED');
1321 $this->deleteMessage($this->_message->messageNB);
1322 }
1323
1324 return $message;
1325 }
1326
1327 private function decodeAddressImap(string $type): void
1328 {
1329 $address = $type.'address';
1330 $name = $type.'_name';
1331 $email = $type.'_email';
1332 if (empty($this->_message->$type)) {
1333 return;
1334 }
1335
1336 $var = $this->_message->$type;
1337
1338 if (empty($this->_message->header)) {
1339 $this->_message->header = new \stdClass();
1340 }
1341
1342 if (!empty($this->_message->$address)) {
1343 $this->_message->header->$name = $this->_message->$address;
1344 } else {
1345 $this->_message->header->$name = $var[0]->personal;
1346 }
1347
1348 $this->_message->header->$email = $var[0]->mailbox.'@'.@$var[0]->host;
1349 }
1350
1351 /**
1352 * If num is empty then it's a message otherwise it's a send status
1353 */
1354 protected function display(string $message, int $type = self::MESSAGE_TYPE_SUCCESS, string $num = ''): void
1355 {
1356 $this->messages[] = $message;
1357
1358 if (!$this->report) return;
1359
1360 if ($type === self::MESSAGE_TYPE_SUCCESS) {
1361 $color = 'green';
1362 } elseif ($type === self::MESSAGE_TYPE_INFO) {
1363 $color = 'blue';
1364 } else {
1365 $color = 'red';
1366 }
1367
1368 if (!empty($num)) {
1369 echo '<br />'.esc_html($num).' : ';
1370 } else {
1371 echo '<br />&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;';
1372 }
1373
1374 echo '<span style="color: '.esc_attr($color).'">'.esc_html($message).'</span>';
1375
1376 if (function_exists('ob_flush')) {
1377 @ob_flush();
1378 }
1379 if (!$this->mod_security2) {
1380 @flush();
1381 }
1382 }
1383
1384 public function decodeHeader(string $input): string
1385 {
1386 // Remove white space between encoded-words
1387 $input = preg_replace('/(=\?[^?]+\?(q|b)\?[^?]*\?=)(\s)+=\?/i', '\1=?', $input);
1388 $currentCharset = false;
1389
1390 // For each encoded-word...
1391 while (preg_match('/(=\?([^?]+)\?(q|b)\?([^?]*)\?=)/i', $input, $matches)) {
1392 $encoded = $matches[1];
1393 $charset = $matches[2];
1394 $encoding = $matches[3];
1395 $text = $matches[4];
1396
1397 switch (strtolower($encoding)) {
1398 case 'b':
1399 $text = base64_decode($text);
1400 break;
1401
1402 case 'q':
1403 $text = str_replace('_', ' ', $text);
1404 preg_match_all('/=([a-f0-9]{2})/i', $text, $matches);
1405 foreach ($matches[1] as $value) {
1406 $text = str_replace('='.$value, chr(hexdec($value)), $text);
1407 }
1408 break;
1409 }
1410 $currentCharset = $charset;
1411 $input = str_replace($encoded, $text, $input);
1412 }
1413
1414 //If we have a charset and we can handle it...
1415 if (!empty($currentCharset) && in_array($currentCharset, $this->allCharsets)) {
1416 $input = $this->encodingHelper->change($input, $currentCharset, 'UTF-8');
1417 }
1418
1419 return $input;
1420 }
1421
1422 private function explodeBodyMixed(object $struct, string $path = '1'): array
1423 {
1424 $allParts = [];
1425
1426 if (empty($struct->parts)) {
1427 return $allParts;
1428 }
1429
1430 $pathPrefix = ($struct->subtype === 'MIXED' && $path === '1') ? '' : $path.'.';
1431 foreach ($struct->parts as $i => $part) {
1432 $partPath = $pathPrefix.($i + 1);
1433 if ($part->type == 1) {
1434 $allParts = array_merge($this->explodeBodyMixed($part, $partPath), $allParts);
1435 } else {
1436 $allParts[$partPath] = $part;
1437 }
1438 }
1439
1440 return $allParts;
1441 }
1442
1443 private function explodeBody(object $struct, string $path = '0', bool $inline = false): array
1444 {
1445 $allParts = [];
1446
1447 if (empty($struct->parts)) {
1448 return $allParts;
1449 }
1450
1451 //counts real content
1452 $c = 0;
1453 foreach ($struct->parts as $part) {
1454 if ($part->type == 1) {
1455 //There are more parts....:
1456 if ($part->subtype === 'MIXED') {
1457 // refreshing current path
1458 $path = $this->incPath($path);
1459 //create a new path-id (ex.:2.0)
1460 $newpath = $path.'.0';
1461 //fetch new parts
1462 $allParts = array_merge($this->explodeBody($part, $newpath), $allParts);
1463 } else {
1464 //Alternativ / rfc / signed
1465 $newpath = $this->incPath($path);
1466 $path = $this->incPath($path);
1467 $allParts = array_merge($this->explodeBody($part, $newpath, true), $allParts);
1468 }
1469 } else {
1470 $c++;
1471 //creating new tree if this is part of a alternativ or rfc message:
1472 if ($c == 1 && $inline) {
1473 $path = $path.'.0';
1474 }
1475 //saving content:
1476 $path = $this->incPath($path);
1477 //print "<br> Content ".$path."<br>"; //debug information
1478 $allParts[$path] = $part;
1479 }
1480 }
1481
1482 return $allParts;
1483 }
1484
1485 //Increases the Path to the parts:
1486 private function incPath(string $path): string
1487 {
1488 $newPath = '';
1489 $path_elements = explode('.', $path);
1490 $limit = count($path_elements);
1491 for ($i = 0; $i < $limit; $i++) {
1492 //last element
1493 if ($i == $limit - 1) {
1494 // new Part-Number
1495 $newPath .= $path_elements[$i] + 1;
1496 } else {
1497 //rebuild "1.2.2"-Chronology
1498 $newPath .= $path_elements[$i].'.';
1499 }
1500 }
1501
1502 return $newPath;
1503 }
1504
1505 private function decodeContent(string $content, object $structure): string
1506 {
1507 $encoding = $structure->encoding;
1508
1509 //First we decode the content properly
1510 if ($encoding == 2) {
1511 $content = imap_binary($content);
1512 } elseif ($encoding == 3) {
1513 $content = base64_decode($content, true);
1514 } elseif ($encoding == 4) {
1515 $content = imap_qprint($content);
1516 }
1517
1518 // Now we convert into utf-8! only for distribution lists
1519 if (!empty($this->action)) {
1520 $charset = $this->getMailParam($structure);
1521 if (!empty($charset) && strtoupper($charset) !== 'UTF-8') {
1522 $content = $this->encodingHelper->change($content, $charset, 'UTF-8');
1523 }
1524
1525 return $content;
1526 }
1527
1528 //It can't be more than 100 000 characters, it's already plenty enough for bounce handling... we avoid embedded pictures or big attachments we could not catch before.
1529 return substr($content, 0, 100000);
1530 }
1531
1532 private function getMailParam(object $params): string
1533 {
1534 $searchIn = [];
1535
1536 if ($params->ifparameters) {
1537 $searchIn = array_merge($searchIn, $params->parameters);
1538 }
1539 if ($params->ifdparameters) {
1540 $searchIn = array_merge($searchIn, $params->dparameters);
1541 }
1542
1543 if (empty($searchIn)) {
1544 return '';
1545 }
1546
1547 foreach ($searchIn as $values) {
1548 if (strtolower($values->attribute) === 'charset') {
1549 return $values->value;
1550 }
1551 }
1552
1553 return '';
1554 }
1555
1556 public function getErrors(): array
1557 {
1558 $return = [];
1559 if (!$this->usePear) {
1560 if (!function_exists('imap_alerts')) {
1561 $return[] = 'The IMAP extension could not be loaded, please change your PHP configuration to enable it or use the pop3 method without imap extension';
1562
1563 return $return;
1564 }
1565 $alerts = $this->callImapFunction('imap_alerts');
1566 $errors = $this->callImapFunction('imap_errors');
1567 if (!empty($alerts)) {
1568 $return = array_merge($return, $alerts);
1569 }
1570 if (!empty($errors)) {
1571 $return = array_merge($return, $errors);
1572 }
1573 }
1574
1575 return $return;
1576 }
1577
1578 private function refreshToken(): void
1579 {
1580 if ($this->config->get('bounce_server') === 'imap.gmail.com') {
1581 $url = 'https://oauth2.googleapis.com/token';
1582 } else {
1583 $tenant = trim($this->config->get('bounce_tenant')) ?: 'consumers';
1584 $url = 'https://login.microsoftonline.com/'.$tenant.'/oauth2/v2.0/token';
1585 }
1586
1587 $oauth = new OAuth(
1588 [
1589 'sendingMethod' => 'bounce',
1590 'tokenGenerationUrl' => $url,
1591 'userName' => trim($this->config->get('bounce_username')),
1592 'clientId' => trim($this->config->get('bounce_client_id')),
1593 'clientSecret' => trim($this->config->get('bounce_client_secret')),
1594 'oauthToken' => trim($this->config->get('bounce_access_token')),
1595 'oauthTokenExpiration' => trim($this->config->get('bounce_access_token_expiration')),
1596 'refreshToken' => trim($this->config->get('bounce_refresh_token')),
1597 ]
1598 );
1599
1600 $this->oAuthToken = $oauth->getToken();
1601 }
1602
1603 private function callImapFunction(string $functionName, array $params = [])
1604 {
1605 if (in_array($this->server, self::HOSTS_NEEDING_OAUTH)) {
1606 $functionName = str_replace('imap_', 'imap2_', $functionName);
1607 }
1608
1609 if (empty($params)) {
1610 return $functionName();
1611 }
1612
1613 return $functionName(...$params);
1614 }
1615 }
1616