diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8969013..bd69b9f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -44,9 +44,12 @@ Breaking changes Features ~~~~~~~~ -* **Amazon SES:** Add new ``merge_headers`` option for per-recipient - headers with template sends. (Requires boto3 >= 1.34.98.) - (Thanks to `@carrerasrodrigo`_ the implementation.) +* Add new ``merge_headers`` option for per-recipient headers with batch sends. + This can be helpful to send individual *List-Unsubscribe* headers (for example). + Supported for all current ESPs *except* MailerSend, Mandrill and Postal. See + `docs `__. + (Thanks to `@carrerasrodrigo`_ for the idea, and for the base and + Amazon SES implementations.) * **Amazon SES:** Allow extra headers, ``metadata``, ``merge_metadata``, and ``tags`` when sending with a ``template_id``. diff --git a/anymail/backends/brevo.py b/anymail/backends/brevo.py index 5512423..f0b7589 100644 --- a/anymail/backends/brevo.py +++ b/anymail/backends/brevo.py @@ -91,28 +91,32 @@ class BrevoPayload(RequestsPayload): self.merge_data = {} self.metadata = {} self.merge_metadata = {} + self.merge_headers = {} def serialize_data(self): """Performs any necessary serialization on self.data, and returns the result.""" if self.is_batch(): # Burst data["to"] into data["messageVersions"] to_list = self.data.pop("to", []) - self.data["messageVersions"] = [ - {"to": [to], "params": self.merge_data.get(to["email"])} - for to in to_list - ] - if self.merge_metadata: - # Merge global metadata with any per-recipient metadata. - # (Top-level X-Mailin-custom header is already set to global metadata, - # and will apply for recipients without a "headers" override.) - for version in self.data["messageVersions"]: - to_email = version["to"][0]["email"] - if to_email in self.merge_metadata: - recipient_metadata = self.metadata.copy() - recipient_metadata.update(self.merge_metadata[to_email]) - version["headers"] = { - "X-Mailin-custom": self.serialize_json(recipient_metadata) - } + self.data["messageVersions"] = [] + for to in to_list: + to_email = to["email"] + version = {"to": [to]} + headers = CaseInsensitiveDict() + if to_email in self.merge_data: + version["params"] = self.merge_data[to_email] + if to_email in self.merge_metadata: + # Merge global metadata with any per-recipient metadata. + # (Top-level X-Mailin-custom header already has global metadata, + # and will apply for recipients without version headers.) + recipient_metadata = self.metadata.copy() + recipient_metadata.update(self.merge_metadata[to_email]) + headers["X-Mailin-custom"] = self.serialize_json(recipient_metadata) + if to_email in self.merge_headers: + headers.update(self.merge_headers[to_email]) + if headers: + version["headers"] = headers + self.data["messageVersions"].append(version) if not self.data["headers"]: del self.data["headers"] # don't send empty headers @@ -212,6 +216,10 @@ class BrevoPayload(RequestsPayload): # Late-bound in serialize_data: self.merge_metadata = merge_metadata + def set_merge_headers(self, merge_headers): + # Late-bound in serialize_data: + self.merge_headers = merge_headers + def set_send_at(self, send_at): try: start_time_iso = send_at.isoformat(timespec="milliseconds") diff --git a/anymail/backends/mailgun.py b/anymail/backends/mailgun.py index 09ef77e..22428f0 100644 --- a/anymail/backends/mailgun.py +++ b/anymail/backends/mailgun.py @@ -117,6 +117,7 @@ class MailgunPayload(RequestsPayload): self.merge_global_data = {} self.metadata = {} self.merge_metadata = {} + self.merge_headers = {} self.to_emails = [] super().__init__(message, defaults, backend, auth=auth, *args, **kwargs) @@ -191,6 +192,8 @@ class MailgunPayload(RequestsPayload): # (E.g., Mailgun's custom-data "name" is set to "%recipient.name%", which picks # up its per-recipient value from Mailgun's # `recipient-variables[to_email]["name"]`.) + # (6) Anymail's `merge_headers` (per-recipient headers) maps to recipient-variables + # prepended with 'h:'. # # If Anymail's `merge_data`, `template_id` (stored templates) and `metadata` (or # `merge_metadata`) are used together, there's a possibility of conflicting keys @@ -268,6 +271,40 @@ class MailgunPayload(RequestsPayload): {key: "%recipient.{}%".format(key) for key in merge_data_keys} ) + # (6) merge_headers --> Mailgun recipient_variables via 'h:'-prefixed keys + if self.merge_headers: + + def hkey(field_name): # 'h:Field-Name' + return "h:{}".format(field_name.title()) + + merge_header_fields = flatset( + recipient_headers.keys() + for recipient_headers in self.merge_headers.values() + ) + merge_header_defaults = { + # existing h:Field-Name value (from extra_headers), or empty string + field: self.data.get(hkey(field), "") + for field in merge_header_fields + } + self.data.update( + # Set up 'h:Field-Name': '%recipient.h:Field-Name%' indirection + { + hvar: f"%recipient.{hvar}%" + for hvar in [hkey(field) for field in merge_header_fields] + } + ) + + for email in self.to_emails: + # Each recipient's recipient_variables needs _all_ merge header fields + recipient_headers = merge_header_defaults.copy() + recipient_headers.update(self.merge_headers.get(email, {})) + recipient_variables_for_headers = { + hkey(field): value for field, value in recipient_headers.items() + } + recipient_variables.setdefault(email, {}).update( + recipient_variables_for_headers + ) + # populate Mailgun params self.data.update({"v:%s" % key: value for key, value in custom_data.items()}) if recipient_variables or self.is_batch(): @@ -308,8 +345,8 @@ class MailgunPayload(RequestsPayload): self.data["h:Reply-To"] = reply_to def set_extra_headers(self, headers): - for key, value in headers.items(): - self.data["h:%s" % key] = value + for field, value in headers.items(): + self.data["h:%s" % field.title()] = value def set_text_body(self, body): self.data["text"] = body @@ -385,6 +422,9 @@ class MailgunPayload(RequestsPayload): # Processed at serialization time (to allow combining with merge_data) self.merge_metadata = merge_metadata + def set_merge_headers(self, merge_headers): + self.merge_headers = merge_headers + def set_esp_extra(self, extra): self.data.update(extra) # Allow override of sender_domain via esp_extra diff --git a/anymail/backends/mailjet.py b/anymail/backends/mailjet.py index a672f89..1c17bee 100644 --- a/anymail/backends/mailjet.py +++ b/anymail/backends/mailjet.py @@ -225,6 +225,13 @@ class MailjetPayload(RequestsPayload): recipient_metadata = merge_metadata[email] message["EventPayload"] = self.serialize_json(recipient_metadata) + def set_merge_headers(self, merge_headers): + self._burst_for_batch_send() + for message in self.data["Messages"]: + email = message["To"][0]["Email"] + if email in merge_headers: + message["Headers"] = merge_headers[email] + def set_tags(self, tags): # The choices here are CustomID or Campaign, and Campaign seems closer # to how "tags" are handled by other ESPs -- e.g., you can view dashboard diff --git a/anymail/backends/postmark.py b/anymail/backends/postmark.py index b2e2a05..8c38062 100644 --- a/anymail/backends/postmark.py +++ b/anymail/backends/postmark.py @@ -1,5 +1,7 @@ import re +from requests.structures import CaseInsensitiveDict + from ..exceptions import AnymailRequestsAPIError from ..message import AnymailRecipientStatus from ..utils import ( @@ -209,6 +211,7 @@ class PostmarkPayload(RequestsPayload): self.cc_and_bcc_emails = [] # needed for parse_recipient_status self.merge_data = None self.merge_metadata = None + self.merge_headers = {} super().__init__(message, defaults, backend, headers=headers, *args, **kwargs) def get_api_endpoint(self): @@ -274,6 +277,18 @@ class PostmarkPayload(RequestsPayload): data["Metadata"].update(recipient_metadata) else: data["Metadata"] = recipient_metadata + if to.addr_spec in self.merge_headers: + if "Headers" in data: + # merge global and recipient headers + headers = CaseInsensitiveDict( + (item["Name"], item["Value"]) for item in data["Headers"] + ) + headers.update(self.merge_headers[to.addr_spec]) + else: + headers = self.merge_headers[to.addr_spec] + data["Headers"] = [ + {"Name": name, "Value": value} for name, value in headers.items() + ] return data # @@ -383,6 +398,10 @@ class PostmarkPayload(RequestsPayload): # late-bind self.merge_metadata = merge_metadata + def set_merge_headers(self, merge_headers): + # late-bind + self.merge_headers = merge_headers + def set_esp_extra(self, extra): self.data.update(extra) # Special handling for 'server_token': diff --git a/anymail/backends/resend.py b/anymail/backends/resend.py index d63a654..9c19930 100644 --- a/anymail/backends/resend.py +++ b/anymail/backends/resend.py @@ -98,6 +98,7 @@ class ResendPayload(RequestsPayload): self.to_recipients = [] # for parse_recipient_status self.metadata = {} self.merge_metadata = {} + self.merge_headers = {} headers = kwargs.pop("headers", {}) headers["Authorization"] = "Bearer %s" % backend.api_key headers["Content-Type"] = "application/json" @@ -129,6 +130,14 @@ class ResendPayload(RequestsPayload): data["headers"]["X-Metadata"] = self.serialize_json( recipient_metadata ) + if to.addr_spec in self.merge_headers: + if "headers" in data: + # Merge global headers (or X-Metadata from above) + headers = CaseInsensitiveCasePreservingDict(data["headers"]) + headers.update(self.merge_headers[to.addr_spec]) + else: + headers = self.merge_headers[to.addr_spec] + data["headers"] = headers payload.append(data) return self.serialize_json(payload) @@ -284,5 +293,8 @@ class ResendPayload(RequestsPayload): def set_merge_metadata(self, merge_metadata): self.merge_metadata = merge_metadata # late bound in serialize_data + def set_merge_headers(self, merge_headers): + self.merge_headers = merge_headers # late bound in serialize_data + def set_esp_extra(self, extra): self.data.update(extra) diff --git a/anymail/backends/sendgrid.py b/anymail/backends/sendgrid.py index 0c6667a..b19e884 100644 --- a/anymail/backends/sendgrid.py +++ b/anymail/backends/sendgrid.py @@ -92,6 +92,7 @@ class SendGridPayload(RequestsPayload): self.merge_data = {} # late-bound per-recipient data self.merge_global_data = {} self.merge_metadata = {} + self.merge_headers = {} http_headers = kwargs.pop("headers", {}) http_headers["Authorization"] = "Bearer %s" % backend.api_key @@ -116,6 +117,7 @@ class SendGridPayload(RequestsPayload): self.expand_personalizations_for_batch() self.build_merge_data() self.build_merge_metadata() + self.build_merge_headers() if self.generate_message_id: self.set_anymail_id() @@ -216,6 +218,15 @@ class SendGridPayload(RequestsPayload): recipient_custom_args = self.transform_metadata(recipient_metadata) personalization["custom_args"] = recipient_custom_args + def build_merge_headers(self): + if self.merge_headers: + for personalization in self.data["personalizations"]: + assert len(personalization["to"]) == 1 + recipient_email = personalization["to"][0]["email"] + recipient_headers = self.merge_headers.get(recipient_email) + if recipient_headers: + personalization["headers"] = recipient_headers + # # Payload construction # @@ -374,6 +385,11 @@ class SendGridPayload(RequestsPayload): # and merge_field_format. self.merge_metadata = merge_metadata + def set_merge_headers(self, merge_headers): + # Becomes personalizations[...]['headers'] in + # build_merge_data + self.merge_headers = merge_headers + def set_esp_extra(self, extra): self.merge_field_format = extra.pop( "merge_field_format", self.merge_field_format diff --git a/anymail/backends/sparkpost.py b/anymail/backends/sparkpost.py index 33f8d8f..21c17fc 100644 --- a/anymail/backends/sparkpost.py +++ b/anymail/backends/sparkpost.py @@ -242,6 +242,36 @@ class SparkPostPayload(RequestsPayload): if to_email in merge_metadata: recipient["metadata"] = merge_metadata[to_email] + def set_merge_headers(self, merge_headers): + def header_var(field): + return "Header__" + field.title().replace("-", "_") + + merge_header_fields = set() + + for recipient in self.data["recipients"]: + to_email = recipient["address"]["email"] + if to_email in merge_headers: + recipient_headers = merge_headers[to_email] + recipient.setdefault("substitution_data", {}).update( + {header_var(key): value for key, value in recipient_headers.items()} + ) + merge_header_fields.update(recipient_headers.keys()) + + if merge_header_fields: + headers = self.data.setdefault("content", {}).setdefault("headers", {}) + # Global substitution_data supplies defaults for defined headers: + self.data.setdefault("substitution_data", {}).update( + { + header_var(field): headers[field] + for field in merge_header_fields + if field in headers + } + ) + # Indirect merge_headers through substitution_data: + headers.update( + {field: "{{%s}}" % header_var(field) for field in merge_header_fields} + ) + def set_send_at(self, send_at): try: start_time = send_at.replace(microsecond=0).isoformat() diff --git a/anymail/backends/unisender_go.py b/anymail/backends/unisender_go.py index 6c9cacb..c70a3a3 100644 --- a/anymail/backends/unisender_go.py +++ b/anymail/backends/unisender_go.py @@ -161,7 +161,7 @@ class UnisenderGoPayload(RequestsPayload): headers.pop("to", None) if headers.pop("cc", None): self.unsupported_feature( - "cc with batch send (merge_data or merge_metadata)" + "cc with batch send (merge_data, merge_metadata, or merge_headers)" ) if not headers: @@ -339,5 +339,26 @@ class UnisenderGoPayload(RequestsPayload): if recipient_email in merge_metadata: recipient["metadata"] = merge_metadata[recipient_email] + # Unisender Go supports header substitution only with List-Unsubscribe. + # (See https://godocs.unisender.ru/web-api-ref#email-send under "substitutions".) + SUPPORTED_MERGE_HEADERS = {"List-Unsubscribe"} + + def set_merge_headers(self, merge_headers: dict[str, dict[str, str]]) -> None: + assert self.data["recipients"] # must be called after set_to + if merge_headers: + for recipient in self.data["recipients"]: + recipient_email = recipient["email"] + for key, value in merge_headers.get(recipient_email, {}).items(): + field = key.title() # canonicalize field name capitalization + if field in self.SUPPORTED_MERGE_HEADERS: + # Set up a substitution for Header__Field_Name + field_sub = "Header__" + field.replace("-", "_") + recipient.setdefault("substitutions", {})[field_sub] = value + self.data.setdefault("headers", {})[field] = ( + "{{%s}}" % field_sub + ) + else: + self.unsupported_feature(f"{field!r} in merge_headers") + def set_esp_extra(self, extra: dict) -> None: update_deep(self.data, extra) diff --git a/docs/esps/amazon_ses.rst b/docs/esps/amazon_ses.rst index 507682c..c63a9da 100644 --- a/docs/esps/amazon_ses.rst +++ b/docs/esps/amazon_ses.rst @@ -96,6 +96,14 @@ Limitations and quirks **No delayed sending** Amazon SES does not support :attr:`~anymail.message.AnymailMessage.send_at`. +**Merge features require template_id** + Anymail's :attr:`~anymail.message.AnymailMessage.merge_headers`, + :attr:`~anymail.message.AnymailMessage.merge_metadata`, + :attr:`~anymail.message.AnymailMessage.merge_data`, and + :attr:`~anymail.message.AnymailMessage.merge_global_data` are only supported + when sending :ref:`templated messages ` + (using Anymail's :attr:`~anymail.message.AnymailMessage.template_id`). + **No global send defaults for non-Anymail options** With the Amazon SES backend, Anymail's :ref:`global send defaults ` are only supported for Anymail's added message options (like diff --git a/docs/esps/esp-feature-matrix.csv b/docs/esps/esp-feature-matrix.csv index ea7b1f5..eef8d46 100644 --- a/docs/esps/esp-feature-matrix.csv +++ b/docs/esps/esp-feature-matrix.csv @@ -1,8 +1,9 @@ Email Service Provider,:ref:`amazon-ses-backend`,:ref:`brevo-backend`,:ref:`mailersend-backend`,:ref:`mailgun-backend`,:ref:`mailjet-backend`,:ref:`mandrill-backend`,:ref:`postal-backend`,:ref:`postmark-backend`,:ref:`resend-backend`,:ref:`sendgrid-backend`,:ref:`sparkpost-backend`,:ref:`unisender-go-backend` .. rubric:: :ref:`Anymail send options `,,,,,,,,,,,, :attr:`~AnymailMessage.envelope_sender`,Yes,No,No,Domain only,Yes,Domain only,Yes,No,No,No,Yes,No +:attr:`~AnymailMessage.merge_headers`,Yes*,Yes,No,Yes,Yes,No,No,Yes,Yes,Yes,Yes*,Yes* :attr:`~AnymailMessage.metadata`,Yes,Yes,No,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes,Yes -:attr:`~AnymailMessage.merge_metadata`,No,Yes,No,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes,Yes +:attr:`~AnymailMessage.merge_metadata`,Yes*,Yes,No,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes,Yes :attr:`~AnymailMessage.send_at`,No,Yes,Yes,Yes,No,Yes,No,No,No,Yes,Yes,Yes :attr:`~AnymailMessage.tags`,Yes,Yes,Yes,Yes,Max 1 tag,Yes,Max 1 tag,Max 1 tag,Yes,Yes,Max 1 tag,Yes :attr:`~AnymailMessage.track_clicks`,No,No,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes @@ -10,8 +11,8 @@ Email Service Provider,:ref:`amazon-ses-backend`,:ref:`brevo-backend`,:ref:`mail :ref:`amp-email`,Yes,No,No,Yes,No,No,No,No,No,Yes,Yes,Yes .. rubric:: :ref:`templates-and-merge`,,,,,,,,,,,, :attr:`~AnymailMessage.template_id`,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes -:attr:`~AnymailMessage.merge_data`,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes -:attr:`~AnymailMessage.merge_global_data`,Yes,Yes,(emulated),(emulated),Yes,Yes,No,Yes,No,Yes,Yes,Yes +:attr:`~AnymailMessage.merge_data`,Yes*,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes +:attr:`~AnymailMessage.merge_global_data`,Yes*,Yes,(emulated),(emulated),Yes,Yes,No,Yes,No,Yes,Yes,Yes .. rubric:: :ref:`Status ` and :ref:`event tracking `,,,,,,,,,,,, :attr:`~AnymailMessage.anymail_status`,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes :class:`~anymail.signals.AnymailTrackingEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes diff --git a/docs/esps/index.rst b/docs/esps/index.rst index 4715296..07eab09 100644 --- a/docs/esps/index.rst +++ b/docs/esps/index.rst @@ -48,6 +48,8 @@ The table below summarizes the Anymail features supported for each ESP. :widths: auto :class: sticky-left +\* See ESP detail page for limitations and clarifications + Trying to choose an ESP? Please **don't** start with this table. It's far more important to consider things like an ESP's deliverability stats, latency, uptime, and support for developers. The *number* of extra features an ESP offers is almost diff --git a/docs/esps/mailersend.rst b/docs/esps/mailersend.rst index 5037d31..6ce1f62 100644 --- a/docs/esps/mailersend.rst +++ b/docs/esps/mailersend.rst @@ -219,6 +219,10 @@ see :ref:`unsupported-features`. Any other extra headers will raise an :exc:`~anymail.exceptions.AnymailUnsupportedFeature` error. +**No merge headers support** + MailerSend's API does not provide a way to support Anymail's + :attr:`~anymail.message.AnymailMessage.merge_headers`. + **No metadata support** MailerSend does not support Anymail's :attr:`~anymail.message.AnymailMessage.metadata` or diff --git a/docs/esps/mailgun.rst b/docs/esps/mailgun.rst index 9081933..4caf9ab 100644 --- a/docs/esps/mailgun.rst +++ b/docs/esps/mailgun.rst @@ -247,18 +247,23 @@ Limitations and quirks obvious reasons, only the domain portion applies. You can use anything before the @, and it will be ignored. -**Using merge_metadata with merge_data** +**Using merge_metadata and merge_headers with merge_data** If you use both Anymail's :attr:`~anymail.message.AnymailMessage.merge_data` and :attr:`~anymail.message.AnymailMessage.merge_metadata` features, make sure your - merge_data keys do not start with ``v:``. (It's a good idea anyway to avoid colons - and other special characters in merge_data keys, as this isn't generally portable - to other ESPs.) + :attr:`~!anymail.message.AnymailMessage.merge_data` keys do not start with ``v:``. + + Similarly, if you use Anymail's :attr:`~anymail.message.AnymailMessage.merge_headers` + together with :attr:`~anymail.message.AnymailMessage.merge_data`, make sure your + :attr:`~!anymail.message.AnymailMessage.merge_data` keys do not start with ``h:``. + + (It's a good idea anyway to avoid colons and other special characters in merge data + keys, as this isn't generally portable to other ESPs.) The same underlying Mailgun feature ("recipient-variables") is used to implement - both Anymail features. To avoid conflicts, Anymail prepends ``v:`` to recipient - variables needed for merge_metadata. (This prefix is stripped as Mailgun prepares - the message to send, so it won't be present in your Mailgun API logs or the metadata - that is sent to tracking webhooks.) + all three Anymail features. To avoid conflicts, Anymail prepends ``v:`` to recipient + variables needed for merge metadata, and ``h:`` for merge headers recipient variables. + (These prefixes are stripped as Mailgun prepares the message to send, so won't appear + in your Mailgun API logs or the metadata that is sent to tracking webhooks.) **Additional limitations on merge_data with template_id** If you are using Mailgun's stored handlebars templates (Anymail's diff --git a/docs/esps/mandrill.rst b/docs/esps/mandrill.rst index 2fb325f..5d96982 100644 --- a/docs/esps/mandrill.rst +++ b/docs/esps/mandrill.rst @@ -143,6 +143,10 @@ Limitations and quirks (Verified and reported to MailChimp support 4/2022; see `Anymail discussion #257`_ for more details.) +**No merge headers support** + Mandrill's API does not provide a way to support Anymail's + :attr:`~anymail.message.AnymailMessage.merge_headers`. + **Envelope sender uses only domain** Anymail's :attr:`~anymail.message.AnymailMessage.envelope_sender` is used to populate Mandrill's `'return_path_domain'`---but only the domain portion. diff --git a/docs/esps/postal.rst b/docs/esps/postal.rst index dc8c3e9..034b2e3 100644 --- a/docs/esps/postal.rst +++ b/docs/esps/postal.rst @@ -119,6 +119,13 @@ see :ref:`unsupported-features`. **Attachments must be named** Postal issues an `AttachmentMissingName` error when trying to send an attachment without name. +**No merge features** + Because Postal does not support batch sending, Anymail's + :attr:`~anymail.message.AnymailMessage.merge_headers`, + :attr:`~anymail.message.AnymailMessage.merge_metadata`, + and :attr:`~anymail.message.AnymailMessage.merge_data` + are not supported. + .. _postal-templates: diff --git a/docs/esps/sparkpost.rst b/docs/esps/sparkpost.rst index 404500f..c0ce665 100644 --- a/docs/esps/sparkpost.rst +++ b/docs/esps/sparkpost.rst @@ -206,6 +206,15 @@ Limitations and quirks .. versionadded:: 8.0 +**Extra header limitations** + SparkPost's API silently ignores certain email headers (specified via + Django's :ref:`headers or extra_headers ` or Anymail's + :attr:`~anymail.message.AnymailMessage.merge_headers`). In particular, + attempts to provide a custom :mailheader:`List-Unsubscribe` header will + not work; the message will be sent with SparkPost's own subscription + management headers. (The list of allowed custom headers does not seem + to be documented.) + **Envelope sender may use domain only** Anymail's :attr:`~anymail.message.AnymailMessage.envelope_sender` is used to populate SparkPost's `'return_path'` parameter. Anymail supplies the full diff --git a/docs/esps/unisender_go.rst b/docs/esps/unisender_go.rst index dd2a97b..8e73353 100644 --- a/docs/esps/unisender_go.rst +++ b/docs/esps/unisender_go.rst @@ -212,6 +212,13 @@ Limitations and quirks :attr:`~anymail.message.AnymailMessage.merge_data` or :attr:`~anymail.message.AnymailMessage.merge_global_data`. +**Limited merge headers support** + Unisender Go supports per-recipient :mailheader:`List-Unsubscribe` headers + (if your account has been approved to disable their unsubscribe link), + but trying to include any other field in Anymail's + :attr:`~anymail.message.AnymailMessage.merge_headers` will raise + an :exc:`~anymail.exceptions.AnymailUnsupportedFeature` error. + **No envelope sender overrides** Unisender Go does not support overriding a message's :attr:`~anymail.message.AnymailMessage.envelope_sender`. diff --git a/docs/sending/anymail_additions.rst b/docs/sending/anymail_additions.rst index a754f81..ee78b60 100644 --- a/docs/sending/anymail_additions.rst +++ b/docs/sending/anymail_additions.rst @@ -101,6 +101,49 @@ an :ref:`unsupported feature ` error. .. _how envelope sender relates to return path: https://www.postmastery.com/blog/about-the-return-path-header/ + .. attribute:: merge_headers + + .. versionadded:: 11.0 + + On a message with multiple recipients, if your ESP supports it, + you can set this to a `dict` of *per-recipient* extra email headers. + Each key in the dict is a recipient email (address portion only), + and its value is a dict of header fields and values for that recipient: + + .. code-block:: python + + message.to = ["wile@example.com", "R. Runner "] + message.extra_headers = { + # Headers for all recipients + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + } + message.merge_headers = { + # Per-recipient headers + "wile@example.com": { + "List-Unsubscribe": "", + }, + "rr@example.com": { + "List-Unsubscribe": "", + }, + } + + When :attr:`!merge_headers` is set, Anymail will use the ESP's + :ref:`batch sending ` option, so that each :attr:`to` recipient gets + an individual message (and doesn't see the other emails on the :attr:`to` list). + + Many ESPs restrict which headers are allowed. Be sure to check Anymail's + :ref:`ESP-specific docs ` for your ESP. + (Also, :ref:`special handling ` for :mailheader:`From`, + :mailheader:`To` and :mailheader:`Reply-To` headers does *not* apply + to :attr:`!merge_headers`.) + + If :attr:`!merge_headers` defines a particular header for only some + recipients, the default for other recipients comes from the message's + :ref:`extra_headers `. If not defined there, behavior + varies by ESP: some will include the header field only for recipients + where you have provided it; other ESPs will send an empty header field + to the other recipients. + .. attribute:: metadata If your ESP supports tracking arbitrary metadata, you can set this to diff --git a/tests/test_brevo_backend.py b/tests/test_brevo_backend.py index 2d5fa0f..822248d 100644 --- a/tests/test_brevo_backend.py +++ b/tests/test_brevo_backend.py @@ -568,6 +568,43 @@ class BrevoBackendAnymailFeatureTests(BrevoBackendMockAPITestCase): {"notification_batch": "zx912"}, ) + def test_merge_headers(self): + self.set_mock_response(json_data=self._mock_batch_response) + self.message.to = ["alice@example.com", "Bob "] + self.message.extra_headers = { + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": "", + } + self.message.merge_headers = { + "alice@example.com": { + "List-Unsubscribe": "", + }, + "bob@example.com": { + "List-Unsubscribe": "", + }, + } + + self.message.send() + + data = self.get_api_call_json() + versions = data["messageVersions"] + self.assertEqual(len(versions), 2) + self.assertEqual( + versions[0]["headers"], {"List-Unsubscribe": ""} + ) + self.assertEqual( + versions[1]["headers"], {"List-Unsubscribe": ""} + ) + self.assertNotIn("params", versions[0]) # because no merge_data + # non-merge headers still in base data + self.assertEqual( + data["headers"], + { + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": "", + }, + ) + def test_default_omits_options(self): """Make sure by default we don't send any ESP-specific options. diff --git a/tests/test_brevo_integration.py b/tests/test_brevo_integration.py index c9d472f..3df2a49 100644 --- a/tests/test_brevo_integration.py +++ b/tests/test_brevo_integration.py @@ -113,6 +113,18 @@ class BrevoBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): "test+to1@anymail.dev": {"customer-id": "ZXK9123"}, "test+to2@anymail.dev": {"customer-id": "ZZT4192"}, }, + headers={ + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": "", + }, + merge_headers={ + "test+to1@anymail.dev": { + "List-Unsubscribe": "", + }, + "test+to2@anymail.dev": { + "List-Unsubscribe": "", + }, + }, ) message.attach("attachment1.txt", "Here is some\ntext", "text/plain") diff --git a/tests/test_mailgun_backend.py b/tests/test_mailgun_backend.py index 234b09b..730491b 100644 --- a/tests/test_mailgun_backend.py +++ b/tests/test_mailgun_backend.py @@ -111,7 +111,7 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase): cc=["cc1@example.com", "Also CC "], headers={ "Reply-To": "another@example.com", - "X-MyHeader": "my value", + "x-my-header": "my value", "Message-ID": "mycustommsgid@example.com", }, ) @@ -126,8 +126,8 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase): ) self.assertEqual(data["cc"], ["cc1@example.com", "Also CC "]) self.assertEqual(data["h:Reply-To"], "another@example.com") - self.assertEqual(data["h:X-MyHeader"], "my value") - self.assertEqual(data["h:Message-ID"], "mycustommsgid@example.com") + self.assertEqual(data["h:X-My-Header"], "my value") + self.assertEqual(data["h:Message-Id"], "mycustommsgid@example.com") # multiple recipients, but not a batch send: self.assertNotIn("recipient-variables", data) @@ -816,6 +816,51 @@ class MailgunBackendAnymailFeatureTests(MailgunBackendMockAPITestCase): ): self.message.send() + def test_merge_headers(self): + # Per-recipient merge_headers uses the same recipient-variables mechanism + # as above, using variable names starting with "h:" + self.message.to = ["alice@example.com", "Bob "] + self.message.extra_headers = { + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": "", + "X-Custom": "custom-default", + } + self.message.merge_headers = { + "alice@example.com": { + "List-Unsubscribe": "", + "X-No-Default": "custom-for-alice", + }, + "bob@example.com": { + "List-Unsubscribe": "", + "X-Custom": "custom-for-bob", + }, + } + self.message.send() + + data = self.get_api_call_data() + # non-merge header has fixed value: + self.assertEqual(data["h:List-Unsubscribe-Post"], "List-Unsubscribe=One-Click") + # merge headers refer to recipient-variables: + self.assertEqual(data["h:List-Unsubscribe"], "%recipient.h:List-Unsubscribe%") + self.assertEqual(data["h:X-Custom"], "%recipient.h:X-Custom%") + self.assertEqual(data["h:X-No-Default"], "%recipient.h:X-No-Default%") + # recipient-variables populates them: + self.assertJSONEqual( + data["recipient-variables"], + { + "alice@example.com": { + "h:List-Unsubscribe": "", + "h:X-Custom": "custom-default", # from extra_headers + "h:X-No-Default": "custom-for-alice", + }, + "bob@example.com": { + "h:List-Unsubscribe": "", + "h:X-Custom": "custom-for-bob", + "h:X-No-Default": "", # no default in extra_headers + }, + }, + ) + def test_force_batch(self): # Mailgun uses presence of recipient-variables to indicate batch send self.message.to = ["alice@example.com", "Bob "] diff --git a/tests/test_mailgun_integration.py b/tests/test_mailgun_integration.py index fdfbe02..c870959 100644 --- a/tests/test_mailgun_integration.py +++ b/tests/test_mailgun_integration.py @@ -201,6 +201,36 @@ class MailgunBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): # (We could try fetching the message from event["storage"]["url"] # to verify content and other headers.) + def test_per_recipient_options(self): + message = AnymailMessage( + from_email=formataddr(("Test From", self.from_email)), + to=["test+to1@anymail.dev", '"Recipient 2" '], + subject="Anymail Mailgun per-recipient options test", + body="This is the text body", + merge_metadata={ + "test+to1@anymail.dev": {"meta1": "one", "meta2": "two"}, + "test+to2@anymail.dev": {"meta1": "recipient 2"}, + }, + headers={ + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": "", + "X-Custom-Header": "default", + }, + merge_headers={ + "test+to1@anymail.dev": { + "List-Unsubscribe": "", + "X-Custom-Header": "custom", + }, + "test+to2@anymail.dev": { + "List-Unsubscribe": "", + }, + }, + ) + message.send() + recipient_status = message.anymail_status.recipients + self.assertEqual(recipient_status["test+to1@anymail.dev"].status, "queued") + self.assertEqual(recipient_status["test+to2@anymail.dev"].status, "queued") + def test_stored_template(self): message = AnymailMessage( # name of a real template named in Anymail's Mailgun test account: diff --git a/tests/test_mailjet_backend.py b/tests/test_mailjet_backend.py index 25ff8d1..dec6c57 100644 --- a/tests/test_mailjet_backend.py +++ b/tests/test_mailjet_backend.py @@ -562,6 +562,45 @@ class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase): {"order_id": 678, "notification_batch": "zx912"}, ) + def test_merge_headers(self): + self.message.to = ["alice@example.com", "Bob "] + self.message.extra_headers = { + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": "", + } + self.message.merge_headers = { + "alice@example.com": { + "List-Unsubscribe": "", + }, + "bob@example.com": { + "List-Unsubscribe": "", + }, + } + self.message.send() + + data = self.get_api_call_json() + messages = data["Messages"] + self.assertEqual(len(messages), 2) + self.assertEqual(messages[0]["To"][0]["Email"], "alice@example.com") + self.assertEqual( + messages[0]["Headers"], + {"List-Unsubscribe": ""}, + ) + self.assertEqual(messages[1]["To"][0]["Email"], "bob@example.com") + self.assertEqual( + messages[1]["Headers"], + {"List-Unsubscribe": ""}, + ) + + # non-merge headers still in globals: + self.assertEqual( + data["Globals"]["Headers"], + { + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": "", + }, + ) + def test_default_omits_options(self): """Make sure by default we don't send any ESP-specific options. diff --git a/tests/test_mailjet_integration.py b/tests/test_mailjet_integration.py index 3ab7a0c..32c9dc6 100644 --- a/tests/test_mailjet_integration.py +++ b/tests/test_mailjet_integration.py @@ -113,6 +113,23 @@ class MailjetBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): "test+to2@anymail.dev": {"value": "two"}, }, merge_global_data={"global": "global_value"}, + metadata={"customer-id": "unknown", "meta2": 2}, + merge_metadata={ + "test+to1@anymail.dev": {"customer-id": "ZXK9123"}, + "test+to2@anymail.dev": {"customer-id": "ZZT4192"}, + }, + headers={ + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": "", + }, + merge_headers={ + "test+to1@anymail.dev": { + "List-Unsubscribe": "", + }, + "test+to2@anymail.dev": { + "List-Unsubscribe": "", + }, + }, ) message.send() recipient_status = message.anymail_status.recipients diff --git a/tests/test_postmark_backend.py b/tests/test_postmark_backend.py index 00a1571..7d64f0f 100644 --- a/tests/test_postmark_backend.py +++ b/tests/test_postmark_backend.py @@ -712,6 +712,49 @@ class PostmarkBackendAnymailFeatureTests(PostmarkBackendMockAPITestCase): self.assertEqual(messages[1]["To"], "Bob ") self.assertEqual(messages[1]["Metadata"], {"order_id": 678, "tier": "premium"}) + def test_merge_headers(self): + self.message.to = ["alice@example.com", "Bob "] + self.message.extra_headers = { + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": "", + } + self.message.merge_headers = { + "alice@example.com": { + "List-Unsubscribe": "", + }, + "bob@example.com": { + "List-Unsubscribe": "", + }, + } + self.message.send() + + self.assert_esp_called("/email/batch") + data = self.get_api_call_json() + self.assertEqual(len(data), 2) + # Global and merge headers are combined: + self.assertEqual(data[0]["To"], "alice@example.com") + self.assertCountEqual( + data[0]["Headers"], + [ + {"Name": "List-Unsubscribe", "Value": ""}, + { + "Name": "List-Unsubscribe-Post", + "Value": "List-Unsubscribe=One-Click", + }, + ], + ) + self.assertEqual(data[1]["To"], "Bob ") + self.assertCountEqual( + data[1]["Headers"], + [ + {"Name": "List-Unsubscribe", "Value": ""}, + { + "Name": "List-Unsubscribe-Post", + "Value": "List-Unsubscribe=One-Click", + }, + ], + ) + def test_default_omits_options(self): """Make sure by default we don't send any ESP-specific options. diff --git a/tests/test_postmark_integration.py b/tests/test_postmark_integration.py index 3d240ed..6fe2a4c 100644 --- a/tests/test_postmark_integration.py +++ b/tests/test_postmark_integration.py @@ -68,13 +68,29 @@ class PostmarkBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): cc=["test+cc1@anymail.dev", "Copy 2 "], bcc=["test+bcc1@anymail.dev", "Blind Copy 2 "], reply_to=["reply1@example.com", "Reply 2 "], - headers={"X-Anymail-Test": "value"}, + headers={ + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": "", + }, # no send_at support metadata={"meta1": "simple string", "meta2": 2}, tags=["tag 1"], # max one tag track_opens=True, track_clicks=True, - merge_data={}, # force batch send (distinct message for each `to`) + # either of these merge_ options will force batch send + # (unique message for each "to" recipient) + merge_metadata={ + "test+to1@anymail.dev": {"customer-id": "ZXK9123"}, + "test+to2@anymail.dev": {"customer-id": "ZZT4192"}, + }, + merge_headers={ + "test+to1@anymail.dev": { + "List-Unsubscribe": "", + }, + "test+to2@anymail.dev": { + "List-Unsubscribe": "", + }, + }, ) message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain") message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv") diff --git a/tests/test_resend_backend.py b/tests/test_resend_backend.py index b2bb70e..66e3a82 100644 --- a/tests/test_resend_backend.py +++ b/tests/test_resend_backend.py @@ -533,6 +533,49 @@ class ResendBackendAnymailFeatureTests(ResendBackendMockAPITestCase): "faccb7a5-8a28-4e9a-ac64-8da1cc3bc1cb", ) + def test_merge_headers(self): + self.set_mock_response(json_data=self._mock_batch_response) + message = AnymailMessage( + from_email="from@example.com", + to=["alice@example.com", "Bob "], + headers={ + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": "", + }, + merge_headers={ + "alice@example.com": { + "List-Unsubscribe": "", + }, + "bob@example.com": { + "List-Unsubscribe": "", + }, + }, + ) + message.send() + + # merge_headers forces batch send API: + self.assert_esp_called("/emails/batch") + + data = self.get_api_call_json() + self.assertEqual(len(data), 2) + self.assertEqual(data[0]["to"], ["alice@example.com"]) + # global and recipient headers are combined: + self.assertEqual( + data[0]["headers"], + { + "List-Unsubscribe": "", + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + }, + ) + self.assertEqual(data[1]["to"], ["Bob "]) + self.assertEqual( + data[1]["headers"], + { + "List-Unsubscribe": "", + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + }, + ) + def test_track_opens(self): self.message.track_opens = True with self.assertRaisesMessage(AnymailUnsupportedFeature, "track_opens"): diff --git a/tests/test_resend_integration.py b/tests/test_resend_integration.py index 6ea722c..6b9e2bb 100644 --- a/tests/test_resend_integration.py +++ b/tests/test_resend_integration.py @@ -87,7 +87,7 @@ class ResendBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): ) # non-empty string def test_batch_send(self): - # merge_metadata or merge_data will use batch send API + # merge_metadata, merge_headers, or merge_data will use batch send API message = AnymailMessage( subject="Anymail Resend batch sendintegration test", body="This is the text body", @@ -99,6 +99,18 @@ class ResendBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): "test+to2@anymail.dev": {"meta3": "recipient 2"}, }, tags=["tag 1", "tag 2"], + headers={ + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": "", + }, + merge_headers={ + "test+to1@anymail.dev": { + "List-Unsubscribe": "", + }, + "test+to2@anymail.dev": { + "List-Unsubscribe": "", + }, + }, ) message.attach_alternative("

HTML content

", "text/html") message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain") diff --git a/tests/test_sendgrid_backend.py b/tests/test_sendgrid_backend.py index 823d109..2ac7984 100644 --- a/tests/test_sendgrid_backend.py +++ b/tests/test_sendgrid_backend.py @@ -1016,6 +1016,45 @@ class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase): ], ) + def test_merge_headers(self): + self.message.to = ["alice@example.com", "Bob "] + self.message.extra_headers = { + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": "", + } + self.message.merge_headers = { + "alice@example.com": { + "List-Unsubscribe": "", + }, + "bob@example.com": { + "List-Unsubscribe": "", + }, + } + self.message.send() + data = self.get_api_call_json() + personalizations = data["personalizations"] + self.assertEqual(len(personalizations), 2) + + self.assertEqual(personalizations[0]["to"][0]["email"], "alice@example.com") + self.assertEqual( + personalizations[0]["headers"], + {"List-Unsubscribe": ""}, + ) + self.assertEqual(personalizations[1]["to"][0]["email"], "bob@example.com") + self.assertEqual( + personalizations[1]["headers"], + {"List-Unsubscribe": ""}, + ) + + # non-merge headers still in globals: + self.assertEqual( + data["headers"], + { + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": "", + }, + ) + @override_settings( ANYMAIL_SENDGRID_GENERATE_MESSAGE_ID=False # else we force custom_args ) diff --git a/tests/test_sendgrid_integration.py b/tests/test_sendgrid_integration.py index 4e738af..b274c1f 100644 --- a/tests/test_sendgrid_integration.py +++ b/tests/test_sendgrid_integration.py @@ -119,6 +119,23 @@ class SendGridBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): esp_extra={ "merge_field_format": "%{}%", }, + metadata={"meta1": "simple string", "meta2": 2}, + merge_metadata={ + "to1@sink.sendgrid.net": {"meta3": "recipient 1"}, + "to2@sink.sendgrid.net": {"meta3": "recipient 2"}, + }, + headers={ + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": "", + }, + merge_headers={ + "to1@sink.sendgrid.net": { + "List-Unsubscribe": "", + }, + "to2@sink.sendgrid.net": { + "List-Unsubscribe": "", + }, + }, ) message.send() recipient_status = message.anymail_status.recipients diff --git a/tests/test_sparkpost_backend.py b/tests/test_sparkpost_backend.py index 25589b1..29d8e7a 100644 --- a/tests/test_sparkpost_backend.py +++ b/tests/test_sparkpost_backend.py @@ -605,6 +605,58 @@ class SparkPostBackendAnymailFeatureTests(SparkPostBackendMockAPITestCase): ) self.assertEqual(data["metadata"], {"notification_batch": "zx912"}) + def test_merge_headers(self): + self.set_mock_result(accepted=2) + self.message.to = ["alice@example.com", "Bob "] + self.message.extra_headers = { + "X-Custom-1": "custom 1", + "X-Custom-2": "custom 2 (default)", + } + self.message.merge_headers = { + "alice@example.com": { + "X-Custom-2": "custom 2 alice", + "X-Custom-3": "custom 3 alice", + }, + "bob@example.com": {"X-Custom-2": "custom 2 bob"}, + } + + self.message.send() + data = self.get_api_call_json() + recipients = data["recipients"] + self.assertEqual(len(recipients), 2) + self.assertEqual(recipients[0]["address"]["email"], "alice@example.com") + self.assertEqual( + recipients[0]["substitution_data"], + { + "Header__X_Custom_2": "custom 2 alice", + "Header__X_Custom_3": "custom 3 alice", + }, + ) + self.assertEqual(recipients[1]["address"]["email"], "bob@example.com") + self.assertEqual( + recipients[1]["substitution_data"], + { + "Header__X_Custom_2": "custom 2 bob", + }, + ) + # Indirect merge_headers through template substitutions: + self.assertEqual( + data["content"]["headers"], + { + "X-Custom-1": "custom 1", # (not a merge_header, value unchanged) + "X-Custom-2": "{{Header__X_Custom_2}}", + "X-Custom-3": "{{Header__X_Custom_3}}", + }, + ) + # Defaults for merge_headers in global substitution_data: + self.assertEqual( + data["substitution_data"], + { + "Header__X_Custom_2": "custom 2 (default)", + # No default specified for X-Custom-3; SparkPost will use empty string + }, + ) + def test_default_omits_options(self): """Make sure by default we don't send any ESP-specific options. diff --git a/tests/test_sparkpost_integration.py b/tests/test_sparkpost_integration.py index ba0edd9..b24a201 100644 --- a/tests/test_sparkpost_integration.py +++ b/tests/test_sparkpost_integration.py @@ -116,6 +116,19 @@ class SparkPostBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): "to2@test.sink.sparkpostmail.com": {"value": "two"}, }, merge_global_data={"global": "global_value"}, + merge_metadata={ + "to1@test.sink.sparkpostmail.com": {"meta1": "one"}, + "to2@test.sink.sparkpostmail.com": {"meta1": "two"}, + }, + headers={ + "X-Custom": "custom header default", + }, + merge_headers={ + # (Note that SparkPost doesn't support custom List-Unsubscribe headers) + "to1@test.sink.sparkpostmail.com": { + "X-Custom": "custom header one", + }, + }, ) message.send() recipient_status = message.anymail_status.recipients diff --git a/tests/test_unisender_go_backend.py b/tests/test_unisender_go_backend.py index a28d1a1..b9fe8bc 100644 --- a/tests/test_unisender_go_backend.py +++ b/tests/test_unisender_go_backend.py @@ -602,12 +602,53 @@ class UnisenderGoBackendAnymailFeatureTests(UnisenderGoBackendMockAPITestCase): self.assertNotIn("to", headers) self.assertNotIn("cc", headers) + def test_merge_headers(self): + self.message.to = ["alice@example.com", "Bob "] + self.message.extra_headers = { + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": "", + } + self.message.merge_headers = { + "alice@example.com": { + "List-Unsubscribe": "", + }, + "bob@example.com": { + "List-Unsubscribe": "", + }, + } + self.message.send() + data = self.get_api_call_json() + headers = data["message"]["headers"] + recipients = data["message"]["recipients"] + + self.assertEqual(headers["List-Unsubscribe-Post"], "List-Unsubscribe=One-Click") + + # merge_headers List-Unsubscribe is handled as substitution: + self.assertEqual(headers["List-Unsubscribe"], "{{Header__List_Unsubscribe}}") + self.assertEqual( + recipients[0]["substitutions"], + {"Header__List_Unsubscribe": ""}, + ) + self.assertEqual( + recipients[1]["substitutions"], + # Header substitutions merged with other substitutions: + {"Header__List_Unsubscribe": "", "to_name": "Bob"}, + ) + + def test_unsupported_merge_headers(self): + # Unisender Go only allows substitutions in the List-Unsubscribe header + self.message.merge_headers = {"to@example.com": {"X-Other": "not supported"}} + with self.assertRaisesMessage( + AnymailUnsupportedFeature, "'X-Other' in merge_headers" + ): + self.message.send() + def test_cc_unsupported_with_batch_send(self): self.message.merge_data = {} self.message.cc = ["cc@example.com"] with self.assertRaisesMessage( AnymailUnsupportedFeature, - "cc with batch send (merge_data or merge_metadata)", + "cc with batch send (merge_data, merge_metadata, or merge_headers)", ): self.message.send() diff --git a/tests/test_unisender_go_integration.py b/tests/test_unisender_go_integration.py index b2132e2..1d9ed3b 100644 --- a/tests/test_unisender_go_integration.py +++ b/tests/test_unisender_go_integration.py @@ -147,6 +147,18 @@ class UnisenderGoBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): "test+to1@anymail.dev": {"customer-id": "ZXK9123"}, "test+to2@anymail.dev": {"customer-id": "ZZT4192"}, }, + headers={ + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": "", + }, + merge_headers={ + "test+to1@anymail.dev": { + "List-Unsubscribe": "", + }, + "test+to2@anymail.dev": { + "List-Unsubscribe": "", + }, + }, ) message.from_email = None # use template sender message.attach("attachment1.txt", "Here is some\ntext", "text/plain")