22
33from ..exceptions import AnymailRequestsAPIError
44from ..message import AnymailRecipientStatus
5- from ..utils import get_anymail_setting
5+ from ..utils import get_anymail_setting , parse_address_list
66
77from .base_requests import AnymailRequestsBackend , RequestsPayload
88
@@ -33,58 +33,90 @@ def raise_for_status(self, response, payload, message):
3333 super (EmailBackend , self ).raise_for_status (response , payload , message )
3434
3535 def parse_recipient_status (self , response , payload , message ):
36+ # default to "unknown" status for each recipient, unless/until we find otherwise
37+ unknown_status = AnymailRecipientStatus (message_id = None , status = 'unknown' )
38+ recipient_status = {to .addr_spec : unknown_status for to in payload .to_emails }
39+
3640 parsed_response = self .deserialize_json_response (response , payload , message )
37- try :
38- error_code = parsed_response ["ErrorCode" ]
39- msg = parsed_response ["Message" ]
40- except (KeyError , TypeError ):
41- raise AnymailRequestsAPIError ("Invalid Postmark API response format" ,
42- email_message = message , payload = payload , response = response ,
43- backend = self )
44-
45- message_id = parsed_response .get ("MessageID" , None )
46- rejected_emails = []
47-
48- if error_code == 300 : # Invalid email request
49- # Either the From address or at least one recipient was invalid. Email not sent.
50- if "'From' address" in msg :
51- # Normal error
41+ if not isinstance (parsed_response , list ):
42+ # non-batch calls return a single response object
43+ parsed_response = [parsed_response ]
44+
45+ for one_response in parsed_response :
46+ try :
47+ # these fields should always be present
48+ error_code = one_response ["ErrorCode" ]
49+ msg = one_response ["Message" ]
50+ except (KeyError , TypeError ):
51+ raise AnymailRequestsAPIError ("Invalid Postmark API response format" ,
52+ email_message = message , payload = payload , response = response ,
53+ backend = self )
54+
55+ if error_code == 0 :
56+ # At least partial success, and (some) email was sent.
57+ try :
58+ to_header = one_response ["To" ]
59+ message_id = one_response ["MessageID" ]
60+ except KeyError :
61+ raise AnymailRequestsAPIError ("Invalid Postmark API success response format" ,
62+ email_message = message , payload = payload ,
63+ response = response , backend = self )
64+ for to in parse_address_list (to_header ):
65+ recipient_status [to .addr_spec .lower ()] = AnymailRecipientStatus (
66+ message_id = message_id , status = 'sent' )
67+ # Sadly, have to parse human-readable message to figure out if everyone got it:
68+ # "Message OK, but will not deliver to these inactive addresses: {addr_spec, ...}.
69+ # Inactive recipients are ones that have generated a hard bounce or a spam complaint."
70+ reject_addr_specs = self ._addr_specs_from_error_msg (
71+ msg , r'inactive addresses:\s*(.*)\.\s*Inactive recipients' )
72+ for reject_addr_spec in reject_addr_specs :
73+ recipient_status [reject_addr_spec ] = AnymailRecipientStatus (
74+ message_id = None , status = 'rejected' )
75+
76+ elif error_code == 300 : # Invalid email request
77+ # Either the From address or at least one recipient was invalid. Email not sent.
78+ # response["To"] is not populated for this error; must examine response["Message"]:
79+ # "Invalid 'To' address: '{addr_spec}'."
80+ # "Error parsing 'To': Illegal email domain '{domain}' in address '{addr_spec}'."
81+ # "Error parsing 'To': Illegal email address '{addr_spec}'. It must contain the '@' symbol."
82+ # "Invalid 'From' address: '{email_address}'."
83+ if "'From' address" in msg :
84+ # Normal error
85+ raise AnymailRequestsAPIError (email_message = message , payload = payload , response = response ,
86+ backend = self )
87+ else :
88+ # Use AnymailRecipientsRefused logic
89+ invalid_addr_specs = self ._addr_specs_from_error_msg (msg , r"address:?\s*'(.*)'" )
90+ for invalid_addr_spec in invalid_addr_specs :
91+ recipient_status [invalid_addr_spec ] = AnymailRecipientStatus (
92+ message_id = None , status = 'invalid' )
93+
94+ elif error_code == 406 : # Inactive recipient
95+ # All recipients were rejected as hard-bounce or spam-complaint. Email not sent.
96+ # response["To"] is not populated for this error; must examine response["Message"]:
97+ # "You tried to send to a recipient that has been marked as inactive.\n
98+ # Found inactive addresses: {addr_spec, ...}.\n
99+ # Inactive recipients are ones that have generated a hard bounce or a spam complaint. "
100+ reject_addr_specs = self ._addr_specs_from_error_msg (
101+ msg , r'inactive addresses:\s*(.*)\.\s*Inactive recipients' )
102+ for reject_addr_spec in reject_addr_specs :
103+ recipient_status [reject_addr_spec ] = AnymailRecipientStatus (
104+ message_id = None , status = 'rejected' )
105+
106+ else : # Other error
52107 raise AnymailRequestsAPIError (email_message = message , payload = payload , response = response ,
53108 backend = self )
54- else :
55- # Use AnymailRecipientsRefused logic
56- default_status = 'invalid'
57- elif error_code == 406 : # Inactive recipient
58- # All recipients were rejected as hard-bounce or spam-complaint. Email not sent.
59- default_status = 'rejected'
60- elif error_code == 0 :
61- # At least partial success, and email was sent.
62- # Sadly, have to parse human-readable message to figure out if everyone got it.
63- default_status = 'sent'
64- rejected_emails = self .parse_inactive_recipients (msg )
65- else :
66- raise AnymailRequestsAPIError (email_message = message , payload = payload , response = response ,
67- backend = self )
68-
69- return {
70- recipient .addr_spec : AnymailRecipientStatus (
71- message_id = message_id ,
72- status = ('rejected' if recipient .addr_spec .lower () in rejected_emails
73- else default_status )
74- )
75- for recipient in payload .all_recipients
76- }
77109
78- def parse_inactive_recipients (self , msg ):
79- """Return a list of 'inactive' email addresses from a Postmark "OK" response
110+ return recipient_status
111+
112+ @staticmethod
113+ def _addr_specs_from_error_msg (error_msg , pattern ):
114+ """Extract a list of email addr_specs from Postmark error_msg.
80115
81- :param str msg: the "Message" from the Postmark API response
116+ pattern must be a re whose first group matches a comma-separated
117+ list of addr_specs in the message
82118 """
83- # Example msg with inactive recipients:
84- # "Message OK, but will not deliver to these inactive addresses: [email protected] , [email protected] ." 85- # " Inactive recipients are ones that have generated a hard bounce or a spam complaint."
86- # Example msg with everything OK: "OK"
87- match = re .search (r'inactive addresses:\s*(.*)\.\s*Inactive recipients' , msg )
119+ match = re .search (pattern , error_msg , re .MULTILINE )
88120 if match :
8912190122 return [email .strip ().lower () for email in emails .split (',' )]
@@ -101,23 +133,50 @@ def __init__(self, message, defaults, backend, *args, **kwargs):
101133 # 'X-Postmark-Server-Token': see get_request_params (and set_esp_extra)
102134 }
103135 self .server_token = backend .server_token # added to headers later, so esp_extra can override
104- self .all_recipients = [] # used for backend.parse_recipient_status
136+ self .to_emails = []
137+ self .merge_data = None
105138 super (PostmarkPayload , self ).__init__ (message , defaults , backend , headers = headers , * args , ** kwargs )
106139
107140 def get_api_endpoint (self ):
141+ batch_send = self .merge_data is not None and len (self .to_emails ) > 1
108142 if 'TemplateId' in self .data or 'TemplateModel' in self .data :
109- # This is the one Postmark API documented to have a trailing slash. (Typo?)
110- return "email/withTemplate/"
143+ if batch_send :
144+ return "email/batchWithTemplates"
145+ else :
146+ # This is the one Postmark API documented to have a trailing slash. (Typo?)
147+ return "email/withTemplate/"
111148 else :
112- return "email"
149+ if batch_send :
150+ return "email/batch"
151+ else :
152+ return "email"
113153
114154 def get_request_params (self , api_url ):
115155 params = super (PostmarkPayload , self ).get_request_params (api_url )
116156 params ['headers' ]['X-Postmark-Server-Token' ] = self .server_token
117157 return params
118158
119159 def serialize_data (self ):
120- return self .serialize_json (self .data )
160+ data = self .data
161+ api_endpoint = self .get_api_endpoint ()
162+ if api_endpoint == "email/batchWithTemplates" :
163+ data = {"Messages" : [self .data_for_recipient (to ) for to in self .to_emails ]}
164+ elif api_endpoint == "email/batch" :
165+ data = [self .data_for_recipient (to ) for to in self .to_emails ]
166+ return self .serialize_json (data )
167+
168+ def data_for_recipient (self , to ):
169+ data = self .data .copy ()
170+ data ["To" ] = to .address
171+ if self .merge_data and to .addr_spec in self .merge_data :
172+ recipient_data = self .merge_data [to .addr_spec ]
173+ if "TemplateModel" in data :
174+ # merge recipient_data into merge_global_data
175+ data ["TemplateModel" ] = data ["TemplateModel" ].copy ()
176+ data ["TemplateModel" ].update (recipient_data )
177+ else :
178+ data ["TemplateModel" ] = recipient_data
179+ return data
121180
122181 #
123182 # Payload construction
@@ -136,7 +195,8 @@ def set_recipients(self, recipient_type, emails):
136195 if emails :
137196 field = recipient_type .capitalize ()
138197 self .data [field ] = ', ' .join ([email .address for email in emails ])
139- self .all_recipients += emails # used for backend.parse_recipient_status
198+ if recipient_type == "to" :
199+ self .to_emails = emails
140200
141201 def set_subject (self , subject ):
142202 self .data ["Subject" ] = subject
@@ -204,7 +264,9 @@ def set_template_id(self, template_id):
204264 if field in self .data and not self .data [field ]:
205265 del self .data [field ]
206266
207- # merge_data: Postmark doesn't support per-recipient substitutions
267+ def set_merge_data (self , merge_data ):
268+ # late-bind
269+ self .merge_data = merge_data
208270
209271 def set_merge_global_data (self , merge_global_data ):
210272 self .data ["TemplateModel" ] = merge_global_data
0 commit comments