diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c93f984..1d35935 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -39,6 +39,9 @@ Features * **SendGrid:** Support both new "dynamic" and original "legacy" transactional templates. (See `docs `__.) +* **SendGrid:** Allow merging `esp_extra["personalizations"]` dict into other message-derived + personalizations. (See + `docs `__.) v4.0 diff --git a/anymail/backends/sendgrid.py b/anymail/backends/sendgrid.py index 4210a0c..5238df4 100644 --- a/anymail/backends/sendgrid.py +++ b/anymail/backends/sendgrid.py @@ -1,4 +1,5 @@ import uuid +from collections import Mapping from email.utils import quote as rfc822_quote import warnings @@ -171,7 +172,7 @@ class SendGridPayload(RequestsPayload): pass # no merge_data for this recipient self.data["personalizations"].append(personalization) - if self.merge_field_format is None and all(field.isalnum() for field in all_fields): + if self.merge_field_format is None and len(all_fields) and all(field.isalnum() for field in all_fields): warnings.warn( "Your SendGrid merge fields don't seem to have delimiters, " "which can cause unexpected results with Anymail's merge_data. " @@ -347,6 +348,10 @@ class SendGridPayload(RequestsPayload): def set_esp_extra(self, extra): self.merge_field_format = extra.pop("merge_field_format", self.merge_field_format) self.use_dynamic_template = extra.pop("use_dynamic_template", self.use_dynamic_template) + if isinstance(extra.get("personalizations", None), Mapping): + # merge personalizations *dict* into other message personalizations + assert len(self.data["personalizations"]) == 1 + self.data["personalizations"][0].update(extra.pop("personalizations")) if "x-smtpapi" in extra: raise AnymailConfigurationError( "You are attempting to use SendGrid v2 API-style x-smtpapi params " diff --git a/docs/esps/sendgrid.rst b/docs/esps/sendgrid.rst index 41d0b43..34bf49f 100644 --- a/docs/esps/sendgrid.rst +++ b/docs/esps/sendgrid.rst @@ -123,6 +123,12 @@ Your :attr:`esp_extra` dict will be deeply merged into the parameters Anymail has constructed for the send, with `esp_extra` having precedence in conflicts. +Anymail has special handling for `esp_extra["personalizations"]`. If that value +is a `dict`, Anymail will merge that personalizations dict into the personalizations +for each message recipient. (If you pass a `list`, that will override the +personalizations Anymail normally constructs from the message, and you will need to +specify each recipient in the personalizations list yourself.) + Example: .. code-block:: python @@ -140,6 +146,11 @@ Example: "substitution_tag": "%%OPEN_TRACKING_PIXEL%%", }, }, + # Because "personalizations" is a dict, Anymail will merge "future_feature" + # into the SendGrid personalizations array for each message recipient + "personalizations": { + "future_feature": {"future": "data"}, + }, } diff --git a/tests/test_sendgrid_backend.py b/tests/test_sendgrid_backend.py index b72352f..9680ba9 100644 --- a/tests/test_sendgrid_backend.py +++ b/tests/test_sendgrid_backend.py @@ -627,6 +627,34 @@ class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase): self.assertEqual(data['categories'], ["tag"]) self.assertEqual(data['tracking_settings']['click_tracking'], {'enable': True}) + def test_esp_extra_pesonalizations(self): + self.message.to = ["First recipient ", "second@example.com"] + self.message.merge_data = {} # force separate messages for each 'to' + + # esp_extra['personalizations'] dict merges with message-derived personalizations + self.message.esp_extra = { + "personalizations": {"future_feature": "works"}} + self.message.send() + data = self.get_api_call_json() + self.assertEqual(data['personalizations'], [ + {'to': [{'email': 'first@example.com', 'name': '"First recipient"'}], + 'future_feature': "works"}, + {'to': [{'email': 'second@example.com'}], + 'future_feature': "works"}, # merged into *every* recipient + ]) + + # but esp_extra['personalizations'] list just overrides entire personalizations + # (for backwards compatibility) + self.message.esp_extra = { + "personalizations": [{"to": [{"email": "custom@example.com"}], + "future_feature": "works"}]} + self.message.send() + data = self.get_api_call_json() + self.assertEqual(data['personalizations'], [ + {'to': [{'email': 'custom@example.com'}], + 'future_feature': "works"}, + ]) + # noinspection PyUnresolvedReferences def test_send_attaches_anymail_status(self): """ The anymail_status should be attached to the message when it is sent """