Skip to content

Commit 85dce5f

Browse files
janneThoftmedmunds
authored andcommitted
SendGrid: add merge_metadata
Add support in SendGrid backend for per-recipient metadata.
1 parent 412a1b7 commit 85dce5f

File tree

3 files changed

+165
-4
lines changed

3 files changed

+165
-4
lines changed

anymail/backends/base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ class BasePayload(object):
245245
('template_id', last, force_non_lazy),
246246
('merge_data', combine, force_non_lazy_dict),
247247
('merge_global_data', combine, force_non_lazy_dict),
248+
('merge_metadata', combine, force_non_lazy_dict),
248249
('esp_extra', combine, force_non_lazy_dict),
249250
)
250251
esp_message_attrs = () # subclasses can override
@@ -495,6 +496,9 @@ def set_merge_data(self, merge_data):
495496
def set_merge_global_data(self, merge_global_data):
496497
self.unsupported_feature("merge_global_data")
497498

499+
def set_merge_metadata(self, merge_metadata):
500+
self.unsupported_feature("merge_metadata")
501+
498502
# ESP-specific payload construction
499503
def set_esp_extra(self, extra):
500504
self.unsupported_feature("esp_extra")

anymail/backends/sendgrid.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ def __init__(self, message, defaults, backend, *args, **kwargs):
7777
self.merge_field_format = backend.merge_field_format
7878
self.merge_data = None # late-bound per-recipient data
7979
self.merge_global_data = None
80+
self.merge_metadata = None
8081

8182
http_headers = kwargs.pop('headers', {})
8283
http_headers['Authorization'] = 'Bearer %s' % backend.api_key
@@ -101,6 +102,7 @@ def serialize_data(self):
101102
if self.generate_message_id:
102103
self.set_anymail_id()
103104
self.build_merge_data()
105+
self.build_merge_metadata()
104106

105107
if not self.data["headers"]:
106108
del self.data["headers"] # don't send empty headers
@@ -204,6 +206,28 @@ def build_merge_data_legacy(self):
204206
"Search SENDGRID_MERGE_FIELD_FORMAT in the Anymail docs for more info.",
205207
AnymailWarning)
206208

209+
def build_merge_metadata(self):
210+
if self.merge_metadata is None:
211+
return
212+
213+
if self.merge_data is None:
214+
# Burst apart each to-email in personalizations[0] into a separate
215+
# personalization, and add merge_metadata for that recipient
216+
assert len(self.data["personalizations"]) == 1
217+
base_personalizations = self.data["personalizations"].pop()
218+
to_list = base_personalizations.pop("to") # {email, name?} for each message.to
219+
for recipient in to_list:
220+
personalization = base_personalizations.copy() # captures cc, bcc, and any esp_extra
221+
personalization["to"] = [recipient]
222+
self.data["personalizations"].append(personalization)
223+
224+
for personalization in self.data["personalizations"]:
225+
recipient_email = personalization["to"][0]["email"]
226+
recipient_metadata = self.merge_metadata.get(recipient_email)
227+
if recipient_metadata:
228+
recipient_custom_args = self.transform_metadata(recipient_metadata)
229+
personalization["custom_args"] = recipient_custom_args
230+
207231
#
208232
# Payload construction
209233
#
@@ -296,11 +320,14 @@ def add_attachment(self, attachment):
296320
self.data.setdefault("attachments", []).append(att)
297321

298322
def set_metadata(self, metadata):
323+
self.data["custom_args"] = self.transform_metadata(metadata)
324+
325+
def transform_metadata(self, metadata):
299326
# SendGrid requires custom_args values to be strings -- not integers.
300327
# (And issues the cryptic error {"field": null, "message": "Bad Request", "help": null}
301328
# if they're not.)
302329
# We'll stringify ints and floats; anything else is the caller's responsibility.
303-
self.data["custom_args"] = {
330+
return {
304331
k: str(v) if isinstance(v, BASIC_NUMERIC_TYPES) else v
305332
for k, v in metadata.items()
306333
}
@@ -344,6 +371,12 @@ def set_merge_global_data(self, merge_global_data):
344371
# template type and merge_field_format.
345372
self.merge_global_data = merge_global_data
346373

374+
def set_merge_metadata(self, merge_metadata):
375+
# Becomes personalizations[...]['custom_args'] in
376+
# build_merge_data, after we know recipients, template type,
377+
# and merge_field_format.
378+
self.merge_metadata = merge_metadata
379+
347380
def set_esp_extra(self, extra):
348381
self.merge_field_format = extra.pop("merge_field_format", self.merge_field_format)
349382
self.use_dynamic_template = extra.pop("use_dynamic_template", self.use_dynamic_template)

tests/test_sendgrid_backend.py

Lines changed: 127 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from django.core import mail
1212
from django.test import SimpleTestCase, override_settings, tag
1313
from django.utils.timezone import get_fixed_timezone, override as override_current_timezone
14+
from mock import patch
1415

1516
from anymail.exceptions import (AnymailAPIError, AnymailConfigurationError, AnymailSerializationError,
1617
AnymailUnsupportedFeature, AnymailWarning)
@@ -32,6 +33,13 @@ class SendGridBackendMockAPITestCase(RequestsBackendMockAPITestCase):
3233

3334
def setUp(self):
3435
super(SendGridBackendMockAPITestCase, self).setUp()
36+
37+
# Patch uuid4 to generate predictable anymail_ids for testing
38+
patch_uuid4 = patch('anymail.backends.sendgrid.uuid.uuid4',
39+
side_effect=["mocked-uuid-%d" % n for n in range(1, 5)])
40+
patch_uuid4.start()
41+
self.addCleanup(patch_uuid4.stop)
42+
3543
# Simple message useful for many tests
3644
self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', '[email protected]', ['[email protected]'])
3745

@@ -57,7 +65,7 @@ def test_send_mail(self):
5765
'to': [{'email': "[email protected]"}],
5866
}])
5967
# make sure the backend assigned the anymail_id for event tracking and notification
60-
self.assertUUIDIsValid(data['custom_args']['anymail_id'])
68+
self.assertEqual(data['custom_args']['anymail_id'], 'mocked-uuid-1')
6169

6270
def test_name_addr(self):
6371
"""Make sure RFC2822 name-addr format (with display-name) is allowed
@@ -118,7 +126,7 @@ def test_email_message(self):
118126
'Message-ID': "<[email protected]>",
119127
})
120128
# make sure custom Message-ID also added to custom_args
121-
self.assertUUIDIsValid(data['custom_args']['anymail_id'])
129+
self.assertEqual(data['custom_args']['anymail_id'], 'mocked-uuid-1')
122130

123131
def test_html_message(self):
124132
text_content = 'This is an important message.'
@@ -573,6 +581,122 @@ def test_legacy_warn_if_no_global_merge_field_delimiters(self):
573581
with self.assertWarnsRegex(AnymailWarning, r'SENDGRID_MERGE_FIELD_FORMAT'):
574582
self.message.send()
575583

584+
def test_merge_metadata(self):
585+
self.message.to = ['[email protected]', 'Bob <[email protected]>']
586+
self.message.merge_metadata = {
587+
'[email protected]': {'order_id': 123},
588+
'[email protected]': {'order_id': 678, 'tier': 'premium'},
589+
}
590+
self.message.send()
591+
data = self.get_api_call_json()
592+
self.assertEqual(data['personalizations'], [
593+
{'to': [{'email': '[email protected]'}],
594+
'custom_args': {'order_id': '123'}},
595+
{'to': [{'email': '[email protected]', 'name': '"Bob"'}],
596+
'custom_args': {'order_id': '678', 'tier': 'premium'}},
597+
])
598+
self.assertEqual(data['custom_args'], {'anymail_id': 'mocked-uuid-1'})
599+
600+
def test_metadata_with_merge_metadata(self):
601+
# Per SendGrid docs: "personalizations[x].custom_args will be merged
602+
# with message level custom_args, overriding any conflicting keys."
603+
# So there's no need to merge global metadata with per-recipient merge_metadata
604+
# (like we have to for template merge_global_data and merge_data).
605+
self.message.to = ['[email protected]', 'Bob <[email protected]>']
606+
self.message.metadata = {'tier': 'basic', 'batch': 'ax24'}
607+
self.message.merge_metadata = {
608+
'[email protected]': {'order_id': 123},
609+
'[email protected]': {'order_id': 678, 'tier': 'premium'},
610+
}
611+
self.message.send()
612+
data = self.get_api_call_json()
613+
self.assertEqual(data['personalizations'], [
614+
{'to': [{'email': '[email protected]'}],
615+
'custom_args': {'order_id': '123'}},
616+
{'to': [{'email': '[email protected]', 'name': '"Bob"'}],
617+
'custom_args': {'order_id': '678', 'tier': 'premium'}},
618+
])
619+
self.assertEqual(data['custom_args'],
620+
{'tier': 'basic', 'batch': 'ax24', 'anymail_id': 'mocked-uuid-1'})
621+
622+
def test_merge_metadata_with_merge_data(self):
623+
# (using dynamic templates)
624+
self.message.to = ['[email protected]', 'Bob <[email protected]>', '[email protected]']
625+
self.message.cc = ['[email protected]'] # gets applied to *each* recipient in a merge
626+
self.message.template_id = "d-5a963add2ec84305813ff860db277d7a"
627+
self.message.merge_data = {
628+
'[email protected]': {'name': "Alice", 'group': "Developers"},
629+
'[email protected]': {'name': "Bob"}
630+
# and no data for [email protected]
631+
}
632+
self.message.merge_global_data = {
633+
'group': "Users",
634+
'site': "ExampleCo",
635+
}
636+
self.message.merge_metadata = {
637+
'[email protected]': {'order_id': 123},
638+
'[email protected]': {'order_id': 678, 'tier': 'premium'},
639+
# and no metadata for [email protected]
640+
}
641+
self.message.send()
642+
data = self.get_api_call_json()
643+
self.assertEqual(data['personalizations'], [
644+
{'to': [{'email': '[email protected]'}],
645+
'cc': [{'email': '[email protected]'}], # all recipients get the cc
646+
'dynamic_template_data': {
647+
'name': "Alice", 'group': "Developers", 'site': "ExampleCo"},
648+
'custom_args': {'order_id': '123'}},
649+
{'to': [{'email': '[email protected]', 'name': '"Bob"'}],
650+
'cc': [{'email': '[email protected]'}],
651+
'dynamic_template_data': {
652+
'name': "Bob", 'group': "Users", 'site': "ExampleCo"},
653+
'custom_args': {'order_id': '678', 'tier': 'premium'}},
654+
{'to': [{'email': '[email protected]'}],
655+
'cc': [{'email': '[email protected]'}],
656+
'dynamic_template_data': {
657+
'group': "Users", 'site': "ExampleCo"}},
658+
])
659+
660+
def test_merge_metadata_with_legacy_template(self):
661+
self.message.to = ['[email protected]', 'Bob <[email protected]>', '[email protected]']
662+
self.message.cc = ['[email protected]'] # gets applied to *each* recipient in a merge
663+
self.message.template_id = "5a963add2ec84305813ff860db277d7a"
664+
self.message.esp_extra = {'merge_field_format': ':{}'}
665+
self.message.merge_data = {
666+
'[email protected]': {'name': "Alice", 'group': "Developers"},
667+
'[email protected]': {'name': "Bob"}
668+
# and no data for [email protected]
669+
}
670+
self.message.merge_global_data = {
671+
'group': "Users",
672+
'site': "ExampleCo",
673+
}
674+
self.message.merge_metadata = {
675+
'[email protected]': {'order_id': 123},
676+
'[email protected]': {'order_id': 678, 'tier': 'premium'},
677+
# and no metadata for [email protected]
678+
}
679+
self.message.send()
680+
data = self.get_api_call_json()
681+
self.assertEqual(data['personalizations'], [
682+
{'to': [{'email': '[email protected]'}],
683+
'cc': [{'email': '[email protected]'}], # all recipients get the cc
684+
'custom_args': {'order_id': '123'},
685+
'substitutions': {':name': "Alice", ':group': "Developers", ':site': ":site"}},
686+
{'to': [{'email': '[email protected]', 'name': '"Bob"'}],
687+
'cc': [{'email': '[email protected]'}],
688+
'custom_args': {'order_id': '678', 'tier': 'premium'},
689+
'substitutions': {':name': "Bob", ':group': ":group", ':site': ":site"}},
690+
{'to': [{'email': '[email protected]'}],
691+
'cc': [{'email': '[email protected]'}],
692+
# no custom_args
693+
'substitutions': {':group': ":group", ':site': ":site"}},
694+
])
695+
self.assertEqual(data['sections'], {
696+
':group': "Users",
697+
':site': "ExampleCo",
698+
})
699+
576700
@override_settings(ANYMAIL_SENDGRID_GENERATE_MESSAGE_ID=False) # else we force custom_args
577701
def test_default_omits_options(self):
578702
"""Make sure by default we don't send any ESP-specific options.
@@ -666,7 +790,7 @@ def test_send_attaches_anymail_status(self):
666790
sent = msg.send()
667791
self.assertEqual(sent, 1)
668792
self.assertEqual(msg.anymail_status.status, {'queued'})
669-
self.assertUUIDIsValid(msg.anymail_status.message_id) # don't know exactly what it'll be
793+
self.assertEqual(msg.anymail_status.message_id, 'mocked-uuid-1')
670794
self.assertEqual(msg.anymail_status.recipients['[email protected]'].status, 'queued')
671795
self.assertEqual(msg.anymail_status.recipients['[email protected]'].message_id,
672796
msg.anymail_status.message_id)

0 commit comments

Comments
 (0)