| @@ -153,11 +153,12 @@ | ||
| 153 | 153 | } |
| 154 | 154 | |
| 155 | 155 | $query .= ' GROUP BY campaign.id'; |
| 156 | 156 | |
| 157 | - if (!empty($settings['ordering']) && !empty($settings['ordering_sort_order'])) { | |
| 157 | + if (!empty($settings['ordering'])) { | |
| 158 | 158 | $table = in_array($settings['ordering'], ['name', 'creation_date']) ? 'mail' : 'campaign'; |
| 159 | - $query .= ' ORDER BY '.$table.'.'.acym_secureDBColumn($settings['ordering']).' '.acym_secureDBColumn(strtoupper($settings['ordering_sort_order'])); | |
| 159 | + $order = isset($settings['ordering_sort_order']) && strtolower($settings['ordering_sort_order']) === 'asc' ? 'ASC' : 'DESC'; | |
| 160 | + $query .= ' ORDER BY '.$table.'.'.acym_secureDBColumn($settings['ordering']).' '.acym_secureDBColumn($order); | |
| 160 | 161 | } |
| 161 | 162 | |
| 162 | 163 | if (empty($settings['offset']) || $settings['offset'] < 0) { |
| 163 | 164 | $settings['offset'] = 0; |
| @@ -254,12 +255,19 @@ | ||
| 254 | 255 | $element->open = 0; |
| 255 | 256 | $element->click = 0; |
| 256 | 257 | |
| 257 | 258 | if (!empty($element->subscribers)) { |
| 258 | - $element->open = number_format($element->open_unique / $element->subscribers * 100, 2); | |
| 259 | + // In anonymous mode opens/clicks are counted globally (totals, not unique subscribers), so a percentage of recipients is meaningless: expose the average number of opens/clicks per email sent instead (e.g. 3.25), rendered as "3.25×" in the view | |
| 260 | + $anonymousStats = !empty($this->config->get('anonymous_stats', 0)); | |
| 261 | + $clicksNb = $urlClickClass->getNumberUsersClicked([$element->mail_id], $isAbTest); | |
| 259 | 262 | |
| 260 | - $clicksNb = $urlClickClass->getNumberUsersClicked([$element->mail_id], $isAbTest); | |
| 261 | - $element->click = number_format($clicksNb / $element->subscribers * 100, 2); | |
| 263 | + if ($anonymousStats) { | |
| 264 | + $element->open = number_format($element->open_unique / $element->subscribers, 2); | |
| 265 | + $element->click = number_format($clicksNb / $element->subscribers, 2); | |
| 266 | + } else { | |
| 267 | + $element->open = number_format($element->open_unique / $element->subscribers * 100, 2); | |
| 268 | + $element->click = number_format($clicksNb / $element->subscribers * 100, 2); | |
| 269 | + } | |
| 262 | 270 | } |
| 263 | 271 | } |
| 264 | 272 | |
| 265 | 273 | public function getTrackingSales(object $element): void |
| @@ -304,12 +312,21 @@ | ||
| 304 | 312 | $element->subscribers += $mailsStat->sent; |
| 305 | 313 | } |
| 306 | 314 | |
| 307 | 315 | if (!empty($element->subscribers)) { |
| 308 | - $element->open = number_format($element->open / $element->subscribers * 100, 2); | |
| 309 | - $element->click = number_format($element->click / $element->subscribers * 100, 2); | |
| 316 | + // In anonymous mode opens/clicks are counted globally (totals, not unique subscribers), so expose the average number of opens/clicks per email sent (e.g. 3.25) instead of a percentage, rendered as "3.25×" in the view | |
| 317 | + $anonymousStats = !empty($this->config->get('anonymous_stats', 0)); | |
| 318 | + | |
| 319 | + if ($anonymousStats) { | |
| 320 | + $element->open = number_format($element->open / $element->subscribers, 2); | |
| 321 | + $element->click = number_format($element->click / $element->subscribers, 2); | |
| 322 | + } else { | |
| 323 | + $element->open = number_format($element->open / $element->subscribers * 100, 2); | |
| 324 | + $element->click = number_format($element->click / $element->subscribers * 100, 2); | |
| 325 | + } | |
| 310 | 326 | } |
| 311 | 327 | |
| 328 | + //Tracking sales | |
| 312 | 329 | if (!acym_isTrackingSalesActive()) { |
| 313 | 330 | return; |
| 314 | 331 | } |
| 315 | 332 | |
| @@ -334,8 +351,19 @@ | ||
| 334 | 351 | |
| 335 | 352 | return empty($campaign) ? null : $this->decode($campaign); |
| 336 | 353 | } |
| 337 | 354 | |
| 355 | + public function getOneByMailId(int $mailId): ?object | |
| 356 | + { | |
| 357 | + $campaign = acym_loadObject( | |
| 358 | + 'SELECT * | |
| 359 | + FROM #__acym_campaign | |
| 360 | + WHERE `mail_id` = '.intval($mailId) | |
| 361 | + ); | |
| 362 | + | |
| 363 | + return empty($campaign) ? null : $campaign; | |
| 364 | + } | |
| 365 | + | |
| 338 | 366 | public function getOneByIdWithMail(int $id): ?object |
| 339 | 367 | { |
| 340 | 368 | $campaign = acym_loadObject( |
| 341 | 369 | 'SELECT campaign.*, mail.name, mail.subject, mail.body, mail.from_name, mail.from_email, mail.reply_to_name, mail.reply_to_email, mail.bcc, mail.links_language, mail.tracking, mail.translation, mail.bounce_email |
| @@ -368,11 +396,22 @@ | ||
| 368 | 396 | |
| 369 | 397 | acym_arrayToInteger($listsIds); |
| 370 | 398 | if (empty($listsIds)) return false; |
| 371 | 399 | |
| 400 | + $listsIds = array_unique($listsIds); | |
| 401 | + | |
| 402 | + $existingListsIds = acym_loadResultArray('SELECT `id` FROM #__acym_list WHERE `id` IN ('.implode(',', $listsIds).')'); | |
| 403 | + acym_arrayToInteger($existingListsIds); | |
| 404 | + | |
| 405 | + $unknownListsIds = array_diff($listsIds, $existingListsIds); | |
| 406 | + if (!empty($unknownListsIds)) { | |
| 407 | + $this->errors[] = acym_translationSprintf('ACYM_LIST_NOT_FOUND', implode(', ', $unknownListsIds)); | |
| 408 | + } | |
| 409 | + | |
| 410 | + if (empty($existingListsIds)) return false; | |
| 411 | + | |
| 372 | 412 | $values = []; |
| 373 | - $listsIds = array_unique($listsIds); | |
| 374 | - foreach ($listsIds as $listId) { | |
| 413 | + foreach ($existingListsIds as $listId) { | |
| 375 | 414 | $values[] = '('.intval($mailId).', '.intval($listId).')'; |
| 376 | 415 | } |
| 377 | 416 | |
| 378 | 417 | if (!empty($values)) { |
| @@ -394,9 +433,9 @@ | ||
| 394 | 433 | if (in_array($oneAttribute, $this->jsonColumns)) { |
| 395 | 434 | $campaign->$oneAttribute = json_encode(empty($value) ? [] : $value); |
| 396 | 435 | } else { |
| 397 | 436 | if (empty($value)) continue; |
| 398 | - $campaign->$oneAttribute = strip_tags($value); | |
| 437 | + $campaign->$oneAttribute = acym_stripTags($value); | |
| 399 | 438 | } |
| 400 | 439 | } |
| 401 | 440 | |
| 402 | 441 | $campaignID = parent::save($campaign); |
| @@ -431,13 +470,13 @@ | ||
| 431 | 470 | if (empty($userId)) { |
| 432 | 471 | return false; |
| 433 | 472 | } |
| 434 | 473 | |
| 435 | - if (acym_isAdmin()) { | |
| 474 | + if (acym_isAdmin() && acym_isAllowed('campaigns')) { | |
| 436 | 475 | return true; |
| 437 | 476 | } |
| 438 | 477 | |
| 439 | - $query = 'SELECT COUNT(*) FROM #__acym_campaign AS campaign | |
| 478 | + $query = 'SELECT COUNT(*) FROM #__acym_campaign AS campaign | |
| 440 | 479 | JOIN #__acym_mail AS mail ON campaign.mail_id = mail.id '; |
| 441 | 480 | |
| 442 | 481 | $condition = 'mail.creator_id = '.intval($userId); |
| 443 | 482 | if ($this->config->get('front_campaigns_filter', 'own') === 'allowed') { |
| @@ -453,8 +492,12 @@ | ||
| 453 | 492 | |
| 454 | 493 | return acym_loadResult($query) > 0; |
| 455 | 494 | } |
| 456 | 495 | |
| 496 | + /** | |
| 497 | + * Delete a campaign. Needs to delete the tag associated with the campaign and the mail attached to the campaign. | |
| 498 | + * Deleting a mail of a campaign needs to clean the association table mail_has_list and its tags | |
| 499 | + */ | |
| 457 | 500 | public function delete(array $elements): int |
| 458 | 501 | { |
| 459 | 502 | acym_arrayToInteger($elements); |
| 460 | 503 | $this->onlyManageableCampaigns($elements); |
| @@ -556,8 +599,9 @@ | ||
| 556 | 599 | } |
| 557 | 600 | |
| 558 | 601 | public function send(int $campaignID, int $result = 0, bool $abTestFinal = false) |
| 559 | 602 | { |
| 603 | + // Make sure the email we're trying to send exists | |
| 560 | 604 | $campaign = $this->getOneById($campaignID); |
| 561 | 605 | |
| 562 | 606 | if (empty($campaign->mail_id)) { |
| 563 | 607 | $this->errors[] = acym_translation('ACYM_EMAIL_NOT_FOUND'); |
| @@ -571,17 +615,20 @@ | ||
| 571 | 615 | if (empty($filters)) { |
| 572 | 616 | $filters = [0 => []]; |
| 573 | 617 | acym_trigger('onAcymSendCampaignSpecial', [$campaign, &$filters[0], &$pluginIsExisting]); |
| 574 | 618 | } else { |
| 619 | + // Adds the special campaigns conditions to the "OR" blocs of the segment | |
| 575 | 620 | foreach ($filters as $key => $filter) { |
| 576 | 621 | acym_trigger('onAcymSendCampaignSpecial', [$campaign, &$filters[$key], &$pluginIsExisting]); |
| 577 | 622 | } |
| 578 | 623 | } |
| 579 | 624 | |
| 625 | + // This is a special campaign type, but the required plugin is not installed | |
| 580 | 626 | if (!$pluginIsExisting) { |
| 581 | 627 | return false; |
| 582 | 628 | } |
| 583 | 629 | |
| 630 | + // Make sure some receivers have been selected | |
| 584 | 631 | $lists = acym_loadResultArray('SELECT list_id FROM #__acym_mail_has_list WHERE mail_id = '.intval($campaign->mail_id)); |
| 585 | 632 | if (empty($lists)) { |
| 586 | 633 | $this->errors[] = acym_translation('ACYM_NO_LIST_SELECTED'); |
| 587 | 634 | |
| @@ -638,8 +685,9 @@ | ||
| 638 | 685 | } else { |
| 639 | 686 | $select = [intval($campaign->mail_id), 'ul.`user_id`', acym_escapeDB($date)]; |
| 640 | 687 | } |
| 641 | 688 | |
| 689 | + // Resending a campaign only to users who didn't receive it | |
| 642 | 690 | if (!empty($campaign->sending_params['resendTarget']) && 'new' === $campaign->sending_params['resendTarget']) { |
| 643 | 691 | if (acym_isMultilingual()) { |
| 644 | 692 | $automationHelper->leftjoin['us'] = '`#__acym_user_stat` AS `us` ON `us`.`user_id` = `user`.`id` AND `us`.`mail_id` IN (SELECT id FROM #__acym_mail WHERE parent_id = '.intval( |
| 645 | 693 | $campaign->mail_id |
| @@ -661,10 +709,11 @@ | ||
| 661 | 709 | $numberUsersInsertedByMailId[intval($campaign->mail_id)] = acym_query( |
| 662 | 710 | 'INSERT IGNORE INTO `#__acym_queue` (`mail_id`, `user_id`, `sending_date`) '.$automationHelper->getQuery($select1) |
| 663 | 711 | ); |
| 664 | 712 | |
| 713 | + // If we have an odd number of users to send, we send one more to the first mail | |
| 665 | 714 | $numberOfUsersToSend2 = $numberOfUsersToSend1 * 2 > $numberOfUsersToSend ? $numberOfUsersToSend1 - 1 : $numberOfUsersToSend1; |
| 666 | - $automationHelper->limit = $numberOfUsersToSend.', '.$numberOfUsersToSend2; | |
| 715 | + $automationHelper->limit = $numberOfUsersToSend1.', '.$numberOfUsersToSend2; | |
| 667 | 716 | $select2 = [intval($campaign->sending_params['abtest']['B']), 'ul.`user_id`', acym_escapeDB($date)]; |
| 668 | 717 | $numberUsersInsertedByMailId[intval($campaign->sending_params['abtest']['B'])] = acym_query( |
| 669 | 718 | 'INSERT IGNORE INTO `#__acym_queue` (`mail_id`, `user_id`, `sending_date`) '.$automationHelper->getQuery($select2) |
| 670 | 719 | ); |
| @@ -736,8 +785,9 @@ | ||
| 736 | 785 | public function getOpenRateCampaigns(array $mailIds) |
| 737 | 786 | { |
| 738 | 787 | acym_arrayToInteger($mailIds); |
| 739 | 788 | |
| 789 | + //TODO move these methods in mail stat class | |
| 740 | 790 | $query = 'SELECT SUM(sent) AS sent, SUM(open_unique) AS open_unique FROM #__acym_mail_stat |
| 741 | 791 | WHERE mail_id IN ('.implode(',', $mailIds).')'; |
| 742 | 792 | |
| 743 | 793 | return acym_loadObject($query); |
| @@ -824,30 +874,37 @@ | ||
| 824 | 874 | } |
| 825 | 875 | |
| 826 | 876 | public function getLastNewsletters(array &$params): array |
| 827 | 877 | { |
| 878 | + // Init select elements | |
| 828 | 879 | $querySelect = 'SELECT mail.*, campaign.sending_date '; |
| 829 | 880 | $queryCountSelect = 'SELECT COUNT(*) FROM (SELECT DISTINCT mail.id '; |
| 830 | 881 | |
| 882 | + // Form the query | |
| 831 | 883 | $query = 'FROM #__acym_campaign AS campaign |
| 832 | 884 | JOIN #__acym_mail AS mail ON campaign.mail_id = mail.id '; |
| 833 | 885 | |
| 886 | + // We may need some joins depending on the selected options | |
| 834 | 887 | if (isset($params['userId']) || isset($params['lists'])) { |
| 835 | 888 | $query .= 'JOIN #__acym_mail_has_list AS maillist ON mail.id = maillist.mail_id '; |
| 836 | 889 | if (isset($params['userId'])) $query .= 'JOIN #__acym_user_has_list AS userlist ON maillist.list_id = userlist.list_id '; |
| 837 | 890 | } |
| 838 | 891 | |
| 892 | + // Make sure we display only active campaigns | |
| 839 | 893 | $where = 'WHERE campaign.active = 1 AND campaign.sent = 1 AND mail.type = '.acym_escapeDB(MailClass::TYPE_STANDARD).' AND campaign.visible = 1 '; |
| 840 | 894 | |
| 895 | + // If we want an archive of some specific lists | |
| 841 | 896 | if (isset($params['lists'])) { |
| 842 | 897 | acym_arrayToInteger($params['lists']); |
| 843 | 898 | $where .= 'AND maillist.list_id IN ('.implode(', ', $params['lists']).') '; |
| 844 | 899 | } |
| 845 | 900 | |
| 901 | + // If we want an archive for a specific user | |
| 846 | 902 | if (isset($params['userId']) && !empty($params['displayUserListOnly'])) { |
| 847 | 903 | $where .= 'AND userlist.user_id = '.intval($params['userId']).' '; |
| 848 | 904 | } |
| 849 | 905 | |
| 906 | + // If the user search for a newsletter | |
| 850 | 907 | if (isset($params['search'])) { |
| 851 | 908 | $search = acym_escapeDB('%'.acym_utf8Encode($params['search']).'%'); |
| 852 | 909 | $where .= 'AND (mail.subject LIKE '.$search.' OR mail.body LIKE '.$search.')'; |
| 853 | 910 | } |
| @@ -855,11 +912,13 @@ | ||
| 855 | 912 | $query .= $where; |
| 856 | 913 | $return = []; |
| 857 | 914 | $return['count'] = (int)acym_loadResult($queryCountSelect.$query.') AS r '); |
| 858 | 915 | |
| 916 | + // Make sure we display campaigns only once | |
| 859 | 917 | $endQuerySelect = 'GROUP BY mail.id '; |
| 860 | 918 | $endQuerySelect .= 'ORDER BY campaign.sending_date DESC'; |
| 861 | 919 | |
| 920 | + // Init the pagination | |
| 862 | 921 | $page = $params['page'] ?? 0; |
| 863 | 922 | $numberPerPage = $params['numberPerPage'] ?? 0; |
| 864 | 923 | $lastNewsletters = $params['limit'] ?? 0; |
| 865 | 924 | |
| @@ -944,8 +1003,9 @@ | ||
| 944 | 1003 | $adminNotificationEmail = $mailClass->getOneByName('acy_notification_auto_campaign_admin'); |
| 945 | 1004 | $time = time(); |
| 946 | 1005 | |
| 947 | 1006 | foreach ($activeAutoCampaigns as $campaign) { |
| 1007 | + // Check the start date | |
| 948 | 1008 | $nextTrigger = $campaign->next_trigger; |
| 949 | 1009 | if (empty($nextTrigger) && !empty($campaign->sending_params['start_date'])) { |
| 950 | 1010 | $nextTrigger = $campaign->sending_params['start_date']; |
| 951 | 1011 | } |
| @@ -953,8 +1013,9 @@ | ||
| 953 | 1013 | if (!empty($nextTrigger) && acym_getTime(acym_date($nextTrigger, 'Y-m-d H:i')) > $time) { |
| 954 | 1014 | continue; |
| 955 | 1015 | } |
| 956 | 1016 | |
| 1017 | + //check if we trigger the campaign | |
| 957 | 1018 | $step = new \stdClass(); |
| 958 | 1019 | $step->triggers = $campaign->sending_params; |
| 959 | 1020 | $step->last_execution = $campaign->last_generated; |
| 960 | 1021 | $step->next_execution = $campaign->next_trigger; |
| @@ -967,8 +1028,9 @@ | ||
| 967 | 1028 | $this->save($campaign); |
| 968 | 1029 | continue; |
| 969 | 1030 | } |
| 970 | 1031 | |
| 1032 | + //update the campaign | |
| 971 | 1033 | $campaignMail = $mailClass->getOneById($campaign->mail_id); |
| 972 | 1034 | |
| 973 | 1035 | $lastGenerated = $campaign->last_generated; |
| 974 | 1036 | $shouldGenerate = $this->updateAutoCampaign($campaign, $campaignMail, $time); |
| @@ -977,8 +1039,9 @@ | ||
| 977 | 1039 | if (!$shouldGenerate) { |
| 978 | 1040 | continue; |
| 979 | 1041 | } |
| 980 | 1042 | |
| 1043 | + //We generate the new campaign | |
| 981 | 1044 | $generatedCampaign = $this->generateCampaign($campaign, $campaignMail, $lastGenerated, $mailClass); |
| 982 | 1045 | if (empty($generatedCampaign)) { |
| 983 | 1046 | $this->messages[] = acym_translationSprintf('ACYM_CAMPAIGN_FAILED_GENERATING', $campaign->name); |
| 984 | 1047 | continue; |
| @@ -985,8 +1048,9 @@ | ||
| 985 | 1048 | } |
| 986 | 1049 | |
| 987 | 1050 | $this->messages[] = acym_translationSprintf('ACYM_CAMPAIGN_GENERATED', $campaign->name, $campaign->sending_params['number_generated']); |
| 988 | 1051 | |
| 1052 | + // We send it directly if no confirmation is needed | |
| 989 | 1053 | if (empty($campaign->sending_params['need_confirm_to_send'])) { |
| 990 | 1054 | $this->send($generatedCampaign->id); |
| 991 | 1055 | } elseif (!empty($campaign->sending_params['admin_notification_emails']) && !empty($adminNotificationEmail)) { |
| 992 | 1056 | $mailerHelper->addParam('campaign_name', $campaign->name); |
| @@ -1001,12 +1065,14 @@ | ||
| 1001 | 1065 | } |
| 1002 | 1066 | |
| 1003 | 1067 | private function shouldGenerateCampaign(object $campaign, object $campaignMail): bool |
| 1004 | 1068 | { |
| 1069 | + // The generateByCategory function is the only one that can stop a campaign generation, with min number of items | |
| 1005 | 1070 | $results = acym_trigger('generateByCategory', [&$campaignMail], null, function ($plugin) { |
| 1006 | 1071 | $plugin->generateCampaignResult->status = true; |
| 1007 | 1072 | }); |
| 1008 | 1073 | |
| 1074 | + // If one of the return statuses is "false", we won't generate the campaign | |
| 1009 | 1075 | foreach ($results as $oneResult) { |
| 1010 | 1076 | if (isset($oneResult->status) && !$oneResult->status) { |
| 1011 | 1077 | $this->messages[] = acym_translationSprintf('ACYM_CAMPAIGN_NOT_GENERATED', $campaign->name, $oneResult->message); |
| 1012 | 1078 | |
| @@ -1052,8 +1118,9 @@ | ||
| 1052 | 1118 | $newCampaign->sending_params = empty($campaign->sending_params['segment']) ? '' : ['segment' => $campaign->sending_params['segment']]; |
| 1053 | 1119 | |
| 1054 | 1120 | $newCampaign->id = $this->save($newCampaign); |
| 1055 | 1121 | |
| 1122 | + // Replace content in the generated mail. MUST be done after campaign has been saved | |
| 1056 | 1123 | acym_trigger('replaceContent', [&$newMail, false]); |
| 1057 | 1124 | $mailClass->save($newMail); |
| 1058 | 1125 | |
| 1059 | 1126 | return $newCampaign; |