9
9
from .base import AnymailBaseWebhookView
10
10
from ..exceptions import AnymailWebhookValidationFailure
11
11
from ..signals import tracking , AnymailTrackingEvent , EventType , RejectReason
12
- from ..utils import get_anymail_setting , combine
12
+ from ..utils import get_anymail_setting , combine , querydict_getfirst
13
13
14
14
15
15
class MailgunBaseWebhookView (AnymailBaseWebhookView ):
@@ -28,6 +28,8 @@ def __init__(self, **kwargs):
28
28
def validate_request (self , request ):
29
29
super (MailgunBaseWebhookView , self ).validate_request (request ) # first check basic auth if enabled
30
30
try :
31
+ # Must use the *last* value of these fields if there are conflicting merged user-variables.
32
+ # (Fortunately, Django QueryDict is specced to return the last value.)
31
33
token = request .POST ['token' ]
32
34
timestamp = request .POST ['timestamp' ]
33
35
signature = str (request .POST ['signature' ]) # force to same type as hexdigest() (for python2)
@@ -75,27 +77,31 @@ class MailgunTrackingWebhookView(MailgunBaseWebhookView):
75
77
76
78
def esp_to_anymail_event (self , esp_event ):
77
79
# esp_event is a Django QueryDict (from request.POST),
78
- # which has multi-valued fields, but is *not* case-insensitive
79
-
80
- event_type = self .event_types .get (esp_event ['event' ], EventType .UNKNOWN )
81
- timestamp = datetime .fromtimestamp (int (esp_event ['timestamp' ]), tz = utc )
80
+ # which has multi-valued fields, but is *not* case-insensitive.
81
+ # Because of the way Mailgun merges user-variables into the event,
82
+ # we must generally use the *first* value of any multi-valued field
83
+ # to avoid potential conflicting user-data.
84
+ esp_event .getfirst = querydict_getfirst .__get__ (esp_event )
85
+
86
+ event_type = self .event_types .get (esp_event .getfirst ('event' ), EventType .UNKNOWN )
87
+ timestamp = datetime .fromtimestamp (int (esp_event ['timestamp' ]), tz = utc ) # use *last* value of timestamp
82
88
# Message-Id is not documented for every event, but seems to always be included.
83
89
# (It's sometimes spelled as 'message-id', lowercase, and missing the <angle-brackets>.)
84
- message_id = esp_event .get ('Message-Id' , esp_event .get ('message-id' , None ) )
90
+ message_id = esp_event .getfirst ('Message-Id' , None ) or esp_event .getfirst ('message-id' , None )
85
91
if message_id and not message_id .startswith ('<' ):
86
92
message_id = "<{}>" .format (message_id )
87
93
88
- description = esp_event .get ('description' , None )
89
- mta_response = esp_event .get ('error' , esp_event .get ('notification' , None ) )
94
+ description = esp_event .getfirst ('description' , None )
95
+ mta_response = esp_event .getfirst ('error' , None ) or esp_event .getfirst ('notification' , None )
90
96
reject_reason = None
91
97
try :
92
- mta_status = int (esp_event [ 'code' ] )
98
+ mta_status = int (esp_event . getfirst ( 'code' ) )
93
99
except (KeyError , TypeError ):
94
100
pass
95
101
except ValueError :
96
102
# RFC-3463 extended SMTP status code (class.subject.detail, where class is "2", "4" or "5")
97
103
try :
98
- status_class = esp_event [ 'code' ] .split ('.' )[0 ]
104
+ status_class = esp_event . getfirst ( 'code' ) .split ('.' )[0 ]
99
105
except (TypeError , IndexError ):
100
106
# illegal SMTP status code format
101
107
pass
@@ -107,37 +113,84 @@ def esp_to_anymail_event(self, esp_event):
107
113
RejectReason .BOUNCED if 400 <= mta_status < 600
108
114
else RejectReason .OTHER )
109
115
110
- # Mailgun merges metadata fields with the other event fields.
111
- # However, it also includes the original message headers,
112
- # which have the metadata separately as X-Mailgun-Variables.
113
- try :
114
- headers = json .loads (esp_event ['message-headers' ])
115
- except (KeyError , ):
116
- metadata = {}
117
- else :
118
- variables = [value for [field , value ] in headers
119
- if field == 'X-Mailgun-Variables' ]
120
- if len (variables ) >= 1 :
121
- # Each X-Mailgun-Variables value is JSON. Parse and merge them all into single dict:
122
- metadata = combine (* [json .loads (value ) for value in variables ])
123
- else :
124
- metadata = {}
116
+ metadata = self ._extract_metadata (esp_event )
125
117
126
- # tags are sometimes delivered as X-Mailgun-Tag fields, sometimes as tag
127
- tags = esp_event .getlist ('tag' , esp_event .getlist ('X-Mailgun-Tag' , []) )
118
+ # tags are supposed to be in 'tag' fields, but are sometimes in undocumented X-Mailgun-Tag
119
+ tags = esp_event .getlist ('tag' , None ) or esp_event .getlist ('X-Mailgun-Tag' , [])
128
120
129
121
return AnymailTrackingEvent (
130
122
event_type = event_type ,
131
123
timestamp = timestamp ,
132
124
message_id = message_id ,
133
- event_id = esp_event .get ('token' , None ),
134
- recipient = esp_event .get ('recipient' , None ),
125
+ event_id = esp_event .get ('token' , None ), # use *last* value of token
126
+ recipient = esp_event .getfirst ('recipient' , None ),
135
127
reject_reason = reject_reason ,
136
128
description = description ,
137
129
mta_response = mta_response ,
138
130
tags = tags ,
139
131
metadata = metadata ,
140
- click_url = esp_event .get ('url' , None ),
141
- user_agent = esp_event .get ('user-agent' , None ),
132
+ click_url = esp_event .getfirst ('url' , None ),
133
+ user_agent = esp_event .getfirst ('user-agent' , None ),
142
134
esp_event = esp_event ,
143
135
)
136
+
137
+ def _extract_metadata (self , esp_event ):
138
+ # Mailgun merges user-variables into the POST fields. If you know which user variable
139
+ # you want to retrieve--and it doesn't conflict with a Mailgun event field--that's fine.
140
+ # But if you want to extract all user-variables (like we do), it's more complicated...
141
+ event_type = esp_event .getfirst ('event' )
142
+ metadata = {}
143
+
144
+ if 'message-headers' in esp_event :
145
+ # For events where original message headers are available, it's most reliable
146
+ # to recover user-variables from the X-Mailgun-Variables header(s).
147
+ headers = json .loads (esp_event ['message-headers' ])
148
+ variables = [value for [field , value ] in headers if field == 'X-Mailgun-Variables' ]
149
+ if len (variables ) >= 1 :
150
+ # Each X-Mailgun-Variables value is JSON. Parse and merge them all into single dict:
151
+ metadata = combine (* [json .loads (value ) for value in variables ])
152
+
153
+ elif event_type in self ._known_event_fields :
154
+ # For other events, we must extract from the POST fields, ignoring known Mailgun
155
+ # event parameters, and treating all other values as user-variables.
156
+ known_fields = self ._known_event_fields [event_type ]
157
+ for field , values in esp_event .lists ():
158
+ if field not in known_fields :
159
+ # Unknown fields are assumed to be user-variables. (There should really only be
160
+ # a single value, but just in case take the last one to match QueryDict semantics.)
161
+ metadata [field ] = values [- 1 ]
162
+ elif field == 'tag' :
163
+ # There's no way to distinguish a user-variable named 'tag' from an actual tag,
164
+ # so don't treat this/these value(s) as metadata.
165
+ pass
166
+ elif len (values ) == 1 :
167
+ # This is an expected event parameter, and since there's only a single value
168
+ # it must be the event param, not metadata.
169
+ pass
170
+ else :
171
+ # This is an expected event parameter, but there are (at least) two values.
172
+ # One is the event param, and the other is a user-variable metadata value.
173
+ # Which is which depends on the field:
174
+ if field in {'signature' , 'timestamp' , 'token' }:
175
+ metadata [field ] = values [0 ] # values = [user-variable, event-param]
176
+ else :
177
+ metadata [field ] = values [- 1 ] # values = [event-param, user-variable]
178
+
179
+ return metadata
180
+
181
+ _common_event_fields = {
182
+ # These fields are documented to appear in all Mailgun opened, clicked and unsubscribed events:
183
+ 'event' , 'recipient' , 'domain' , 'ip' , 'country' , 'region' , 'city' , 'user-agent' , 'device-type' ,
184
+ 'client-type' , 'client-name' , 'client-os' , 'campaign-id' , 'campaign-name' , 'tag' , 'mailing-list' ,
185
+ 'timestamp' , 'token' , 'signature' ,
186
+ # Undocumented, but observed in actual events:
187
+ 'body-plain' , 'h' , 'message-id' ,
188
+ }
189
+ _known_event_fields = {
190
+ # For all Mailgun event types that *don't* include message-headers,
191
+ # map Mailgun (not normalized) event type to set of expected event fields.
192
+ # Used for metadata extraction.
193
+ 'clicked' : _common_event_fields | {'url' },
194
+ 'opened' : _common_event_fields ,
195
+ 'unsubscribed' : _common_event_fields ,
196
+ }
0 commit comments