PluginProbe
AcyMailing – An Ultimate Newsletter Plugin and Marketing Automation Solution for WordPress / 11.1.0
AcyMailing – An Ultimate Newsletter Plugin and Marketing Automation Solution for WordPress v11.1.0
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
← All changes | back/Helpers/MailerHelper.php +205 -105 10.10.2 → 11.1.0 View file →
@@ -19,9 +19,11 @@
19 19 class MailerHelper extends Mailer
20 20 {
21 21 const NEW_TRY_ERRORS = [1, 6];
22 22 const REGEX_URL = '#<[^>]* href[ ]*=[ ]*"(?!mailto:|\#|ymsgr:|callto:|file:|ftp:|webcal:|skype:|tel:)([^"]+)"#Ui';
23 + const AUTOLOGIN_PARAMS = 'autoSubId=%7Bsubscriber:id%7D&amp;subKey=%7Bsubscriber:autologin_token%7Curlencode%7D';
23 24
25 + // Redefine PHPMailer protected attributes for dynamic texts system
24 26 public $to = [];
25 27 public $cc = [];
26 28 public $bcc = [];
27 29 public $ReplyTo = [];
@@ -48,29 +50,35 @@
48 50
49 51 public bool $report = true;
50 52 public bool $alreadyCheckedAddresses = false;
51 53 public int $errorNumber = 0;
54 + // Can be used when our sending method is temporary unavailable to not count it as a "real" failed
52 55 public bool $failedCounting = true;
53 56
57 + // Used by external code
54 58 public bool $autoAddUser = false;
55 59 public bool $userCreationTriggers = true;
56 60
57 61 public string $reportMessage = '';
58 62 public bool $dtextsFailed = false;
59 - private array $originalDynamicUrls = [];
60 63
64 + // Should we track the sending of a message (used for welcoming message)
61 65 public bool $trackEmail = false;
62 66
67 + // Sending method used in the configuration
63 68 public string $externalMailer;
64 69
70 + // To import custom stylesheet from user
65 71 public string $stylesheet = '';
66 72 public array $settings;
67 73
74 + // Used to store special dynamic text content
68 75 public array $parameters = [];
69 76
70 77 private string $userLanguage = '';
71 78 private string $receiverEmail;
72 79
80 + // Special send statuses
73 81 private object $overrideEmailToSend;
74 82 public bool $isTest = false;
75 83 public bool $isSpamTest = false;
76 84 public bool $isForward = false;
@@ -80,10 +88,12 @@
80 88 public string $type;
81 89 public string $links_language;
82 90 public int $id = 0;
83 91 public ?object $mail = null;
92 + // Mail objects with replaceContent ran
84 93 private array $defaultMail = [];
85 94
95 + // Sending method list plugin
86 96 public array $listsIds = [];
87 97 private string $currentSendingMethod = '';
88 98 private array $currentMethodSetting = [];
89 99
@@ -96,8 +106,9 @@
96 106 public function __construct()
97 107 {
98 108 parent::__construct();
99 109
110 + // The DKIM fails when the X-Mailer is added and the user uses their own keys, it makes no sense D:
100 111 $this->XMailer = ' ';
101 112 $this->SMTPAutoTLS = false;
102 113
103 114 $this->encodingHelper = new EncodingHelper();
@@ -118,11 +129,13 @@
118 129 }
119 130
120 131 $this->setSendingMethodSetting();
121 132
133 + //Do we have a DKIM validation?
122 134 if ($this->config->get('dkim', 0) && $this->Mailer != 'elasticemail') {
123 135 $this->DKIM_domain = $this->config->get('dkim_domain');
124 136 $this->DKIM_selector = $this->config->get('dkim_selector', 'acy');
137 + //Just in case of...
125 138 if (empty($this->DKIM_selector)) $this->DKIM_selector = 'acy';
126 139 $this->DKIM_passphrase = $this->config->get('dkim_passphrase');
127 140 $this->DKIM_identity = $this->config->get('dkim_identity');
128 141 $this->DKIM_private = trim($this->config->get('dkim_private'));
@@ -128,8 +141,9 @@
128 141 $this->DKIM_private = trim($this->config->get('dkim_private'));
129 142 $this->DKIM_private_string = trim($this->config->get('dkim_private'));
130 143 }
131 144
145 + //Set the Charset, by default 'utf-8'
132 146 $this->CharSet = strtolower($this->config->get('charset'));
133 147 if (empty($this->CharSet)) {
134 148 $this->CharSet = 'utf-8';
135 149 }
@@ -135,18 +149,18 @@
135 149 }
136 150
137 151 $this->clearAll();
138 152
153 + //Set the encoding format, should be 8 bit by default.
139 154 $this->Encoding = $this->config->get('encoding_format');
140 155 if (empty($this->Encoding)) {
141 156 $this->Encoding = '8bit';
142 157 }
143 158
159 + // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- This is for big regex, the default value is 100 000.
144 160 @ini_set('pcre.backtrack_limit', 1000000);
145 161
146 162 $this->SMTPOptions = ['ssl' => ['verify_peer' => false, 'verify_peer_name' => false, 'allow_self_signed' => true]];
147 -
148 - $this->addParamInfo();
149 163 }
150 164
151 165 public function setSendingMethodSetting(): void
152 166 {
@@ -151,13 +165,14 @@
151 165 public function setSendingMethodSetting(): void
152 166 {
153 167 $externalSendingMethod = [];
154 168 acym_trigger('onAcymGetSendingMethods', [&$externalSendingMethod, true]);
155 - $externalSendingMethod = array_keys($externalSendingMethod['sendingMethods']);
169 + $externalSendingMethod = !empty($externalSendingMethod['sendingMethods']) ? array_keys($externalSendingMethod['sendingMethods']) : [];
156 170
157 171 $mailerMethodConfig = $this->getSendingMethod();
158 172 $this->currentSendingMethod = $mailerMethodConfig;
159 173
174 + // Default mailer is to use PHP's mail function
160 175 if ($mailerMethodConfig === 'google') {
161 176 $this->isSMTP();
162 177 $this->Host = 'smtp.gmail.com:465';
163 178 $this->SMTPAuth = true;
@@ -168,8 +183,9 @@
168 183 $this->setOAuth(
169 184 new OAuth(
170 185 [
171 186 'sendingMethod' => 'google',
187 + // https://developers.google.com/identity/protocols/oauth2/web-server#httprest_8
172 188 'tokenGenerationUrl' => 'https://oauth2.googleapis.com/token',
173 189 'userName' => $this->Username,
174 190 'clientId' => trim($this->getSendingMethodSettings('google_client_id')),
175 191 'clientSecret' => trim($this->getSendingMethodSettings('google_client_secret')),
@@ -196,8 +212,9 @@
196 212 $this->setOAuth(
197 213 new OAuth(
198 214 [
199 215 'sendingMethod' => 'outlook',
216 + // https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow#refresh-the-access-token
200 217 'tokenGenerationUrl' => 'https://login.microsoftonline.com/'.$tenant.'/oauth2/v2.0/token',
201 218 'userName' => $this->Username,
202 219 'clientId' => trim($this->getSendingMethodSettings('outlook_client_id')),
203 220 'clientSecret' => trim($this->getSendingMethodSettings('outlook_client_secret')),
@@ -214,8 +231,9 @@
214 231 } elseif ($mailerMethodConfig === 'smtp') {
215 232 $this->isSMTP();
216 233 $this->Host = trim($this->getSendingMethodSettings('smtp_host'));
217 234 $port = $this->getSendingMethodSettings('smtp_port');
235 + // 465 is default port for SSL
218 236 if (empty($port) && $this->getSendingMethodSettings('smtp_secured') === 'ssl') {
219 237 $port = 465;
220 238 }
221 239 if (!empty($port)) {
@@ -269,8 +287,11 @@
269 287 $this->isMail();
270 288 }
271 289 }
272 290
291 + /**
292 + * Dynamically called from PHPMailer
293 + */
273 294 protected function externalSend(string $MIMEHeader, string $MIMEBody): bool
274 295 {
275 296 $fromName = empty($this->FromName) ? $this->config->get('from_name') : $this->FromName;
276 297 $reply_to = array_shift($this->ReplyTo);
@@ -330,8 +351,9 @@
330 351 return false;
331 352 }
332 353 }
333 354
355 + //Check if there is at least one reply to otherwise add the default one.
334 356 if (empty($this->ReplyTo) && empty($this->ReplyToQueue)) {
335 357 if (!empty($this->replyemail)) {
336 358 $replyToEmail = $this->replyemail;
337 359 } elseif ($this->config->get('from_as_replyto', 1) == 1) {
@@ -350,8 +372,9 @@
350 372
351 373 $this->acymAddReplyTo($replyToEmail, $replyToName);
352 374 }
353 375
376 + // Embed images if there are images to embed
354 377 $shouldEmbed = $this->config->get('embed_images', 0);
355 378 if (intval($shouldEmbed) === 1 && $this->Mailer !== 'elasticemail') {
356 379 $this->embedImages();
357 380 }
@@ -377,8 +400,9 @@
377 400
378 401 return false;
379 402 }
380 403
404 + //Check the from address
381 405 if (empty($this->From) || !acym_isValidEmail($this->From)) {
382 406 $this->reportMessage = acym_translation('ACYM_VALID_EMAIL').' ( '.acym_translation('ACYM_FROM_EMAIL').' : '.$this->From.' ) ';
383 407 $this->errorNumber = 9;
384 408 if ($this->report) {
@@ -402,10 +426,13 @@
402 426 if ($this->config->get('save_body') === '1') {
403 427 @file_put_contents(ACYM_ROOT.'acydebug_mail.html', $this->Body);
404 428 }
405 429
430 + //We will change the encoding format in case of its needed...
431 + //We always come from utf-8 to transform to something else!
406 432 $this->Body = htmlentities($this->Body);
407 433 $this->Body = htmlspecialchars_decode($this->Body);
434 + //Fix The Bat issues for special encoding as &sigmaf; was interpreted as &sigma;f;...
408 435 $this->Body = str_replace(['&amp;', '&sigmaf;'], ['&', 'ς'], $this->Body);
409 436
410 437 if ($this->CharSet !== 'utf-8') {
411 438 $this->Body = $this->encodingHelper->change($this->Body, 'UTF-8', $this->CharSet);
@@ -411,8 +438,9 @@
411 438 $this->Body = $this->encodingHelper->change($this->Body, 'UTF-8', $this->CharSet);
412 439 $this->Subject = $this->encodingHelper->change($this->Subject, 'UTF-8', $this->CharSet);
413 440 }
414 441
442 + // These characters can break the send process, let's remove them from the subject
415 443 $this->Subject = str_replace(
416 444 ['’', '“', '”', '–'],
417 445 ["'", '"', '"', '-'],
418 446 $this->Subject
@@ -417,8 +445,9 @@
417 445 ["'", '"', '"', '-'],
418 446 $this->Subject
419 447 );
420 448
449 + // BE CAREFUL! This space is not a space, it's a ALT0160 chr(194) by char(32) which means almost &nbsp;
421 450 $this->Body = str_replace(" ", ' ', $this->Body);
422 451
423 452 $externalSending = false;
424 453
@@ -449,8 +478,9 @@
449 478 }
450 479 }
451 480
452 481
482 + //display error if bloque is displayed... for free.fr especially
453 483 if (!empty($warnings) && strpos($warnings, 'bloque')) {
454 484 $result = false;
455 485 }
456 486
@@ -470,8 +500,9 @@
470 500 $this->reportMessage .= " \n\n ".$warnings;
471 501 }
472 502 $this->errorNumber = 1;
473 503 if ($this->report) {
504 + //We display the report here... we add a link to our doc for the "could not instantiate mail function".
474 505 $this->reportMessage = str_replace(
475 506 'Could not instantiate mail function',
476 507 '<a target="_blank" href="'.ACYM_DOCUMENTATION.'faq/could-not-instantiate-mail-function">'.acym_translation('ACYM_COUND_NOT_INSTANCIATE_MAIL_FUCNTION').'</a>',
477 508 $this->reportMessage
@@ -518,8 +549,9 @@
518 549 }
519 550
520 551 public function load(int $mailId, ?object $user = null): ?object
521 552 {
553 + // If it's already loaded return the email
522 554 if (isset($this->defaultMail[$mailId])) {
523 555 return $this->defaultMail[$mailId];
524 556 }
525 557
@@ -558,8 +590,9 @@
558 590 $acymLanguages['userLanguage'] = $this->userLanguage;
559 591 $this->setFrom($this->getSendSettings('from_email'), $this->getSendSettings('from_name'));
560 592 }
561 593
594 + //We could not load the email...
562 595 if (empty($this->defaultMail[$mailId]->id)) {
563 596 unset($this->defaultMail[$mailId]);
564 597
565 598 return null;
@@ -580,8 +613,11 @@
580 613
581 614
582 615 $this->mailId = $mailId;
583 616 $this->id = $this->mailId;
617 + if (empty($this->defaultMail[$mailId]->language)) {
618 + $this->defaultMail[$mailId]->language = acym_getDefaultNewsletterLanguage();
619 + }
584 620 $this->mail = clone $this->defaultMail[$mailId];
585 621 acym_trigger('replaceContent', [&$this->defaultMail[$mailId], true]);
586 622
587 623 $this->prepareEmailContent($mailId);
@@ -591,8 +627,16 @@
591 627
592 628 return $this->defaultMail[$mailId];
593 629 }
594 630
631 + /**
632 + * @param int|string $mailId The mail Id
633 + * @param int|string|object $user Can be the user Id, an email address or the user object
634 + * @param array $options Additional options for bcc, attachments, etc...
635 + *
636 + * @return bool
637 + * @throws Exception
638 + */
595 639 public function sendOne($mailId, $user, array $options = []): bool
596 640 {
597 641 $this->clearAll();
598 642
@@ -601,9 +645,9 @@
601 645 $this->dtextsFailed = false;
602 646
603 647 if (empty($receiver->email)) {
604 648 $this->errorNumber = 4;
605 - $this->reportMessage = acym_translationSprintf('ACYM_SEND_ERROR_USER', '<b><i>'.acym_escape($user).'</i></b>');
649 + $this->reportMessage = acym_translationSprintf('ACYM_SEND_ERROR_USER', '<b><i>'.esc_html(is_object($user) ? $user->email : $user).'</i></b>');
606 650 if ($this->report) {
607 651 acym_enqueueMessage($this->reportMessage, 'error');
608 652 }
609 653
@@ -609,11 +653,13 @@
609 653
610 654 return false;
611 655 }
612 656
657 + // Can be a string for Joomla 3 and WordPress
613 658 $mailId = intval($mailId);
614 659 $receiver->id = intval($receiver->id);
615 660
661 + // Load the mail with global tags replaced
616 662 if (!$this->load($mailId, $receiver)) {
617 663 $this->reportMessage = 'Can not load the email with ID n°'.$mailId;
618 664 $this->errorNumber = 2;
619 665
@@ -629,15 +675,14 @@
629 675 $this->prepareAddresses($mailId, $receiver);
630 676 $this->prepareBcc($mailId, $options);
631 677 $this->prepareAttachments($mailId, $options);
632 678 $this->replaceDynamicContent($mailId);
633 - $this->extractOriginalDynamicUrls();
679 + $this->prepareTracking($mailId, $receiver);
634 680 if (!$this->handleDtexts($receiver)) {
635 681 $this->dtextsFailed = true;
636 682
637 683 return false;
638 684 }
639 - $this->prepareTracking($mailId, $receiver);
640 685
641 686 $status = $this->send();
642 687
643 688 $this->initStatistics($status, $receiver->id);
@@ -715,8 +760,9 @@
715 760 if ($countLinks[$url] === 1) {
716 761 continue;
717 762 }
718 763
764 + // Handle the Outlook buttons that double the link, we need to consider it as the same link that's on the main button element
719 765 $previousIsOutlook = false;
720 766 if (strpos($results[0][$key], '<v:roundrect') === 0) {
721 767 $previousLinkHandled = $results[0][$key];
722 768 if ($countLinks[$url] === 2) {
@@ -754,8 +800,9 @@
754 800
755 801 foreach ($results[1] as $i => $url) {
756 802 $urlsNotToTrack = [
757 803 'task=unsub',
804 + 'task=disableTracking',
758 805 'fonts.googleapis.com',
759 806 ];
760 807
761 808 $track = true;
@@ -766,22 +813,30 @@
766 813 break;
767 814 }
768 815 }
769 816
817 + //We don't track unsubscribe link
770 818 if (isset($urls[$results[0][$i]]) || !$track) {
771 819 continue;
772 820 }
773 821
822 + //We often need to check if the url is within the website... but we don't care about http or https
774 823 $simplifiedUrl = str_replace(['https://', 'http://', 'www.'], '', $url);
775 824 $simplifiedWebsite = str_replace(['https://', 'http://', 'www.'], '', ACYM_LIVE);
776 825 $internalUrl = strpos($simplifiedUrl, rtrim($simplifiedWebsite, '/')) === 0;
777 826
827 + // If this is an internal url
828 + //$subfolder : Record if the subfolder exists or not in which case it will be an external link
778 829 $subfolder = false;
779 830 if ($internalUrl) {
780 831 $urlWithoutBase = str_replace($simplifiedWebsite, '', $simplifiedUrl);
832 + // If there is a /, it means there could be a sub-folder
833 + //It can be separated with a ? as well like administrator?option=com_content
781 834 if (strpos($urlWithoutBase, '/') || strpos($urlWithoutBase, '?')) {
835 + // Get the supposed sub-folder name
782 836 $slashPosition = strpos($urlWithoutBase, '/');
783 837 $folderName = substr($urlWithoutBase, 0, !$slashPosition ? strpos($urlWithoutBase, '?') : $slashPosition);
838 + //There is no dot in a folder!
784 839 if (strpos($folderName, '.') === false) {
785 840 $subfolder = @is_dir(ACYM_ROOT.$folderName);
786 841 }
787 842 }
@@ -796,8 +851,9 @@
796 851 $args = [];
797 852 $args[] = empty($campaign->sending_params['utm_source']) ? 'utm_source=newsletter_'.$idToUse : 'utm_source='.urlencode($campaign->sending_params['utm_source']);
798 853 $args[] = empty($campaign->sending_params['utm_medium']) ? 'utm_medium=email' : 'utm_medium='.urlencode($campaign->sending_params['utm_medium']);
799 854 $args[] = empty($campaign->sending_params['utm_campaign']) ? 'utm_campaign='.$utmCampaign : 'utm_campaign='.urlencode($campaign->sending_params['utm_campaign']);
855 + //If we have an anchor we need to remove it and add it to the end of the url
800 856 $anchor = '';
801 857 if (strpos($url, '#') !== false) {
802 858 $anchor = substr($url, strpos($url, '#'));
803 859 $url = substr($url, 0, strpos($url, '#'));
@@ -807,62 +863,28 @@
807 863 $mytracker = $url.'&'.implode('&', $args);
808 864 } else {
809 865 $mytracker = $url.'?'.implode('&', $args);
810 866 }
867 + //We add back the anchor if we had one
811 868 $mytracker .= $anchor;
812 869 $urls[$results[0][$i]] = str_replace($results[1][$i], $mytracker, $results[0][$i]);
813 870
871 + //Set the url variable so that we can use it later on...
814 872 $url = $mytracker;
815 873 }
816 874
817 875 if (strpos($trackingSystem, 'acymailing') !== false) {
818 - $isAutologin = false;
819 - $autologinParams = 'autoSubId=%7Bsubscriber:id%7D&amp;subKey=%7Bsubscriber:autologin_token%7Curlencode%7D';
820 - if (strpos($url, $autologinParams) !== false) {
821 - $isAutologin = true;
822 - $url = str_replace(
823 - [
824 - '?'.$autologinParams.'&amp;',
825 - '?'.$autologinParams,
826 - '&amp;'.$autologinParams,
827 - ],
828 - [
829 - '?',
830 - '',
831 - '',
832 - ],
833 - $url
834 - );
876 + $isAutologin = strpos($url, self::AUTOLOGIN_PARAMS) !== false;
877 + if ($isAutologin) {
878 + $url = $this->stripAutologinParams($url);
835 879 }
836 880
837 - if (preg_match('#passw|modify|\{|%7B#i', $url)) {
881 + if (preg_match('#passw|modify#i', $url)) {
838 882 continue;
839 883 }
840 884
841 885 $urlForRegistration = $url;
842 - if (!empty($this->originalDynamicUrls[$i])) {
843 - $urlForRegistration = $this->originalDynamicUrls[$i];
844 886
845 - if (strpos($urlForRegistration, $autologinParams) !== false) {
846 - $isAutologin = true;
847 - }
848 -
849 - $urlForRegistration = str_replace(
850 - [
851 - '?'.$autologinParams.'&amp;',
852 - '?'.$autologinParams,
853 - '&amp;'.$autologinParams,
854 - ],
855 - ['?', '', ''],
856 - $urlForRegistration
857 - );
858 -
859 - if (preg_match('#[?&](idU=\d+)#', $url, $idUMatch)) {
860 - $separator = strpos($urlForRegistration, '?') === false ? '?' : '&amp;';
861 - $urlForRegistration .= $separator.$idUMatch[1];
862 - }
863 - }
864 -
865 887 if (!$fromStat) {
866 888 $mytracker = $urlClass->getUrl($urlForRegistration, $mailId, $userid, $userkey);
867 889 }
868 890
@@ -870,14 +892,10 @@
870 892 continue;
871 893 }
872 894
873 895 if ($isAutologin) {
874 - $mytracker .= strpos($mytracker, '?') === false ? '?' : '&amp;';
875 - if (!empty($this->originalDynamicUrls[$i]) && preg_match('#autoSubId=[^&"]+&amp;subKey=[^&"]+#', $url, $resolvedAutologinMatch)) {
876 - $mytracker .= $resolvedAutologinMatch[0];
877 - } else {
878 - $mytracker .= $autologinParams;
879 - }
896 + $separator = strpos($mytracker, '?') === false ? '?' : '&amp;';
897 + $mytracker .= $separator.self::AUTOLOGIN_PARAMS;
880 898 }
881 899
882 900 $urls[$results[0][$i]] = str_replace($results[1][$i], $mytracker, $results[0][$i]);
883 901 }
@@ -887,12 +905,17 @@
887 905 }
888 906
889 907 public function textVersion(string $html, bool $fullConvert = true): string
890 908 {
909 + //Replace relative links into absolute before replacing the text version so that we keep correct urls
891 910 $html = acym_absoluteURL($html);
892 911
912 + //If we come from a text version, we don't want to replace the spaces.
913 + //We will only do that if we come from an HTML Version to avoid breaking the user code
893 914 if ($fullConvert) {
915 + //As in HTML multiple spaces are interpreted as only one space, we remove the multiple spaces for the text version
894 916 $html = preg_replace('# +#', ' ', $html);
917 + //Same thing, return chars don't exist in html so we can simply remove them, neither \t
895 918 $html = str_replace(["\n", "\r", "\t"], '', $html);
896 919 }
897 920
898 921 $removepictureslinks = "#< *a[^>]*> *< *img[^>]*> *< *\/ *a *>#isU";
@@ -920,15 +943,20 @@
920 943 ['', '', '', '', "\n\n", "\n* ", "\n", "\n", '${2} ( ${1} )'],
921 944 $html
922 945 );
923 946
947 + //The striptags function may not do the job properly in some cases...
924 948 $text = preg_replace('#(&lt;|&\#60;)([^ \n\r\t])#i', '&lt; ${2}', $text);
925 949
926 - $text = str_replace([" ", "&nbsp;"], ' ', strip_tags($text));
950 + //BE CAREFUL!!!! This space is not a space, it's a ALT0160!! which means &nbsp;
951 + $text = str_replace([" ", "&nbsp;"], ' ', acym_stripTags($text));
952 + //BE CAREFUL!! That is magic code :) :) :)
927 953
954 + //@ is added on the call of html_entity_decode because on PHP 4, warnings are displayed using this function with utf-8 characters.
928 955 $text = trim(@html_entity_decode($text, ENT_QUOTES, 'UTF-8'));
929 956
930 957 if ($fullConvert) {
958 + //We do that one more time as some extra spaces may have appeared
931 959 $text = preg_replace('# +#', ' ', $text);
932 960 $text = preg_replace('#\n *\n\s+#', "\n\n", $text);
933 961 }
934 962
@@ -934,8 +962,11 @@
934 962
935 963 return $text;
936 964 }
937 965
966 + /**
967 + * @throws Exception
968 + */
938 969 protected function embedImages(): bool
939 970 {
940 971 preg_match_all('/(src|background)=[\'|"]([^"\']*)[\'|"]/Ui', $this->Body, $images);
941 972
@@ -958,12 +989,15 @@
958 989
959 990 $allImages = [];
960 991
961 992 foreach ($images[2] as $i => $url) {
993 + //We don't add twice the same images otherwise there is a bug
994 + //and the picture is really attached but not in hidden base64
962 995 if (isset($allImages[$url])) {
963 996 continue;
964 997 }
965 998
999 + // Don't embed images with a controller, most likely the stats picture
966 1000 if (strpos($url, 'ctrl=') !== false) {
967 1001 continue;
968 1002 }
969 1003
@@ -968,25 +1002,30 @@
968 1002 }
969 1003
970 1004 $allImages[$url] = 1;
971 1005
1006 + // We convert the url into local directory
972 1007 $path = acym_internalUrlToPath($url);
973 1008 $path = $this->removeAdditionalParams($path);
974 1009
1010 + // The file has to be an image located in the website's folder
1011 + $path = $this->getEmbeddableImagePath($path);
1012 + if (empty($path)) {
1013 + continue;
1014 + }
1015 +
1016 + $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
1017 + if (!isset($mimetypes[$ext])) {
1018 + continue;
1019 + }
1020 +
975 1021 $filename = str_replace(['%', ' '], '_', basename($url));
976 1022 $filename = $this->removeAdditionalParams($filename);
977 1023
978 1024 $md5 = md5($filename);
979 1025 $cid = 'cid:'.$md5;
980 - $fileParts = explode(".", $filename);
981 - if (empty($fileParts[1])) {
982 - continue;
983 - }
984 - $ext = strtolower($fileParts[1]);
985 - if (!isset($mimetypes[$ext])) {
986 - continue;
987 - }
988 1026
1027 + // We only change the url if we were able to embed the image.
989 1028 if ($this->addEmbeddedImage($path, $md5, $filename, 'base64', $mimetypes[$ext])) {
990 1029 $this->Body = preg_replace('/'.preg_quote($images[0][$i], '/').'/Ui', $images[1][$i].'="'.$cid.'"', $this->Body);
991 1030 } else {
992 1031 $embedSuccess = false;
@@ -995,32 +1034,42 @@
995 1034
996 1035 return $embedSuccess;
997 1036 }
998 1037
999 - public function cleanText(?string $text): string
1038 + private function getEmbeddableImagePath(string $path): string
1000 1039 {
1001 - return trim(preg_replace('/(%0A|%0D|\n+|\r+)/i', '', (string)$text));
1002 - }
1040 + // No url and no stream wrapper, we only read local files
1041 + if (empty($path) || preg_match('#^[a-z][a-z\d+.-]*://#i', $path) === 1) {
1042 + return '';
1043 + }
1003 1044
1004 - public function addParamInfo(): void
1005 - {
1006 - if (!empty($_SERVER)) {
1007 - $serverinfo = [];
1008 - foreach ($_SERVER as $oneKey => $oneInfo) {
1009 - $serverinfo[] = $oneKey.' => '.strip_tags(print_r($oneInfo, true));
1010 - }
1011 - $this->addParam('serverinfo', implode('<br />', $serverinfo));
1045 + // The file must be inside the website's folder
1046 + $root = rtrim(ACYM_ROOT, DS).DS;
1047 + if (strpos($path, $root) !== 0 || preg_match('#(^|[/\\\\])\.\.([/\\\\]|$)#', substr($path, strlen($root))) === 1) {
1048 + return '';
1012 1049 }
1013 1050
1014 - if (!empty($_REQUEST)) {
1015 - $postinfo = [];
1016 - foreach ($_REQUEST as $oneKey => $oneInfo) {
1017 - $postinfo[] = $oneKey.' => '.strip_tags(print_r($oneInfo, true));
1018 - }
1019 - $this->addParam('postinfo', implode('<br />', $postinfo));
1051 + if (!is_file($path) || !is_readable($path)) {
1052 + return '';
1020 1053 }
1054 +
1055 + // An image extension doesn't tell what the file actually contains, so we check the content itself
1056 + $imageInfo = @getimagesize($path);
1057 + if (empty($imageInfo['mime']) || strpos($imageInfo['mime'], 'image/') !== 0) {
1058 + return '';
1059 + }
1060 +
1061 + return $path;
1021 1062 }
1022 1063
1064 + public function cleanText(?string $text): string
1065 + {
1066 + return trim(preg_replace('/(%0A|%0D|\n+|\r+)/i', '', (string)$text));
1067 + }
1068 +
1069 + /**
1070 + * Adds shortcodes available in the email content
1071 + */
1023 1072 public function addParam(string $name, $value): void
1024 1073 {
1025 1074 $tagName = '{'.$name.'}';
1026 1075 $this->parameters[$tagName] = $value;
@@ -1032,12 +1081,14 @@
1032 1081
1033 1082 $overrideClass = new OverrideClass();
1034 1083 $override = $overrideClass->getMailByBaseContent($options['subject'], $options['message']);
1035 1084
1085 + // We let the CMS send the email
1036 1086 if (empty($override) && !$overrideAllEmails) {
1037 1087 return false;
1038 1088 }
1039 1089
1090 + // AcyMailing needs to send the email
1040 1091 $this->autoAddUser = true;
1041 1092
1042 1093 if (empty($override)) {
1043 1094 $this->trackEmail = false;
@@ -1059,11 +1110,13 @@
1059 1110 }
1060 1111
1061 1112 $errorCode = 0;
1062 1113 $errorMessage = 0;
1114 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fsockopen -- Connectivity test for SMTP ports.
1063 1115 $fp = @fsockopen($targetServer, $port, $errorCode, $errorMessage, 5);
1064 1116
1065 1117 if (is_resource($fp)) {
1118 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the socket resource opened above, not a filesystem handle.
1066 1119 fclose($fp);
1067 1120
1068 1121 return true;
1069 1122 } else {
@@ -1168,8 +1221,13 @@
1168 1221
1169 1222 return true;
1170 1223 }
1171 1224
1225 + /**
1226 + * @param mixed $user
1227 + *
1228 + * @throws Exception
1229 + */
1172 1230 private function loadUser($user, bool $force = false): object
1173 1231 {
1174 1232 if (is_numeric($user)) {
1175 1233 $receiver = $this->userClass->getOneById($user);
@@ -1181,12 +1239,12 @@
1181 1239 $receiver = $this->userClass->getOneByEmail($user, $force || $this->autoAddUser);
1182 1240 }
1183 1241
1184 1242 if (empty($receiver)) {
1185 - throw new Exception(acym_translation('ACYM_USER_NOT_FOUND'));
1243 + throw new Exception(esc_attr(acym_translation('ACYM_USER_NOT_FOUND')));
1186 1244 }
1187 1245
1188 - $this->userLanguage = empty($receiver->language) ? acym_getLanguageTag() : $receiver->language;
1246 + $this->userLanguage = empty($receiver->language) ? acym_getDefaultNewsletterLanguage() : $receiver->language;
1189 1247 $this->receiverEmail = (string)($receiver->email ?? '');
1190 1248
1191 1249 return $receiver;
1192 1250 }
@@ -1205,17 +1263,19 @@
1205 1263 if ($this->isTest && !empty($options['testNote'])) {
1206 1264 $this->Body = '<div style="text-align: center; padding: 25px; font-family: Poppins; font-size: 20px">'.$options['testNote'].'</div>'.$this->Body;
1207 1265 }
1208 1266
1267 + //We add the intro text at the top of the body
1209 1268 if (!empty($this->introtext)) {
1210 1269 $this->Body = $this->introtext.$this->Body;
1211 1270 }
1212 1271
1213 1272 if (!empty($this->defaultMail[$mailId]->preheader)) {
1214 - $preHeader = '<!--[if !mso 9]><!--><div style="visibility:hidden;mso-hide:all;font-size:0;color:transparent;height:0;line-height:0;max-height:0;max-width:0;opacity:0;overflow:hidden;">';
1273 + $preHeader = '<!--[if !mso 9]><!--><div style="visibility:hidden;mso-hide:all;font-size:0;height:0;line-height:0;max-height:0;max-width:0;opacity:0;overflow:hidden;">';
1215 1274 $preHeader .= $this->defaultMail[$mailId]->preheader;
1216 1275 $preHeader .= '</div><!--<![endif]-->';
1217 1276
1277 + //We want to insert the preview at the start of the body so we match the start of the mail
1218 1278 preg_match('#(<(.*)<body(.*)>)#Uis', $this->Body, $matches);
1219 1279 if (empty($matches) || empty($matches[1])) {
1220 1280 $this->Body = $preHeader.$this->Body;
1221 1281 } else {
@@ -1231,9 +1291,10 @@
1231 1291 }
1232 1292
1233 1293 private function prepareHeaders(int $mailId, int $receiverId, array $options): void
1234 1294 {
1235 - $subject = base64_encode(rand(0, 9999999)).'AC'.$receiverId.'Y'.$this->defaultMail[$mailId]->id.'BA'.base64_encode(time().rand(0, 99999));
1295 + // Specify a messageID which will be kept by most mail clients and in the feedback loop
1296 + $subject = base64_encode(acym_rand(0, 9999999)).'AC'.$receiverId.'Y'.$this->defaultMail[$mailId]->id.'BA'.base64_encode(time().acym_rand(0, 99999));
1236 1297 $this->MessageID = '<'.preg_replace('|[^a-z0-9+_]|i', '', $subject).'@'.$this->serverHostname().'>';
1237 1298 $this->addCustomHeader('Feedback-ID', $this->defaultMail[$mailId]->id.':'.$receiverId.':'.$this->defaultMail[$mailId]->type.':'.base64_encode(ACYM_ROOT));
1238 1299
1239 1300 if (!empty($this->defaultMail[$mailId]->headers)) {
@@ -1251,8 +1312,9 @@
1251 1312 {
1252 1313 $receiverName = '';
1253 1314 if ($this->config->get('add_names', true)) {
1254 1315 $receiverName = $this->cleanText($receiver->name);
1316 + //We do not set a name if the name is the same as the email address, it prevents the email from being sent with some mail servers
1255 1317 if ($receiverName == $this->cleanText($receiver->email)) {
1256 1318 $receiverName = '';
1257 1319 }
1258 1320 }
@@ -1260,8 +1322,9 @@
1260 1322
1261 1323 $this->setFrom($this->getSendSettings('from_email', $mailId), $this->getSendSettings('from_name', $mailId));
1262 1324 $this->acymAddReplyTo($this->defaultMail[$mailId]->reply_to_email ?? '', $this->defaultMail[$mailId]->reply_to_name ?? '');
1263 1325
1326 + // This is the bounce email address
1264 1327 if (!empty($this->defaultMail[$mailId]->bounce_email)) {
1265 1328 $this->Sender = $this->cleanText($this->defaultMail[$mailId]->bounce_email);
1266 1329 } else {
1267 1330 $this->Sender = $this->cleanText($this->config->get('bounce_email'));
@@ -1267,8 +1330,11 @@
1267 1330 $this->Sender = $this->cleanText($this->config->get('bounce_email'));
1268 1331 }
1269 1332 }
1270 1333
1334 + /**
1335 + * @throws Exception
1336 + */
1271 1337 private function prepareBcc(int $mailId, array $options): void
1272 1338 {
1273 1339 $bccAddresses = [];
1274 1340
@@ -1282,12 +1348,20 @@
1282 1348 }
1283 1349
1284 1350 if (!empty($bccAddresses)) {
1285 1351 $bccAddresses = array_filter($bccAddresses);
1352 +
1353 + // loadUser() overwrites the current receiver, keep the real one
1354 + $receiverEmail = $this->receiverEmail;
1355 + $userLanguage = $this->userLanguage;
1356 +
1286 1357 foreach ($bccAddresses as $oneBccAddress) {
1287 1358 $this->loadUser($oneBccAddress, true);
1288 1359 $this->addBCC($oneBccAddress);
1289 1360 }
1361 +
1362 + $this->receiverEmail = $receiverEmail;
1363 + $this->userLanguage = $userLanguage;
1290 1364 }
1291 1365 }
1292 1366
1293 1367 private function prepareAttachments(int $mailId, array $options): void
@@ -1328,8 +1402,9 @@
1328 1402 private function replaceDynamicContent(int $mailId): void
1329 1403 {
1330 1404 $this->replaceParams();
1331 1405
1406 + // Left side are mail columns on which we replace the short codes
1332 1407 $this->body = &$this->Body;
1333 1408 $this->altbody = &$this->AltBody;
1334 1409 $this->subject = &$this->Subject;
1335 1410 $this->from = &$this->From;
@@ -1344,27 +1419,15 @@
1344 1419 $this->stylesheet = &$this->stylesheet;
1345 1420 $this->links_language = $this->defaultMail[$mailId]->links_language;
1346 1421 }
1347 1422
1348 - private function extractOriginalDynamicUrls(): void
1423 + private function stripAutologinParams(string $url): string
1349 1424 {
1350 - $this->originalDynamicUrls = [];
1351 - $absoluteBody = acym_absoluteURL($this->body);
1352 - preg_match_all(
1353 - self::REGEX_URL,
1354 - $absoluteBody,
1355 - $results
1425 + return str_replace(
1426 + ['?'.self::AUTOLOGIN_PARAMS.'&amp;', '?'.self::AUTOLOGIN_PARAMS, '&amp;'.self::AUTOLOGIN_PARAMS],
1427 + ['?', '', ''],
1428 + $url
1356 1429 );
1357 -
1358 - if (empty($results[1])) {
1359 - return;
1360 - }
1361 -
1362 - foreach ($results[1] as $i => $url) {
1363 - if (preg_match('#\{|%7B#i', $url)) {
1364 - $this->originalDynamicUrls[$i] = $url;
1365 - }
1366 - }
1367 1430 }
1368 1431
1369 1432 private function prepareTracking(int $mailId, object $receiver): void
1370 1433 {
@@ -1382,8 +1445,9 @@
1382 1445 private function handleDtexts(object $receiver): bool
1383 1446 {
1384 1447 $this->replaceParams();
1385 1448
1449 + // Sending a spam-test, use the current user instead
1386 1450 if (strpos($receiver->email, '@mt.acyba.com') !== false) {
1387 1451 $currentUser = $this->userClass->getOneByEmail(acym_currentUserEmail());
1388 1452 if (empty($currentUser)) {
1389 1453 $currentUser = $receiver;
@@ -1406,8 +1470,9 @@
1406 1470 if (!empty($acymLanguages['userLanguage'])) {
1407 1471 unset($acymLanguages['userLanguage']);
1408 1472 }
1409 1473
1474 + // A user based dtext may have inserted HTML content, we need to re-inline the CSS to it
1410 1475 foreach ($result as $oneResult) {
1411 1476 if (!empty($oneResult['emogrifier'])) {
1412 1477 $this->prepareEmailContent();
1413 1478 break;
@@ -1491,8 +1556,9 @@
1491 1556 }
1492 1557
1493 1558 $pluginHelper = new PluginHelper();
1494 1559
1560 + //We create an extra tag which contains all possible parameters...
1495 1561 $this->generateAllParams();
1496 1562
1497 1563 $vars = [
1498 1564 'Subject',
@@ -1517,12 +1583,16 @@
1517 1583 }
1518 1584 }
1519 1585 }
1520 1586
1587 + /**
1588 + * Create a new parameter called {alltags} which will include all the others
1589 + */
1521 1590 private function generateAllParams(): void
1522 1591 {
1523 1592 $result = '<table style="border:1px solid;border-collapse:collapse;" border="1" cellpadding="10"><tr><td>Tag</td><td>Value</td></tr>';
1524 1593 foreach ($this->parameters as $name => $value) {
1594 + //Just in case of...
1525 1595 if (!is_string($value)) continue;
1526 1596
1527 1597 $result .= '<tr><td>'.trim($name, '{}').'</td><td>'.$value.'</td></tr>';
1528 1598 }
@@ -1531,11 +1601,13 @@
1531 1601 }
1532 1602
1533 1603 private function sendOverride(object $override, array $options): bool
1534 1604 {
1605 + // 2 - Prepare the email and params
1535 1606 for ($i = 1; $i < count($override->parameters); $i++) {
1536 1607 $oneParam = $override->parameters[$i];
1537 1608
1609 + // Joomla emails have links as text, convert them
1538 1610 $unmodified = $oneParam;
1539 1611 $oneParam = preg_replace(
1540 1612 '/(http|https):\/\/(.*)/',
1541 1613 '<a href="$1://$2" target="_blank">$1://$2</a>',
@@ -1550,11 +1622,13 @@
1550 1622 }
1551 1623
1552 1624 $this->addParam('subject', $options['subject']);
1553 1625
1626 + // 3 - Send the email
1554 1627 $this->overrideEmailToSend = $override;
1555 1628 $statusSend = $this->sendOne($override->id, $options['to'], $options);
1556 1629 if (!$statusSend && !empty($this->reportMessage)) {
1630 + // Something went wrong when trying to send the override, log the information in the cron logs file
1557 1631 $cronHelper = new CronHelper();
1558 1632 $cronHelper->saveReport([$this->reportMessage]);
1559 1633 }
1560 1634
@@ -1571,9 +1645,9 @@
1571 1645 if (!empty($mailId) && !empty($this->defaultMail[$mailId]->$mailType)) {
1572 1646 return $this->defaultMail[$mailId]->$mailType;
1573 1647 }
1574 1648
1575 - $lang = empty($this->userLanguage) ? acym_getLanguageTag() : $this->userLanguage;
1649 + $lang = empty($this->userLanguage) ? acym_getDefaultNewsletterLanguage() : $this->userLanguage;
1576 1650 $setting = $this->config->get($type);
1577 1651 $translation = $this->config->get('sender_info_translation');
1578 1652
1579 1653 if (!empty($translation)) {
@@ -1606,15 +1680,18 @@
1606 1680 if (empty($mail->body)) {
1607 1681 return;
1608 1682 }
1609 1683
1610 - global $emogrifiedMediaCSS;
1611 - $emogrifiedMediaCSS = '';
1684 + global $acymailingEmogrifiedMediaCSS;
1685 + $acymailingEmogrifiedMediaCSS = '';
1612 1686
1613 1687 $style = $this->getEmailStylesheet($mail);
1688 + // Inline styles for mail client compatibility: https://www.caniemail.com/features/html-style/
1689 + // It also deletes the <style> tags from the body
1614 1690 $cssInliner = CssInliner::fromHtml($mail->body)->inlineCss(implode('', $style));
1615 1691 $domDocument = $cssInliner->getDomDocument();
1616 1692
1693 + // Remove the TinyMCE ids and the trailing ; in styles
1617 1694 $mail->body = preg_replace(
1618 1695 [
1619 1696 '# id="mce_\d+"#Ui',
1620 1697 '#(style="[^"]+);"#Ui',
@@ -1627,10 +1704,12 @@
1627 1704 ->removeRedundantClassesAfterCssInlined($cssInliner)
1628 1705 ->renderBodyContent()
1629 1706 );
1630 1707
1708 + // This character is added in the title and text blocks to prevent TinyMCE from adding an extra space when the block is empty
1631 1709 $mail->body = str_replace('&zwj;', '', $mail->body);
1632 1710
1711 + // Remove the extra spaces in the style attributes
1633 1712 $mail->body = preg_replace_callback(
1634 1713 '#style="([^"]+)"#Ui',
1635 1714 function ($matches) {
1636 1715 return 'style="'.str_replace(['; ', ': '], [';', ':'], $matches[1]).'"';
@@ -1649,10 +1728,13 @@
1649 1728 $finalContent .= '<meta http-equiv="Content-Type" content="text/html; charset='.strtolower($this->config->get('charset')).'" />'."\n";
1650 1729 $finalContent .= '<meta name="viewport" content="width=device-width, initial-scale=1.0" />'."\n";
1651 1730 $finalContent .= '<title>'.$mail->subject.'</title>'."\n";
1652 1731
1653 - global $emogrifiedMediaCSS;
1654 - $finalContent .= '<style>'.$emogrifiedMediaCSS.'</style>';
1732 + //TODO maybe insert this CSS to a second <head> tag?
1733 + // Source: https://designmodo.com/html-css-emails/#:~:text=words%20of%20wisdom-,Internal%C2%A0CSS,-Undoubtedly%2C%20one%20of
1734 + // Apparently only for Yahoo on Android: https://www.caniemail.com/features/html-style/
1735 + global $acymailingEmogrifiedMediaCSS;
1736 + $finalContent .= '<style>'.$acymailingEmogrifiedMediaCSS.'</style>';
1655 1737 $finalContent .= '<!--[if mso]><style type="text/css">#acym__wysid__template center > table { width: 580px; }</style><![endif]-->';
1656 1738 $finalContent .= '<!--[if !mso]><!--><style>#acym__wysid__template center > table { width: 100%; }</style><!--<![endif]-->';
1657 1739
1658 1740 if (!empty($mail->headers)) {
@@ -1679,12 +1761,14 @@
1679 1761 private function getEmailStylesheet(object $mail): array
1680 1762 {
1681 1763 $style = [];
1682 1764
1765 + // If this is a drag and drop mail we add foundation css for email
1683 1766 if (strpos($mail->body, 'acym__wysid__template') !== false) {
1684 1767 static $foundationCSS = null;
1685 1768 if (empty($foundationCSS)) {
1686 1769 $foundationCSS = acym_fileGetContent(ACYM_MEDIA.'css'.DS.'libraries'.DS.'foundation_email.min.css');
1770 + // Remove the #acym__wysid__template prefix, not needed in sent emails
1687 1771 $foundationCSS = str_replace('#acym__wysid__template ', '', $foundationCSS);
1688 1772 }
1689 1773 $style['foundation'] = $foundationCSS;
1690 1774 }
@@ -1726,8 +1810,9 @@
1726 1810 }
1727 1811
1728 1812 $statusSend = $this->sendOne($this->overrideEmailToSend->id, $options['to'], $options);
1729 1813 if (!$statusSend && !empty($this->reportMessage)) {
1814 + // Something went wrong when trying to send the override, log the information in the cron logs file
1730 1815 $cronHelper = new CronHelper();
1731 1816 $cronHelper->addMessage($this->reportMessage);
1732 1817 $cronHelper->saveReport();
1733 1818 }
@@ -1734,9 +1819,17 @@
1734 1819
1735 1820 return $statusSend;
1736 1821 }
1737 1822
1823 + /* * * * * * * * * * * * * * * * *
1824 + * *
1825 + * Override PHPMailer's methods *
1826 + * *
1827 + * * * * * * * * * * * * * * * * */
1738 1828
1829 + /**
1830 + * @throws Exception
1831 + */
1739 1832 private function acymAddReplyTo(string $email, string $name): void
1740 1833 {
1741 1834 if (empty($email)) {
1742 1835 return;
@@ -1745,8 +1838,9 @@
1745 1838 $replyToName = $this->config->get('add_names', true) ? $this->cleanText(trim($name)) : '';
1746 1839 $replyToEmail = trim($email);
1747 1840
1748 1841 if (substr_count($replyToEmail, '@') > 1) {
1842 + //We have more than one reply to...
1749 1843 $replyToEmailArray = explode(';', str_replace([';', ','], ';', $replyToEmail));
1750 1844 $replyToNameArray = explode(';', str_replace([';', ','], ';', $replyToName));
1751 1845 foreach ($replyToEmailArray as $i => $oneReplyTo) {
1752 1846 $this->addReplyTo($this->cleanText($oneReplyTo), @$replyToNameArray[$i]);
@@ -1765,8 +1859,13 @@
1765 1859 $this->FromName = $this->cleanText($name);
1766 1860 }
1767 1861 }
1768 1862
1863 + /**
1864 + * Outputs debugging info via user-defined method
1865 + *
1866 + * @param string $str
1867 + */
1769 1868 protected function edebug($str)
1770 1869 {
1771 1870 if (strpos($this->ErrorInfo, $str) === false) {
1772 1871 $this->ErrorInfo .= ' '.$str;
@@ -1776,8 +1875,9 @@
1776 1875 public function getMailMIME()
1777 1876 {
1778 1877 $result = parent::getMailMIME();
1779 1878
1879 + //Added by Adrien on 11.02.2011 and then on 06 April 2011 otherwise we have 3 return char on phpMail or other functions
1780 1880 $result = rtrim($result, static::$LE);
1781 1881
1782 1882 if ($this->Mailer != 'mail') {
1783 1883 $result .= static::$LE.static::$LE;