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 bd33b2c4..27a3267b 100755 --- a/src/elements/ContactElement.php +++ b/src/elements/ContactElement.php @@ -785,7 +785,7 @@ public function getMailingListSubscriptionStatus(int $mailingListId): string { /** @var ContactMailingListRecord|null $contactMailingList */ $contactMailingList = ContactMailingListRecord::find() - ->select('subscriptionStatus') + ->select(['subscriptionStatus']) ->where([ 'contactId' => $this->id, 'mailingListId' => $mailingListId, @@ -1075,7 +1075,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 39eee212..78676a28 100755 --- a/src/elements/MailingListElement.php +++ b/src/elements/MailingListElement.php @@ -656,7 +656,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 9a28a0e3..7feeda29 100755 --- a/src/elements/SendoutElement.php +++ b/src/elements/SendoutElement.php @@ -840,7 +840,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 4d02ad4d..d6285999 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/SendoutsService.php b/src/services/SendoutsService.php index 5492e5ed..f0bcb389 100755 --- a/src/services/SendoutsService.php +++ b/src/services/SendoutsService.php @@ -13,6 +13,7 @@ use craft\helpers\Queue; use craft\helpers\StringHelper; use craft\helpers\UrlHelper; +use craft\mail\Message; use DateTime; use DOMDocument; use DOMElement; @@ -87,7 +88,7 @@ public function getSendoutBySid(string $sid): ?SendoutElement } $sendoutId = SendoutRecord::find() - ->select('id') + ->select(['id']) ->where(['sid' => $sid]) ->scalar(); @@ -299,9 +300,18 @@ 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]) + /** @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); + } + + $message->setFrom([$sendout->fromEmail => $sendout->fromName]) ->setTo($contact->email) ->setSubject($sendout->subject) ->setHtmlBody($htmlBody) @@ -543,7 +553,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', @@ -556,7 +566,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]]); @@ -594,7 +604,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) @@ -648,8 +658,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();