Skip to content

Commit d8d1407

Browse files
joshkerseymedmunds
authored andcommitted
SendGrid: change message_id from Message-ID/smtp-id to UUID anymail_id
SendGrid does not always correctly provide the sent Message-ID header value to a tracking webhook's smtp-id field, making it unreliable to use for Anymail's `message_id`. Instead, generate a UUID `message_id` for Anymail tracking, and pass it from send to webhooks in SendGrid custom args as anymail_id. Webhooks will fall back to smtp-id for compatibility with previously-sent messages that didn't have an anymail_id custom arg. Fixes #108
1 parent 51d2a40 commit d8d1407

File tree

7 files changed

+53
-87
lines changed

7 files changed

+53
-87
lines changed

anymail/backends/sendgrid.py

+7-24
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1+
import uuid
12
from email.utils import quote as rfc822_quote
23
import warnings
34

4-
from django.core.mail import make_msgid
55
from requests.structures import CaseInsensitiveDict
66

77
from .base_requests import AnymailRequestsBackend, RequestsPayload
@@ -99,36 +99,19 @@ def serialize_data(self):
9999
"""Performs any necessary serialization on self.data, and returns the result."""
100100

101101
if self.generate_message_id:
102-
self.ensure_message_id()
102+
self.set_anymail_id()
103103
self.build_merge_data()
104104

105105
if not self.data["headers"]:
106106
del self.data["headers"] # don't send empty headers
107107

108108
return self.serialize_json(self.data)
109109

110-
def ensure_message_id(self):
111-
"""Ensure message has a known Message-ID for later event tracking"""
112-
if "Message-ID" not in self.data["headers"]:
113-
# Only make our own if caller hasn't already provided one
114-
self.data["headers"]["Message-ID"] = self.make_message_id()
115-
self.message_id = self.data["headers"]["Message-ID"]
116-
117-
# Workaround for missing message ID (smtp-id) in SendGrid engagement events
118-
# (click and open tracking): because unique_args get merged into the raw event
119-
# record, we can supply the 'smtp-id' field for any events missing it.
120-
self.data.setdefault("custom_args", {})["smtp-id"] = self.message_id
121-
122-
def make_message_id(self):
123-
"""Returns a Message-ID that could be used for this payload
124-
125-
Tries to use the from_email's domain as the Message-ID's domain
126-
"""
127-
try:
128-
_, domain = self.data["from"]["email"].split("@")
129-
except (AttributeError, KeyError, TypeError, ValueError):
130-
domain = None
131-
return make_msgid(domain=domain)
110+
def set_anymail_id(self):
111+
"""Ensure message has a known anymail_id for later event tracking"""
112+
113+
self.message_id = str(uuid.uuid4())
114+
self.data.setdefault("custom_args", {})["anymail_id"] = self.message_id
132115

133116
def build_merge_data(self):
134117
"""Set personalizations[...]['substitutions'] and data['sections']"""

anymail/backends/sendgrid_v2.py

+6-24
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1+
import uuid
12
import warnings
23

3-
from django.core.mail import make_msgid
44
from requests.structures import CaseInsensitiveDict
55

66
from ..exceptions import AnymailConfigurationError, AnymailRequestsAPIError, AnymailWarning
@@ -99,7 +99,7 @@ def serialize_data(self):
9999
"""Performs any necessary serialization on self.data, and returns the result."""
100100

101101
if self.generate_message_id:
102-
self.ensure_message_id()
102+
self.set_anymail_id()
103103

104104
self.build_merge_data()
105105
if self.merge_data is not None:
@@ -136,29 +136,11 @@ def serialize_data(self):
136136

137137
return self.data
138138

139-
def ensure_message_id(self):
140-
"""Ensure message has a known Message-ID for later event tracking"""
141-
headers = self.data["headers"]
142-
if "Message-ID" not in headers:
143-
# Only make our own if caller hasn't already provided one
144-
headers["Message-ID"] = self.make_message_id()
145-
self.message_id = headers["Message-ID"]
139+
def set_anymail_id(self):
140+
"""Ensure message has a known anymail_id for later event tracking"""
146141

147-
# Workaround for missing message ID (smtp-id) in SendGrid engagement events
148-
# (click and open tracking): because unique_args get merged into the raw event
149-
# record, we can supply the 'smtp-id' field for any events missing it.
150-
self.smtpapi.setdefault('unique_args', {})['smtp-id'] = self.message_id
151-
152-
def make_message_id(self):
153-
"""Returns a Message-ID that could be used for this payload
154-
155-
Tries to use the from_email's domain as the Message-ID's domain
156-
"""
157-
try:
158-
_, domain = self.data["from"].split("@")
159-
except (AttributeError, KeyError, TypeError, ValueError):
160-
domain = None
161-
return make_msgid(domain=domain)
142+
self.message_id = str(uuid.uuid4())
143+
self.smtpapi.setdefault('unique_args', {})["anymail_id"] = self.message_id
162144

163145
def build_merge_data(self):
164146
"""Set smtpapi['sub'] and ['section']"""

anymail/webhooks/sendgrid.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ def esp_to_anymail_event(self, esp_event):
7272
return AnymailTrackingEvent(
7373
event_type=event_type,
7474
timestamp=timestamp,
75-
message_id=esp_event.get('smtp-id', None),
75+
message_id=esp_event.get('anymail_id', esp_event.get('smtp-id')), # backwards compatibility
7676
event_id=esp_event.get('sg_event_id', None),
7777
recipient=esp_event.get('email', None),
7878
reject_reason=reject_reason,
@@ -86,6 +86,7 @@ def esp_to_anymail_event(self, esp_event):
8686

8787
# Known keys in SendGrid events (used to recover metadata above)
8888
sendgrid_event_keys = {
89+
'anymail_id',
8990
'asm_group_id',
9091
'attempt', # MTA deferred count
9192
'category',

tests/test_sendgrid_backend.py

+5-9
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,8 @@ def test_send_mail(self):
5555
self.assertEqual(data['personalizations'], [{
5656
'to': [{'email': "[email protected]"}],
5757
}])
58-
# make sure backend assigned a Message-ID for event tracking
59-
self.assertRegex(data['headers']['Message-ID'], r'\<.+@sender\.example\.com\>') # id uses from_email's domain
60-
# make sure we added the Message-ID to custom_args for event notification
61-
self.assertEqual(data['headers']['Message-ID'], data['custom_args']['smtp-id'])
58+
# make sure the backend assigned the anymail_id for event tracking and notification
59+
self.assertUUIDIsValid(data['custom_args']['anymail_id'])
6260

6361
def test_name_addr(self):
6462
"""Make sure RFC2822 name-addr format (with display-name) is allowed
@@ -119,9 +117,7 @@ def test_email_message(self):
119117
'Message-ID': "<[email protected]>",
120118
})
121119
# make sure custom Message-ID also added to custom_args
122-
self.assertEqual(data['custom_args'], {
123-
'smtp-id': "<[email protected]>",
124-
})
120+
self.assertUUIDIsValid(data['custom_args']['anymail_id'])
125121

126122
def test_html_message(self):
127123
text_content = 'This is an important message.'
@@ -345,7 +341,7 @@ def test_metadata(self):
345341
self.message.metadata = {'user_id': "12345", 'items': 6, 'float': 98.6, 'long': longtype(123)}
346342
self.message.send()
347343
data = self.get_api_call_json()
348-
data['custom_args'].pop('smtp-id', None) # remove Message-ID we added as tracking workaround
344+
data['custom_args'].pop('anymail_id', None) # remove Message-ID we added as tracking workaround
349345
self.assertEqual(data['custom_args'], {'user_id': "12345",
350346
'items': "6", # int converted to a string,
351347
'float': "98.6", # float converted to a string (watch binary rounding!)
@@ -579,7 +575,7 @@ def test_send_attaches_anymail_status(self):
579575
sent = msg.send()
580576
self.assertEqual(sent, 1)
581577
self.assertEqual(msg.anymail_status.status, {'queued'})
582-
self.assertRegex(msg.anymail_status.message_id, r'\<.+@example\.com\>') # don't know exactly what it'll be
578+
self.assertUUIDIsValid(msg.anymail_status.message_id) # don't know exactly what it'll be
583579
self.assertEqual(msg.anymail_status.recipients['[email protected]'].status, 'queued')
584580
self.assertEqual(msg.anymail_status.recipients['[email protected]'].message_id,
585581
msg.anymail_status.message_id)

tests/test_sendgrid_v2_backend.py

+8-13
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,9 @@ def test_send_mail(self):
6363
self.assertEqual(data['text'], "Here is the message.")
6464
self.assertEqual(data['from'], "[email protected]")
6565
self.assertEqual(data['to'], ["[email protected]"])
66-
# make sure backend assigned a Message-ID for event tracking
67-
email_headers = json.loads(data['headers'])
68-
self.assertRegex(email_headers['Message-ID'], r'\<.+@sender\.example\.com\>') # id uses from_email's domain
69-
# make sure we added the Message-ID to unique_args for event notification
66+
# make sure the backend assigned the anymail_id to unique_args for event tracking and notification
7067
smtpapi = self.get_smtpapi()
71-
self.assertEqual(email_headers['Message-ID'], smtpapi['unique_args']['smtp-id'])
68+
self.assertUUIDIsValid(smtpapi['unique_args']['anymail_id'])
7269

7370
@override_settings(ANYMAIL={'SENDGRID_USERNAME': 'sg_username', 'SENDGRID_PASSWORD': 'sg_password'})
7471
def test_user_pass_auth(self):
@@ -129,10 +126,9 @@ def test_email_message(self):
129126
'Reply-To': '[email protected]',
130127
'X-MyHeader': 'my value',
131128
})
132-
# make sure custom Message-ID also added to unique_args
133-
self.assertJSONEqual(data['x-smtpapi'], {
134-
'unique_args': {'smtp-id': '<[email protected]>'}
135-
})
129+
# make sure anymail_id also added to unique_args
130+
smtpapi_json = json.loads(data['x-smtpapi'])
131+
self.assertUUIDIsValid(smtpapi_json['unique_args']['anymail_id'])
136132

137133
def test_html_message(self):
138134
text_content = 'This is an important message.'
@@ -293,8 +289,7 @@ def test_suppress_empty_address_lists(self):
293289
self.assertNotIn('ccname', data)
294290
self.assertNotIn('bcc', data)
295291
self.assertNotIn('bccname', data)
296-
headers = json.loads(data['headers'])
297-
self.assertNotIn('Reply-To', headers)
292+
self.assertNotIn('headers', data)
298293

299294
# Test empty `to` -- but send requires at least one recipient somewhere (like cc)
300295
self.message.to = []
@@ -354,7 +349,7 @@ def test_metadata(self):
354349
self.message.metadata = {'user_id': "12345", 'items': 6}
355350
self.message.send()
356351
smtpapi = self.get_smtpapi()
357-
smtpapi['unique_args'].pop('smtp-id', None) # remove Message-ID we added as tracking workaround
352+
smtpapi['unique_args'].pop('anymail_id', None) # remove Message-ID we added as tracking workaround
358353
self.assertEqual(smtpapi['unique_args'], {'user_id': "12345", 'items': 6})
359354

360355
def test_send_at(self):
@@ -565,7 +560,7 @@ def test_send_attaches_anymail_status(self):
565560
sent = msg.send()
566561
self.assertEqual(sent, 1)
567562
self.assertEqual(msg.anymail_status.status, {'queued'})
568-
self.assertRegex(msg.anymail_status.message_id, r'\<.+@example\.com\>') # don't know exactly what it'll be
563+
self.assertUUIDIsValid(msg.anymail_status.message_id)
569564
self.assertEqual(msg.anymail_status.recipients['[email protected]'].status, 'queued')
570565
self.assertEqual(msg.anymail_status.recipients['[email protected]'].message_id,
571566
msg.anymail_status.message_id)

tests/test_sendgrid_webhooks.py

+16-16
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def test_processed_event(self):
2323
raw_events = [{
2424
"email": "[email protected]",
2525
"timestamp": 1461095246,
26-
"smtp-id": "<[email protected]>",
26+
"anymail_id": "3c2f4df8-c6dd-4cd2-9b91-6582b81a0349",
2727
"sg_event_id": "ZyjAM5rnQmuI1KFInHQ3Nw",
2828
"sg_message_id": "wrfRRvF7Q0GgwUo2CvDmEA.filter0425p1mdw1.13037.57168B4A1D.0",
2929
"event": "processed",
@@ -41,7 +41,7 @@ def test_processed_event(self):
4141
self.assertEqual(event.event_type, "queued")
4242
self.assertEqual(event.timestamp, datetime(2016, 4, 19, 19, 47, 26, tzinfo=utc))
4343
self.assertEqual(event.esp_event, raw_events[0])
44-
self.assertEqual(event.message_id, "<[email protected]>")
44+
self.assertEqual(event.message_id, "3c2f4df8-c6dd-4cd2-9b91-6582b81a0349")
4545
self.assertEqual(event.event_id, "ZyjAM5rnQmuI1KFInHQ3Nw")
4646
self.assertEqual(event.recipient, "[email protected]")
4747
self.assertEqual(event.tags, ["tag1", "tag2"])
@@ -57,7 +57,7 @@ def test_delivered_event(self):
5757
"event": "delivered",
5858
"email": "[email protected]",
5959
"timestamp": 1461095250,
60-
"smtp-id": "<[email protected]>"
60+
"anymail_id": "4ab185c2-0171-492f-9ce0-27de258efc99"
6161
}]
6262
response = self.client.post('/anymail/sendgrid/tracking/',
6363
content_type='application/json', data=json.dumps(raw_events))
@@ -69,7 +69,7 @@ def test_delivered_event(self):
6969
self.assertEqual(event.event_type, "delivered")
7070
self.assertEqual(event.timestamp, datetime(2016, 4, 19, 19, 47, 30, tzinfo=utc))
7171
self.assertEqual(event.esp_event, raw_events[0])
72-
self.assertEqual(event.message_id, "<[email protected]>")
72+
self.assertEqual(event.message_id, "4ab185c2-0171-492f-9ce0-27de258efc99")
7373
self.assertEqual(event.event_id, "nOSv8m0eTQ-vxvwNwt3fZQ")
7474
self.assertEqual(event.recipient, "[email protected]")
7575
self.assertEqual(event.mta_response, "250 2.0.0 OK 1461095248 m143si2210036ioe.159 - gsmtp ")
@@ -79,7 +79,7 @@ def test_delivered_event(self):
7979
def test_dropped_invalid_event(self):
8080
raw_events = [{
8181
"email": "invalid@invalid",
82-
"smtp-id": "<[email protected]>",
82+
"anymail_id": "c74002d9-7ccb-4f67-8b8c-766cec03c9a6",
8383
"timestamp": 1461095250,
8484
"sg_event_id": "3NPOePGOTkeM_U3fgWApfg",
8585
"sg_message_id": "filter0093p1las1.9128.5717FB8127.0",
@@ -95,7 +95,7 @@ def test_dropped_invalid_event(self):
9595
self.assertIsInstance(event, AnymailTrackingEvent)
9696
self.assertEqual(event.event_type, "rejected")
9797
self.assertEqual(event.esp_event, raw_events[0])
98-
self.assertEqual(event.message_id, "<[email protected]>")
98+
self.assertEqual(event.message_id, "c74002d9-7ccb-4f67-8b8c-766cec03c9a6")
9999
self.assertEqual(event.event_id, "3NPOePGOTkeM_U3fgWApfg")
100100
self.assertEqual(event.recipient, "invalid@invalid")
101101
self.assertEqual(event.reject_reason, "invalid")
@@ -104,7 +104,7 @@ def test_dropped_invalid_event(self):
104104
def test_dropped_unsubscribed_event(self):
105105
raw_events = [{
106106
"email": "[email protected]",
107-
"smtp-id": "<[email protected]>",
107+
"anymail_id": "a36ec0f9-aabe-45c7-9a84-3e17afb5cb65",
108108
"timestamp": 1461095250,
109109
"sg_event_id": "oxy9OLwMTAy5EsuZn1qhIg",
110110
"sg_message_id": "filter0199p1las1.4745.5717FB6F5.0",
@@ -120,7 +120,7 @@ def test_dropped_unsubscribed_event(self):
120120
self.assertIsInstance(event, AnymailTrackingEvent)
121121
self.assertEqual(event.event_type, "rejected")
122122
self.assertEqual(event.esp_event, raw_events[0])
123-
self.assertEqual(event.message_id, "<[email protected]>")
123+
self.assertEqual(event.message_id, "a36ec0f9-aabe-45c7-9a84-3e17afb5cb65")
124124
self.assertEqual(event.event_id, "oxy9OLwMTAy5EsuZn1qhIg")
125125
self.assertEqual(event.recipient, "[email protected]")
126126
self.assertEqual(event.reject_reason, "unsubscribed")
@@ -137,7 +137,7 @@ def test_bounce_event(self):
137137
"event": "bounce",
138138
"email": "[email protected]",
139139
"timestamp": 1461095250,
140-
"smtp-id": "<[email protected]>",
140+
"anymail_id": "de212213-bb66-4302-8f3f-20acdb7a104e",
141141
"type": "bounce"
142142
}]
143143
response = self.client.post('/anymail/sendgrid/tracking/',
@@ -149,7 +149,7 @@ def test_bounce_event(self):
149149
self.assertIsInstance(event, AnymailTrackingEvent)
150150
self.assertEqual(event.event_type, "bounced")
151151
self.assertEqual(event.esp_event, raw_events[0])
152-
self.assertEqual(event.message_id, "<[email protected]>")
152+
self.assertEqual(event.message_id, "de212213-bb66-4302-8f3f-20acdb7a104e")
153153
self.assertEqual(event.event_id, "lC0Rc-FuQmKbnxCWxX1jRQ")
154154
self.assertEqual(event.recipient, "[email protected]")
155155
self.assertEqual(event.mta_response, "550 5.1.1 The email account that you tried to reach does not exist.")
@@ -163,7 +163,7 @@ def test_deferred_event(self):
163163
"email": "[email protected]",
164164
"attempt": "1",
165165
"timestamp": 1461200990,
166-
"smtp-id": "<[email protected]>",
166+
"anymail_id": "ccf83222-0d7e-4542-8beb-893122afa757",
167167
}]
168168
response = self.client.post('/anymail/sendgrid/tracking/',
169169
content_type='application/json', data=json.dumps(raw_events))
@@ -174,7 +174,7 @@ def test_deferred_event(self):
174174
self.assertIsInstance(event, AnymailTrackingEvent)
175175
self.assertEqual(event.event_type, "deferred")
176176
self.assertEqual(event.esp_event, raw_events[0])
177-
self.assertEqual(event.message_id, "<[email protected]>")
177+
self.assertEqual(event.message_id, "ccf83222-0d7e-4542-8beb-893122afa757")
178178
self.assertEqual(event.event_id, "b_syL5UiTvWC_Ky5L6Bs5Q")
179179
self.assertEqual(event.recipient, "[email protected]")
180180
self.assertEqual(event.mta_response,
@@ -187,7 +187,7 @@ def test_open_event(self):
187187
"ip": "66.102.6.229",
188188
"sg_event_id": "MjIwNDg5NTgtZGE3OC00NDI1LWFiMmMtMDUyZTU2ZmFkOTFm",
189189
"sg_message_id": "wrfRRvF7Q0GgwUo2CvDmEA.filter0425p1mdw1.13037.57168B4A1D.0",
190-
"smtp-id": "<[email protected]>",
190+
"anymail_id": "44920b35-3e31-478b-bb67-b4f5e0c85ebc",
191191
"useragent": "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0",
192192
"event": "open"
193193
}]
@@ -200,7 +200,7 @@ def test_open_event(self):
200200
self.assertIsInstance(event, AnymailTrackingEvent)
201201
self.assertEqual(event.event_type, "opened")
202202
self.assertEqual(event.esp_event, raw_events[0])
203-
self.assertEqual(event.message_id, "<[email protected]>")
203+
self.assertEqual(event.message_id, "44920b35-3e31-478b-bb67-b4f5e0c85ebc")
204204
self.assertEqual(event.event_id, "MjIwNDg5NTgtZGE3OC00NDI1LWFiMmMtMDUyZTU2ZmFkOTFm")
205205
self.assertEqual(event.recipient, "[email protected]")
206206
self.assertEqual(event.user_agent, "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0")
@@ -211,7 +211,7 @@ def test_click_event(self):
211211
"sg_event_id": "OTdlOGUzYjctYjc5Zi00OWE4LWE4YWUtNjIxNjk2ZTJlNGVi",
212212
"sg_message_id": "_fjPjuJfRW-IPs5SuvYotg.filter0590p1mdw1.2098.57168CFC4B.0",
213213
"useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36",
214-
"smtp-id": "<[email protected]>",
214+
"anymail_id": "75de5af9-a090-4325-87f9-8c599ad66f60",
215215
"event": "click",
216216
"url_offset": {"index": 0, "type": "html"},
217217
"email": "[email protected]",
@@ -227,7 +227,7 @@ def test_click_event(self):
227227
self.assertIsInstance(event, AnymailTrackingEvent)
228228
self.assertEqual(event.event_type, "clicked")
229229
self.assertEqual(event.esp_event, raw_events[0])
230-
self.assertEqual(event.message_id, "<[email protected]>")
230+
self.assertEqual(event.message_id, "75de5af9-a090-4325-87f9-8c599ad66f60")
231231
self.assertEqual(event.event_id, "OTdlOGUzYjctYjc5Zi00OWE4LWE4YWUtNjIxNjk2ZTJlNGVi")
232232
self.assertEqual(event.recipient, "[email protected]")
233233
self.assertEqual(event.user_agent, "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36")

tests/utils.py

+9
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import os
55
import re
66
import sys
7+
import uuid
78
import warnings
89
from base64 import b64decode
910
from contextlib import contextmanager
@@ -165,6 +166,14 @@ def assertEqualIgnoringHeaderFolding(self, first, second, msg=None):
165166
second = rfc822_unfold(second)
166167
self.assertEqual(first, second, msg)
167168

169+
def assertUUIDIsValid(self, uuid_str, version=4):
170+
"""Assert the uuid_str evaluates to a valid UUID"""
171+
try:
172+
uuid.UUID(uuid_str, version=version)
173+
except (ValueError, AttributeError, TypeError):
174+
return False
175+
return True
176+
168177

169178
# Backported from Python 3.4
170179
class _AssertLogsContext(object):

0 commit comments

Comments
 (0)