Skip to content

Commit 578bad9

Browse files
committed
SendGrid: generate unique message_id for each batch recipient
Closes #139
1 parent d2d568b commit 578bad9

File tree

4 files changed

+77
-30
lines changed

4 files changed

+77
-30
lines changed

CHANGELOG.rst

+12-1
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,23 @@ Breaking changes
3939
code is doing something like `message.anymail_status.recipients[email.lower()]`,
4040
you should remove the `.lower()`
4141

42+
* **SendGrid:** In batch sends, Anymail's SendGrid backend now assigns a separate
43+
`message_id` for each "to" recipient, rather than sharing a single id for all
44+
recipients. This improves accuracy of tracking and statistics (and matches the
45+
behavior of many other ESPs).
46+
47+
If your code uses batch sending (merge_data with multiple to-addresses) and checks
48+
`message.anymail_status.message_id` after sending, that value will now be a *set* of
49+
ids. You can obtain each recipient's individual message_id with
50+
`message.anymail_status.recipients[to_email].message_id`.
51+
See `docs <https://anymail.readthedocs.io/en/latest/esps/sendgrid/#sendgrid-message-id>`__.
52+
4253
Features
4354
~~~~~~~~
4455

4556
* Add new `merge_metadata` option for providing per-recipient metadata in batch
4657
sends. Available for all supported ESPs *except* Amazon SES and SendinBlue.
47-
See `docs <https://anymail.readthedocs.io/en/latest/sending/anymail_additions/#anymail.message.AnymailMessage.merge_metadata>`_.
58+
See `docs <https://anymail.readthedocs.io/en/latest/sending/anymail_additions/#anymail.message.AnymailMessage.merge_metadata>`__.
4859
(Thanks `@janneThoft`_ for the idea and SendGrid implementation.)
4960

5061
* **Mailjet:** Remove limitation on using `cc` or `bcc` together with `merge_data`.

anymail/backends/sendgrid.py

+12-10
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,9 @@ def parse_recipient_status(self, response, payload, message):
6262
# (SendGrid uses a non-2xx response for any failures, caught in raise_for_status.)
6363
# SendGrid v3 doesn't provide any information in the response for a successful send,
6464
# so simulate a per-recipient status of "queued":
65-
status = AnymailRecipientStatus(message_id=payload.message_id, status="queued")
66-
return {recipient.addr_spec: status for recipient in payload.all_recipients}
65+
return {recip.addr_spec: AnymailRecipientStatus(message_id=payload.message_ids.get(recip.addr_spec),
66+
status="queued")
67+
for recip in payload.all_recipients}
6768

6869

6970
class SendGridPayload(RequestsPayload):
@@ -73,7 +74,7 @@ def __init__(self, message, defaults, backend, *args, **kwargs):
7374
self.generate_message_id = backend.generate_message_id
7475
self.workaround_name_quote_bug = backend.workaround_name_quote_bug
7576
self.use_dynamic_template = False # how to represent merge_data
76-
self.message_id = None # Message-ID -- assigned in serialize_data unless provided in headers
77+
self.message_ids = {} # recipient -> generated message_id mapping
7778
self.merge_field_format = backend.merge_field_format
7879
self.merge_data = {} # late-bound per-recipient data
7980
self.merge_global_data = {}
@@ -98,24 +99,25 @@ def init_payload(self):
9899

99100
def serialize_data(self):
100101
"""Performs any necessary serialization on self.data, and returns the result."""
101-
102-
if self.generate_message_id:
103-
self.set_anymail_id()
104102
if self.is_batch():
105103
self.expand_personalizations_for_batch()
106104
self.build_merge_data()
107105
self.build_merge_metadata()
106+
if self.generate_message_id:
107+
self.set_anymail_id()
108108

109109
if not self.data["headers"]:
110110
del self.data["headers"] # don't send empty headers
111111

112112
return self.serialize_json(self.data)
113113

114114
def set_anymail_id(self):
115-
"""Ensure message has a known anymail_id for later event tracking"""
116-
117-
self.message_id = str(uuid.uuid4())
118-
self.data.setdefault("custom_args", {})["anymail_id"] = self.message_id
115+
"""Ensure each personalization has a known anymail_id for later event tracking"""
116+
for personalization in self.data["personalizations"]:
117+
message_id = str(uuid.uuid4())
118+
personalization.setdefault("custom_args", {})["anymail_id"] = message_id
119+
for recipient in personalization["to"] + personalization.get("cc", []) + personalization.get("bcc", []):
120+
self.message_ids[recipient["email"]] = message_id
119121

120122
def expand_personalizations_for_batch(self):
121123
"""Split data["personalizations"] into individual message for each recipient"""

docs/esps/sendgrid.rst

+7
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,13 @@ Limitations and quirks
186186
:setting:`SENDGRID_GENERATE_MESSAGE_ID <ANYMAIL_SENDGRID_GENERATE_MESSAGE_ID>`
187187
to False in your Anymail settings.
188188

189+
.. versionchanged:: 6.0
190+
191+
In batch sends, Anymail generates a distinct anymail_id for *each* "to"
192+
recipient. (Previously, a single id was used for all batch recipients.) Check
193+
:attr:`anymail_status.recipients[to_email].message_id <anymail.message.AnymailStatus.recipients>`
194+
for individual batch-send tracking ids.
195+
189196
.. versionchanged:: 3.0
190197

191198
Previously, Anymail generated a custom :mailheader:`Message-ID`

tests/test_sendgrid_backend.py

+46-19
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,9 @@ def test_send_mail(self):
6363
self.assertEqual(data['from'], {'email': "[email protected]"})
6464
self.assertEqual(data['personalizations'], [{
6565
'to': [{'email': "[email protected]"}],
66+
# make sure the backend assigned the anymail_id for event tracking and notification
67+
'custom_args': {'anymail_id': 'mocked-uuid-1'},
6668
}])
67-
# make sure the backend assigned the anymail_id for event tracking and notification
68-
self.assertEqual(data['custom_args']['anymail_id'], 'mocked-uuid-1')
6969

7070
def test_name_addr(self):
7171
"""Make sure RFC2822 name-addr format (with display-name) is allowed
@@ -115,6 +115,8 @@ def test_email_message(self):
115115
{'email': "[email protected]", 'name': '"Also CC"'}],
116116
'bcc': [{'email': "[email protected]"},
117117
{'email': "[email protected]", 'name': '"Also BCC"'}],
118+
# make sure custom Message-ID also added to custom_args
119+
'custom_args': {'anymail_id': 'mocked-uuid-1'},
118120
}])
119121

120122
self.assertEqual(data['from'], {'email': "[email protected]"})
@@ -125,8 +127,6 @@ def test_email_message(self):
125127
'X-MyHeader': "my value",
126128
'Message-ID': "<[email protected]>",
127129
})
128-
# make sure custom Message-ID also added to custom_args
129-
self.assertEqual(data['custom_args']['anymail_id'], 'mocked-uuid-1')
130130

131131
def test_html_message(self):
132132
text_content = 'This is an important message.'
@@ -454,14 +454,17 @@ def test_merge_data(self):
454454
self.assertEqual(data['personalizations'], [
455455
{'to': [{'email': '[email protected]'}],
456456
'cc': [{'email': '[email protected]'}], # all recipients get the cc
457+
'custom_args': {'anymail_id': 'mocked-uuid-1'},
457458
'dynamic_template_data': {
458459
'name': "Alice", 'group': "Developers", 'site': "ExampleCo"}},
459460
{'to': [{'email': '[email protected]', 'name': '"Bob"'}],
460461
'cc': [{'email': '[email protected]'}],
462+
'custom_args': {'anymail_id': 'mocked-uuid-2'},
461463
'dynamic_template_data': {
462464
'name': "Bob", 'group': "Users", 'site': "ExampleCo"}},
463465
{'to': [{'email': '[email protected]'}],
464466
'cc': [{'email': '[email protected]'}],
467+
'custom_args': {'anymail_id': 'mocked-uuid-3'},
465468
'dynamic_template_data': {
466469
'group': "Users", 'site': "ExampleCo"}},
467470
])
@@ -477,6 +480,7 @@ def test_explicit_dynamic_template(self):
477480
data = self.get_api_call_json()
478481
self.assertEqual(data['personalizations'], [
479482
{'to': [{'email': '[email protected]'}],
483+
'custom_args': {'anymail_id': 'mocked-uuid-1'},
480484
'dynamic_template_data': {"test": "data"}}])
481485

482486
self.message.template_id = "d-apparently-not-legacy"
@@ -486,6 +490,7 @@ def test_explicit_dynamic_template(self):
486490
data = self.get_api_call_json()
487491
self.assertEqual(data['personalizations'], [
488492
{'to': [{'email': '[email protected]'}],
493+
'custom_args': {'anymail_id': 'mocked-uuid-2'},
489494
'substitutions': {"<%test%>": "data"}}])
490495

491496
def test_legacy_merge_data(self):
@@ -514,13 +519,16 @@ def test_legacy_merge_data(self):
514519
self.assertEqual(data['personalizations'], [
515520
{'to': [{'email': '[email protected]'}],
516521
'cc': [{'email': '[email protected]'}], # all recipients get the cc
522+
'custom_args': {'anymail_id': 'mocked-uuid-1'},
517523
'substitutions': {':name': "Alice", ':group': "Developers",
518524
':site': "ExampleCo"}}, # merge_global_data merged
519525
{'to': [{'email': '[email protected]', 'name': '"Bob"'}],
520526
'cc': [{'email': '[email protected]'}],
527+
'custom_args': {'anymail_id': 'mocked-uuid-2'},
521528
'substitutions': {':name': "Bob", ':group': "Users", ':site': "ExampleCo"}},
522529
{'to': [{'email': '[email protected]'}],
523530
'cc': [{'email': '[email protected]'}],
531+
'custom_args': {'anymail_id': 'mocked-uuid-3'},
524532
'substitutions': {':group': "Users", ':site': "ExampleCo"}},
525533
])
526534
self.assertNotIn('sections', data) # 'sections' no longer used for merge_global_data
@@ -538,9 +546,11 @@ def test_legacy_merge_field_format_setting(self):
538546
data = self.get_api_call_json()
539547
self.assertEqual(data['personalizations'], [
540548
{'to': [{'email': '[email protected]'}],
549+
'custom_args': {'anymail_id': 'mocked-uuid-1'},
541550
'substitutions': {':name': "Alice", ':group': "Developers", # keys changed to :field
542551
':site': "ExampleCo"}},
543552
{'to': [{'email': '[email protected]', 'name': '"Bob"'}],
553+
'custom_args': {'anymail_id': 'mocked-uuid-2'},
544554
'substitutions': {':name': "Bob", ':site': "ExampleCo"}}
545555
])
546556

@@ -557,8 +567,10 @@ def test_legacy_merge_field_format_esp_extra(self):
557567
data = self.get_api_call_json()
558568
self.assertEqual(data['personalizations'], [
559569
{'to': [{'email': '[email protected]'}],
570+
'custom_args': {'anymail_id': 'mocked-uuid-1'},
560571
'substitutions': {'*|name|*': "Alice", '*|group|*': "Developers", '*|site|*': "ExampleCo"}},
561572
{'to': [{'email': '[email protected]', 'name': '"Bob"'}],
573+
'custom_args': {'anymail_id': 'mocked-uuid-2'},
562574
'substitutions': {'*|name|*': "Bob", '*|site|*': "ExampleCo"}}
563575
])
564576
# Make sure our esp_extra merge_field_format doesn't get sent to SendGrid API:
@@ -587,11 +599,11 @@ def test_merge_metadata(self):
587599
data = self.get_api_call_json()
588600
self.assertEqual(data['personalizations'], [
589601
{'to': [{'email': '[email protected]'}],
590-
'custom_args': {'order_id': '123'}},
602+
# anymail_id added to other custom_args
603+
'custom_args': {'anymail_id': 'mocked-uuid-1', 'order_id': '123'}},
591604
{'to': [{'email': '[email protected]', 'name': '"Bob"'}],
592-
'custom_args': {'order_id': '678', 'tier': 'premium'}},
605+
'custom_args': {'anymail_id': 'mocked-uuid-2', 'order_id': '678', 'tier': 'premium'}},
593606
])
594-
self.assertEqual(data['custom_args'], {'anymail_id': 'mocked-uuid-1'})
595607

596608
def test_metadata_with_merge_metadata(self):
597609
# Per SendGrid docs: "personalizations[x].custom_args will be merged
@@ -608,12 +620,11 @@ def test_metadata_with_merge_metadata(self):
608620
data = self.get_api_call_json()
609621
self.assertEqual(data['personalizations'], [
610622
{'to': [{'email': '[email protected]'}],
611-
'custom_args': {'order_id': '123'}},
623+
'custom_args': {'anymail_id': 'mocked-uuid-1', 'order_id': '123'}},
612624
{'to': [{'email': '[email protected]', 'name': '"Bob"'}],
613-
'custom_args': {'order_id': '678', 'tier': 'premium'}},
625+
'custom_args': {'anymail_id': 'mocked-uuid-2', 'order_id': '678', 'tier': 'premium'}},
614626
])
615-
self.assertEqual(data['custom_args'],
616-
{'tier': 'basic', 'batch': 'ax24', 'anymail_id': 'mocked-uuid-1'})
627+
self.assertEqual(data['custom_args'], {'tier': 'basic', 'batch': 'ax24'})
617628

618629
def test_merge_metadata_with_merge_data(self):
619630
# (using dynamic templates)
@@ -641,16 +652,17 @@ def test_merge_metadata_with_merge_data(self):
641652
'cc': [{'email': '[email protected]'}], # all recipients get the cc
642653
'dynamic_template_data': {
643654
'name': "Alice", 'group': "Developers", 'site': "ExampleCo"},
644-
'custom_args': {'order_id': '123'}},
655+
'custom_args': {'anymail_id': 'mocked-uuid-1', 'order_id': '123'}},
645656
{'to': [{'email': '[email protected]', 'name': '"Bob"'}],
646657
'cc': [{'email': '[email protected]'}],
647658
'dynamic_template_data': {
648659
'name': "Bob", 'group': "Users", 'site': "ExampleCo"},
649-
'custom_args': {'order_id': '678', 'tier': 'premium'}},
660+
'custom_args': {'anymail_id': 'mocked-uuid-2', 'order_id': '678', 'tier': 'premium'}},
650661
{'to': [{'email': '[email protected]'}],
651662
'cc': [{'email': '[email protected]'}],
652663
'dynamic_template_data': {
653-
'group': "Users", 'site': "ExampleCo"}},
664+
'group': "Users", 'site': "ExampleCo"},
665+
'custom_args': {'anymail_id': 'mocked-uuid-3'}},
654666
])
655667

656668
def test_merge_metadata_with_legacy_template(self):
@@ -677,15 +689,15 @@ def test_merge_metadata_with_legacy_template(self):
677689
self.assertEqual(data['personalizations'], [
678690
{'to': [{'email': '[email protected]'}],
679691
'cc': [{'email': '[email protected]'}], # all recipients get the cc
680-
'custom_args': {'order_id': '123'},
692+
'custom_args': {'anymail_id': 'mocked-uuid-1', 'order_id': '123'},
681693
'substitutions': {':name': "Alice", ':group': "Developers", ':site': "ExampleCo"}},
682694
{'to': [{'email': '[email protected]', 'name': '"Bob"'}],
683695
'cc': [{'email': '[email protected]'}],
684-
'custom_args': {'order_id': '678', 'tier': 'premium'},
696+
'custom_args': {'anymail_id': 'mocked-uuid-2', 'order_id': '678', 'tier': 'premium'},
685697
'substitutions': {':name': "Bob", ':group': "Users", ':site': "ExampleCo"}},
686698
{'to': [{'email': '[email protected]'}],
687699
'cc': [{'email': '[email protected]'}],
688-
# no custom_args
700+
'custom_args': {'anymail_id': 'mocked-uuid-3'},
689701
'substitutions': {':group': "Users", ':site': "ExampleCo"}},
690702
])
691703

@@ -756,8 +768,10 @@ def test_esp_extra_pesonalizations(self):
756768
data = self.get_api_call_json()
757769
self.assertEqual(data['personalizations'], [
758770
{'to': [{'email': '[email protected]', 'name': '"First recipient"'}],
771+
'custom_args': {'anymail_id': 'mocked-uuid-1'},
759772
'future_feature': "works"},
760773
{'to': [{'email': '[email protected]'}],
774+
'custom_args': {'anymail_id': 'mocked-uuid-2'},
761775
'future_feature': "works"}, # merged into *every* recipient
762776
])
763777

@@ -770,6 +784,7 @@ def test_esp_extra_pesonalizations(self):
770784
data = self.get_api_call_json()
771785
self.assertEqual(data['personalizations'], [
772786
{'to': [{'email': '[email protected]'}],
787+
'custom_args': {'anymail_id': 'mocked-uuid-3'},
773788
'future_feature': "works"},
774789
])
775790

@@ -784,10 +799,22 @@ def test_send_attaches_anymail_status(self):
784799
self.assertEqual(msg.anymail_status.status, {'queued'})
785800
self.assertEqual(msg.anymail_status.message_id, 'mocked-uuid-1')
786801
self.assertEqual(msg.anymail_status.recipients['[email protected]'].status, 'queued')
787-
self.assertEqual(msg.anymail_status.recipients['[email protected]'].message_id,
788-
msg.anymail_status.message_id)
802+
self.assertEqual(msg.anymail_status.recipients['[email protected]'].message_id, 'mocked-uuid-1')
789803
self.assertEqual(msg.anymail_status.esp_response.content, self.DEFAULT_RAW_RESPONSE)
790804

805+
def test_batch_recipients_get_unique_message_ids(self):
806+
"""In a batch send, each recipient should get a distinct own message_id"""
807+
msg = mail.EmailMessage('Subject', 'Message', '[email protected]',
808+
['[email protected]', 'Someone Else <[email protected]>'],
809+
810+
msg.merge_data = {} # force batch send
811+
msg.send()
812+
self.assertEqual(msg.anymail_status.message_id, {'mocked-uuid-1', 'mocked-uuid-2'})
813+
self.assertEqual(msg.anymail_status.recipients['[email protected]'].message_id, 'mocked-uuid-1')
814+
self.assertEqual(msg.anymail_status.recipients['[email protected]'].message_id, 'mocked-uuid-2')
815+
# cc's (and bcc's) get copied for all batch recipients, but we can only communicate one message_id:
816+
self.assertEqual(msg.anymail_status.recipients['[email protected]'].message_id, 'mocked-uuid-2')
817+
791818
@override_settings(ANYMAIL_SENDGRID_GENERATE_MESSAGE_ID=False)
792819
def test_disable_generate_message_id(self):
793820
msg = mail.EmailMessage('Subject', 'Message', '[email protected]', ['[email protected]'],)

0 commit comments

Comments
 (0)