From b14827644b1d219d74c2ab05d7a4c29a1712fc5c Mon Sep 17 00:00:00 2001 From: bencroker Date: Mon, 8 Apr 2024 09:58:14 -0600 Subject: [PATCH 1/4] 2.14.0 --- CHANGELOG.md | 2 +- composer.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdeea14f..5f8b0fde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Release Notes for Campaign -## 2.14.0 - Unreleased +## 2.14.0 - 2024-04-08 ### Added diff --git a/composer.json b/composer.json index 9c120e9c..008870db 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "putyourlightson/craft-campaign", "description": "Send and manage email campaigns, contacts and mailing lists.", - "version": "2.13.3", + "version": "2.14.0", "type": "craft-plugin", "homepage": "https://putyourlightson.com/plugins/campaign", "license": "proprietary", From f84a5c33e2cb8fd8435e3cd405e851b26b4f60bc Mon Sep 17 00:00:00 2001 From: bencroker Date: Mon, 8 Apr 2024 10:26:21 -0600 Subject: [PATCH 2/4] Add issue to changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f8b0fde..4c93f5e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- Added the ability to bulk subscribe/unsubscribe an existing contact from a mailing list using a front-end form. +- Added the ability to bulk subscribe/unsubscribe an existing contact from a mailing list using a front-end form ([#237](https://github.com/putyourlightson/craft-campaign/issues/237)). - Added tracking of the unsubscribe source originating from a front-end request. ### Fixed From c801a5b184c157905f747fabb463eb8b77e9e318 Mon Sep 17 00:00:00 2001 From: bencroker Date: Sat, 13 Apr 2024 18:13:29 -0600 Subject: [PATCH 3/4] Add one-click unsubscribe email functionality Also standardise queries --- CHANGELOG.md | 11 ++++++- composer.json | 2 +- src/config.php | 15 +++++---- src/controllers/TrackerController.php | 36 +++++++++++++++++++++ src/elements/ContactElement.php | 4 +-- src/elements/MailingListElement.php | 2 +- src/elements/SendoutElement.php | 2 +- src/elements/db/CampaignElementQuery.php | 8 ++--- src/elements/db/ContactElementQuery.php | 4 +-- src/elements/db/MailingListElementQuery.php | 2 +- src/models/SettingsModel.php | 6 ++++ src/services/ReportsService.php | 20 ++++++------ src/services/SendoutsService.php | 35 ++++++++++++++------ tests/pest/Helpers.php | 6 ++-- 14 files changed, 111 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c93f5e4..017e5da4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Release Notes for Campaign +## 2.15.0 - Unreleased + +### Added + +- Added one-click unsubscribe headers to sent emails ([#467](https://github.com/putyourlightson/craft-campaign/issues/467)). +- Added a new one-click unsubscribe controller action. +- Added an `addOneClickUnsubscribeHeaders` config setting that determines whether one-click unsubscribe headers should be added to emails, defaulting to `true`. + ## 2.14.0 - 2024-04-08 ### Added @@ -23,7 +31,7 @@ ### Fixed -- Fixed a bug in which newly created contacts were not being indexed for searching if only an email address was the only field added ([#463](https://github.com/putyourlightson/craft-campaign/issues/463)). +- Fixed a bug in which newly created contacts were not being indexed for searching if an email address was the only field added ([#463](https://github.com/putyourlightson/craft-campaign/issues/463)). ## 2.13.0 - 2024-03-25 @@ -35,6 +43,7 @@ ### Changed - Campaign now requires Craft CMS 4.4.0 or later. +- Change - The sendout job batch size is now set to `100` by default, unless it was previously modified by the `maxBatchSize` config setting. - The sendout job batch delay is now set to `0` by default, unless it was previously modified by the `batchJobDelay` config setting. - Renamed the `maxBatchSize` config setting to `sendoutJobBatchSize`. diff --git a/composer.json b/composer.json index 008870db..c200b86f 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "putyourlightson/craft-campaign", "description": "Send and manage email campaigns, contacts and mailing lists.", - "version": "2.14.0", + "version": "2.15.0", "type": "craft-plugin", "homepage": "https://putyourlightson.com/plugins/campaign", "license": "proprietary", diff --git a/src/config.php b/src/config.php index 120e66f3..9aff1f49 100644 --- a/src/config.php +++ b/src/config.php @@ -28,20 +28,23 @@ // An API key to use for triggering tasks and notifications (min. 16 characters) //'apiKey' => 'aBcDeFgHiJkLmNoP', - //Whether to validate incoming webhook requests using a verification key - //validateWebhookRequests => true, + // Whether one-click unsubscribe headers should be added to emails. + //'addOneClickUnsubscribeHeaders' => true, + + // Whether to validate incoming webhook requests using a verification key + //'validateWebhookRequests' => true, // A webhook signing secret provided by MailerSend to validate incoming webhook requests - //mailersendWebhookSigningSecret => 'aBcDeFgHiJkLmNoP123', + //'mailersendWebhookSigningSecret' => 'aBcDeFgHiJkLmNoP123', // A webhook signing key provided by Mailgun to validate incoming webhook requests - //mailgunWebhookSigningKey => 'key-aBcDeFgHiJkLmNoP', + //'mailgunWebhookSigningKey' => 'key-aBcDeFgHiJkLmNoP', // A webhook verification key provided by SendGrid to validate incoming webhook requests - //sendgridWebhookVerificationKey => 'aBcDeFgHiJkLmNoP123==', + //'sendgridWebhookVerificationKey' => 'aBcDeFgHiJkLmNoP123==', // The allowed IP addresses for incoming webhook requests from Postmark - //postmarkAllowedIpAddresses => [ + //'postmarkAllowedIpAddresses' => [ // '3.134.147.250', // '50.31.156.6', // '50.31.156.77', diff --git a/src/controllers/TrackerController.php b/src/controllers/TrackerController.php index d12a6009..5166cddb 100644 --- a/src/controllers/TrackerController.php +++ b/src/controllers/TrackerController.php @@ -16,6 +16,16 @@ class TrackerController extends BaseMessageController { + /** + * @inheritdoc + */ + public $enableCsrfValidation = false; + + /** + * @var bool Disable Snaptcha validation + */ + public bool $enableSnaptchaValidation = false; + /** * @inheritdoc */ @@ -120,6 +130,32 @@ public function actionUnsubscribe(): ?Response ]); } + /** + * Tracks a one-click unsubscribe. + * https://postmarkapp.com/support/article/1299-how-to-include-a-list-unsubscribe-header + * + * @since 2.15.0 + */ + public function actionOneClickUnsubscribe(): ?Response + { + // Ignore if a non-POST requests but don’t require it, since anti-spam tools may send GET requests. + if (!$this->request->getIsPost()) { + return $this->asRaw(''); + } + + // Get contact and sendout + $contact = $this->getContact(); + $sendout = $this->getSendout(); + + if ($contact === null || $sendout === null) { + throw new NotFoundHttpException(Craft::t('campaign', 'Unsubscribe link is invalid.')); + } + + Campaign::$plugin->tracker->unsubscribe($contact, $sendout); + + return $this->asRaw('OK'); + } + /** * Gets a contact by CID in param. */ diff --git a/src/elements/ContactElement.php b/src/elements/ContactElement.php index bd4f9c38..8d785217 100755 --- a/src/elements/ContactElement.php +++ b/src/elements/ContactElement.php @@ -654,7 +654,7 @@ public function getMailingListSubscriptionStatus(int $mailingListId): string { /** @var ContactMailingListRecord|null $contactMailingList */ $contactMailingList = ContactMailingListRecord::find() - ->select('subscriptionStatus') + ->select(['subscriptionStatus']) ->where([ 'contactId' => $this->id, 'mailingListId' => $mailingListId, @@ -1005,7 +1005,7 @@ private function getMailingListsWithStatus(string $subscriptionStatus = null): a /** @var ContactMailingListRecord[] $contactMailingLists */ $contactMailingLists = ContactMailingListRecord::find() - ->select('mailingListId') + ->select(['mailingListId']) ->where($condition) ->all(); diff --git a/src/elements/MailingListElement.php b/src/elements/MailingListElement.php index 3ca05df8..e1aaa8b1 100755 --- a/src/elements/MailingListElement.php +++ b/src/elements/MailingListElement.php @@ -655,7 +655,7 @@ private function getContactCount(string $subscriptionStatus = null): int private function getContactsBySubscriptionStatus(string $subscriptionStatus = null): array { $query = ContactMailingListRecord::find() - ->select('contactId') + ->select(['contactId']) ->where(['mailingListId' => $this->id]); if ($subscriptionStatus) { diff --git a/src/elements/SendoutElement.php b/src/elements/SendoutElement.php index 565d0100..2076f03c 100755 --- a/src/elements/SendoutElement.php +++ b/src/elements/SendoutElement.php @@ -829,7 +829,7 @@ public function getNotificationEmailAddresses(): array } return ContactElement::find() - ->select('email') + ->select(['email']) ->id($this->notificationContactIds) ->column(); } diff --git a/src/elements/db/CampaignElementQuery.php b/src/elements/db/CampaignElementQuery.php index f84673fb..b0e1ff7a 100755 --- a/src/elements/db/CampaignElementQuery.php +++ b/src/elements/db/CampaignElementQuery.php @@ -95,12 +95,12 @@ protected function beforePrepare(): bool // Add the last sent date $sendoutQuery = SendoutRecord::find() - ->select('campaignId, MAX([[lastSent]]) AS lastSent') - ->groupBy('campaignId'); + ->select(['campaignId', 'lastSent' => 'MAX([[lastSent]])']) + ->groupBy(['campaignId']); - $this->query->addSelect('lastSent'); + $this->query->addSelect(['lastSent']); $this->subQuery->leftJoin(['campaign_sendouts' => $sendoutQuery], '[[campaign_sendouts.campaignId]] = [[campaign_campaigns.id]]'); - $this->subQuery->select('campaign_sendouts.lastSent AS lastSent'); + $this->subQuery->select(['lastSent' => 'campaign_sendouts.lastSent']); // Filter by campaign types in sites that have not been deleted $this->subQuery->innerJoin(CampaignTypeRecord::tableName() . ' campaign_campaigntypes', '[[campaign_campaigntypes.id]] = [[campaign_campaigns.campaignTypeId]]'); diff --git a/src/elements/db/ContactElementQuery.php b/src/elements/db/ContactElementQuery.php index 44a28220..cea126c1 100644 --- a/src/elements/db/ContactElementQuery.php +++ b/src/elements/db/ContactElementQuery.php @@ -145,9 +145,9 @@ protected function beforePrepare(): bool } if ($this->mailingListId) { - $this->query->addSelect('subscriptionStatus'); + $this->query->addSelect(['subscriptionStatus']); $this->subQuery->innerJoin(ContactMailingListRecord::tableName() . ' campaign_contacts_mailinglists', '[[campaign_contacts.id]] = [[campaign_contacts_mailinglists.contactId]]'); - $this->subQuery->select('campaign_contacts_mailinglists.subscriptionStatus AS subscriptionStatus'); + $this->subQuery->select(['subscriptionStatus' => 'campaign_contacts_mailinglists.subscriptionStatus']); $this->subQuery->andWhere(Db::parseParam('campaign_contacts_mailinglists.mailingListId', $this->mailingListId)); } else { // Add a dummy subscriptionStatus value to prevent sorted queries from failing diff --git a/src/elements/db/MailingListElementQuery.php b/src/elements/db/MailingListElementQuery.php index 8ab1dc33..8fc4910d 100755 --- a/src/elements/db/MailingListElementQuery.php +++ b/src/elements/db/MailingListElementQuery.php @@ -117,7 +117,7 @@ protected function beforePrepare(): bool } $this->subQuery->innerJoin(MailingListTypeRecord::tableName() . ' campaign_mailinglisttypes', '[[campaign_mailinglisttypes.id]] = [[campaign_mailinglists.mailingListTypeId]]'); - $this->subQuery->select('campaign_mailinglisttypes.name AS mailingListType'); + $this->subQuery->select(['mailingListType' => 'campaign_mailinglisttypes.name']); $this->subQuery->innerJoin(Table::SITES . ' sites', '[[sites.id]] = [[campaign_mailinglisttypes.siteId]]'); $this->subQuery->andWhere(['[[sites.dateDeleted]]' => null]); diff --git a/src/models/SettingsModel.php b/src/models/SettingsModel.php index 018cce7b..479caa8a 100644 --- a/src/models/SettingsModel.php +++ b/src/models/SettingsModel.php @@ -42,6 +42,12 @@ class SettingsModel extends Model */ public string $apiKey; + /** + * @var bool Whether one-click unsubscribe headers should be added to emails. + * @since 2.15.0 + */ + public bool $addOneClickUnsubscribeHeaders = true; + /** * @var bool Whether to validate incoming webhook requests using a signing key or secret * @since 2.10.0 diff --git a/src/services/ReportsService.php b/src/services/ReportsService.php index 338300d0..435fc838 100755 --- a/src/services/ReportsService.php +++ b/src/services/ReportsService.php @@ -60,7 +60,7 @@ public function getCampaignsReportData(int $siteId = null): array // Get all sent campaigns $campaigns = CampaignElement::find() ->status(CampaignElement::STATUS_SENT) - ->orderBy('lastSent DESC') + ->orderBy(['lastSent' => SORT_DESC]) ->siteId($siteId) ->all(); @@ -649,7 +649,7 @@ private function getChartData(string $recordClass, array $condition, array $inte $fields = []; foreach ($record->fields() as $field) { - $fields[] = 'MIN([[' . $field . ']]) AS ' . $field; + $fields[$field] = 'MIN([[' . $field . ']])'; } // Get records within date range @@ -658,7 +658,7 @@ private function getChartData(string $recordClass, array $condition, array $inte ->where($condition) ->andWhere(Db::parseDateParam('dateCreated', $endDateTime, '<')) ->orderBy(['dateCreated' => SORT_ASC]) - ->groupBy('contactId') + ->groupBy(['contactId']) ->all(); // Get activity @@ -770,14 +770,14 @@ private function getLocations(string $recordClass, array $condition, int $total, /** @var ActiveRecord $recordClass */ $query = ContactRecord::find() - ->select(array_merge($fields, ['COUNT(*) AS count'])) - ->groupBy('country'); + ->select(array_merge($fields, ['count' => 'COUNT(*)'])) + ->groupBy(['country']); if ($recordClass != ContactRecord::class) { $contactIds = $recordClass::find() - ->select('contactId') + ->select(['contactId']) ->where($condition) - ->groupBy('contactId') + ->groupBy(['contactId']) ->column(); $query->andWhere([ContactRecord::tableName() . '.id' => $contactIds]); @@ -838,15 +838,15 @@ private function getDevices(string $recordClass, array $condition, bool $detaile /** @var ActiveRecord $recordClass */ $query = ContactRecord::find() - ->select(array_merge($fields, ['COUNT(*) AS count'])) + ->select(array_merge($fields, ['count' => 'COUNT(*)'])) ->where(['not', ['device' => null]]) ->groupBy($fields); if ($recordClass != ContactRecord::class) { $contactIds = $recordClass::find() - ->select('contactId') + ->select(['contactId']) ->where($condition) - ->groupBy('contactId') + ->groupBy(['contactId']) ->column(); $query->andWhere([ContactRecord::tableName() . '.id' => $contactIds]); diff --git a/src/services/SendoutsService.php b/src/services/SendoutsService.php index a58278c9..872081d2 100755 --- a/src/services/SendoutsService.php +++ b/src/services/SendoutsService.php @@ -13,10 +13,12 @@ use craft\helpers\Queue; use craft\helpers\StringHelper; use craft\helpers\UrlHelper; +use craft\mail\Message; use DateTime; use DOMDocument; use DOMElement; use putyourlightson\campaign\Campaign; +use putyourlightson\campaign\controllers\TrackerController; use putyourlightson\campaign\elements\ContactElement; use putyourlightson\campaign\elements\MailingListElement; use putyourlightson\campaign\elements\SendoutElement; @@ -88,7 +90,7 @@ public function getSendoutBySid(string $sid): ?SendoutElement } $sendoutId = SendoutRecord::find() - ->select('id') + ->select(['id']) ->where(['sid' => $sid]) ->scalar(); @@ -105,7 +107,7 @@ public function getSendoutBySid(string $sid): ?SendoutElement public function getSendoutSendStatusById(int $sendoutId): bool|string|null { return SendoutRecord::find() - ->select('sendStatus') + ->select(['sendStatus']) ->where(['id' => $sendoutId]) ->scalar(); } @@ -311,9 +313,22 @@ public function sendEmail(SendoutElement $sendout, ContactElement $contact, int Campaign::$plugin->mailer->useFileTransport = true; } - // Create message - $message = Campaign::$plugin->mailer->compose() - ->setFrom([$sendout->fromEmail => $sendout->fromName]) + /** + * Use the one-click unsubscribe controller action. + * + * @see TrackerController::actionOneClickUnsubscribe() + */ + $oneClickUnsubscribeUrl = str_replace('campaign/t/unsubscribe', 'campaign/t/one-click-unsubscribe', $contact->getUnsubscribeUrl($sendout)); + + /** @var Message $message */ + $message = Campaign::$plugin->mailer->compose(); + + if (Campaign::$plugin->settings->addOneClickUnsubscribeHeaders) { + $message->setHeader('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click') + ->setHeader('List-Unsubscribe', $oneClickUnsubscribeUrl); + } + + $message->setFrom([$sendout->fromEmail => $sendout->fromName]) ->setTo($contact->email) ->setSubject($sendout->subject) ->setHtmlBody($htmlBody) @@ -555,7 +570,7 @@ private function getMailingListById(int $mailingListId): MailingListElement private function getExcludedMailingListRecipientsQuery(SendoutElement $sendout): ActiveQuery { return ContactMailingListRecord::find() - ->select('contactId') + ->select(['contactId']) ->where([ 'mailingListId' => $sendout->excludedMailingListIds, 'subscriptionStatus' => 'subscribed', @@ -568,7 +583,7 @@ private function getExcludedMailingListRecipientsQuery(SendoutElement $sendout): private function getSentRecipientsQuery(SendoutElement $sendout, bool $todayOnly = false): ActiveQuery { $query = ContactCampaignRecord::find() - ->select('contactId') + ->select(['contactId']) ->where(['sendoutId' => $sendout->id]) ->andWhere(['not', ['sent' => null]]); @@ -606,7 +621,7 @@ private function getPendingRecipientsStandardIds(SendoutElement $sendout): array // Get contacts subscribed to sendout’s mailing lists $query = ContactMailingListRecord::find() - ->select('contactId') + ->select(['contactId']) ->andWhere($baseCondition); // Ensure contacts have not complained, bounced, or been blocked (in contact record) @@ -660,8 +675,8 @@ private function getPendingRecipientsStandard(SendoutElement $sendout, int $limi /** @var array $recipients */ $recipients = ContactMailingListRecord::find() - ->select(['contactId', 'min([[mailingListId]]) as mailingListId', 'min([[subscribed]]) as subscribed']) - ->groupBy('contactId') + ->select(['contactId', 'mailingListId' => 'MIN([[mailingListId]])', 'subscribed' => 'MIN([[subscribed]])']) + ->groupBy(['contactId']) ->where($baseCondition) ->andWhere(['contactId' => $contactIds]) ->orderBy(['contactId' => SORT_ASC]) diff --git a/tests/pest/Helpers.php b/tests/pest/Helpers.php index 336139a9..27ba05bf 100644 --- a/tests/pest/Helpers.php +++ b/tests/pest/Helpers.php @@ -138,13 +138,13 @@ function cleanup(): void { $campaignType = Campaign::$plugin->campaignTypes->getCampaignTypeByHandle(App::env('TEST_CAMPAIGN_TYPE_HANDLE')); $campaignIds = CampaignRecord::find() - ->select('id') + ->select(['id']) ->where(['campaignTypeId' => $campaignType->id]) ->column(); $mailingListType = Campaign::$plugin->mailingListTypes->getMailingListTypeByHandle(App::env('TEST_MAILING_LIST_TYPE_HANDLE')); $mailingListIds = MailingListRecord::find() - ->select('id') + ->select(['id']) ->where(['mailingListTypeId' => $mailingListType->id]) ->column(); @@ -153,7 +153,7 @@ function cleanup(): void $userGroup = Craft::$app->userGroups->getGroupByHandle(App::env('TEST_USER_GROUP_HANDLE')); $userIds = User::find() - ->select('id') + ->select(['id']) ->groupId($userGroup->id) ->column(); From 1716aced5002f3199cd945bac04924bd58d5672c Mon Sep 17 00:00:00 2001 From: bencroker Date: Sun, 14 Apr 2024 08:35:16 -0600 Subject: [PATCH 4/4] Code cleanup --- src/services/SendoutsService.php | 22 +++------------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/src/services/SendoutsService.php b/src/services/SendoutsService.php index 872081d2..306ecffb 100755 --- a/src/services/SendoutsService.php +++ b/src/services/SendoutsService.php @@ -18,7 +18,6 @@ use DOMDocument; use DOMElement; use putyourlightson\campaign\Campaign; -use putyourlightson\campaign\controllers\TrackerController; use putyourlightson\campaign\elements\ContactElement; use putyourlightson\campaign\elements\MailingListElement; use putyourlightson\campaign\elements\SendoutElement; @@ -101,17 +100,6 @@ public function getSendoutBySid(string $sid): ?SendoutElement return $this->getSendoutById($sendoutId); } - /** - * Returns sendout send status by ID. - */ - public function getSendoutSendStatusById(int $sendoutId): bool|string|null - { - return SendoutRecord::find() - ->select(['sendStatus']) - ->where(['id' => $sendoutId]) - ->scalar(); - } - /** * Returns the sendout’s pending recipients. */ @@ -313,17 +301,13 @@ public function sendEmail(SendoutElement $sendout, ContactElement $contact, int Campaign::$plugin->mailer->useFileTransport = true; } - /** - * Use the one-click unsubscribe controller action. - * - * @see TrackerController::actionOneClickUnsubscribe() - */ - $oneClickUnsubscribeUrl = str_replace('campaign/t/unsubscribe', 'campaign/t/one-click-unsubscribe', $contact->getUnsubscribeUrl($sendout)); - /** @var Message $message */ $message = Campaign::$plugin->mailer->compose(); if (Campaign::$plugin->settings->addOneClickUnsubscribeHeaders) { + // Use the one-click unsubscribe controller action. + $oneClickUnsubscribeUrl = str_replace('campaign/t/unsubscribe', 'campaign/t/one-click-unsubscribe', $contact->getUnsubscribeUrl($sendout)); + $message->setHeader('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click') ->setHeader('List-Unsubscribe', $oneClickUnsubscribeUrl); }