mirror of
https://github.com/pacnpal/django-anymail.git
synced 2025-12-20 03:41:05 -05:00
Postmark: don't error on Cc/Bcc-only send; preserve recipient caps
Postmark docs notwithstanding, Postmark allows sending mail without a To field, as long as there is some recipient in Cc or Bcc. The API response has a slightly different shape in this case, and Anymail now handles that. Also updates related recipient status parsing. Previously, Anymail's Postmark backend converted all recipient emails to lowercase for status reporting, and omitted Cc or Bcc recipients from `message.anymail_status.recipients[email]`. Now, the backend preserves the case of each recipient email as originally sent, and includes Cc and Bcc status. Because client code may have been relying on lowercasing recipient emails to check status, this is a potentially breaking change. Fixes #135
This commit is contained in:
@@ -25,6 +25,28 @@ Release history
|
||||
^^^^^^^^^^^^^^^
|
||||
.. This extra heading level keeps the ToC from becoming unmanageably long
|
||||
|
||||
Unreleased
|
||||
----------
|
||||
|
||||
*In development*
|
||||
|
||||
Breaking changes
|
||||
~~~~~~~~~~~~~~~~
|
||||
|
||||
* **Postmark:** Anymail's `message.anymail_status.recipients[email]` no longer
|
||||
lowercases the recipient's email address. For consistency with other ESPs, it now
|
||||
uses the recipient email with whatever case was used in the sent message. If your
|
||||
code is doing something like `message.anymail_status.recipients[email.lower()]`,
|
||||
you should remove the `.lower()`
|
||||
|
||||
Fixes
|
||||
~~~~~
|
||||
|
||||
* **Postmark:** Don't error if a message is sent with only Cc and/or Bcc recipients
|
||||
(but no To addresses). Also, `message.anymail_status.recipients[email]` now includes
|
||||
send status for Cc and Bcc recipients. (Thanks to `@ailionx`_ for reporting the error.)
|
||||
|
||||
|
||||
v5.0
|
||||
----
|
||||
|
||||
@@ -879,6 +901,7 @@ Features
|
||||
.. _#112: https://github.com/anymail/issues/112
|
||||
.. _#115: https://github.com/anymail/issues/115
|
||||
|
||||
.. _@ailionx: https://github.com/ailionx
|
||||
.. _@calvin: https://github.com/calvin
|
||||
.. _@costela: https://github.com/costela
|
||||
.. _@decibyte: https://github.com/decibyte
|
||||
|
||||
@@ -2,7 +2,7 @@ import re
|
||||
|
||||
from ..exceptions import AnymailRequestsAPIError
|
||||
from ..message import AnymailRecipientStatus
|
||||
from ..utils import get_anymail_setting, parse_address_list
|
||||
from ..utils import get_anymail_setting, parse_address_list, CaseInsensitiveCasePreservingDict
|
||||
|
||||
from .base_requests import AnymailRequestsBackend, RequestsPayload
|
||||
|
||||
@@ -33,9 +33,13 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
super(EmailBackend, self).raise_for_status(response, payload, message)
|
||||
|
||||
def parse_recipient_status(self, response, payload, message):
|
||||
# default to "unknown" status for each recipient, unless/until we find otherwise
|
||||
# Default to "unknown" status for each recipient, unless/until we find otherwise.
|
||||
# (This also forces recipient_status email capitalization to match that as sent,
|
||||
# while correctly handling Postmark's lowercase-only inactive recipient reporting.)
|
||||
unknown_status = AnymailRecipientStatus(message_id=None, status='unknown')
|
||||
recipient_status = {to.addr_spec: unknown_status for to in payload.to_emails}
|
||||
recipient_status = CaseInsensitiveCasePreservingDict({
|
||||
recip.addr_spec: unknown_status
|
||||
for recip in payload.to_emails + payload.cc_and_bcc_emails})
|
||||
|
||||
parsed_response = self.deserialize_json_response(response, payload, message)
|
||||
if not isinstance(parsed_response, list):
|
||||
@@ -55,18 +59,34 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
if error_code == 0:
|
||||
# At least partial success, and (some) email was sent.
|
||||
try:
|
||||
to_header = one_response["To"]
|
||||
message_id = one_response["MessageID"]
|
||||
except KeyError:
|
||||
raise AnymailRequestsAPIError("Invalid Postmark API success response format",
|
||||
email_message=message, payload=payload,
|
||||
response=response, backend=self)
|
||||
|
||||
# Assume all To recipients are "sent" unless proven otherwise below.
|
||||
# (Must use "To" from API response to get correct individual MessageIDs in batch send.)
|
||||
try:
|
||||
to_header = one_response["To"] # (missing if cc- or bcc-only send)
|
||||
except KeyError:
|
||||
pass # cc- or bcc-only send; per-recipient status not available
|
||||
else:
|
||||
for to in parse_address_list(to_header):
|
||||
recipient_status[to.addr_spec.lower()] = AnymailRecipientStatus(
|
||||
recipient_status[to.addr_spec] = AnymailRecipientStatus(
|
||||
message_id=message_id, status='sent')
|
||||
|
||||
# Assume all Cc and Bcc recipients are "sent" unless proven otherwise below.
|
||||
# (Postmark doesn't report "Cc" or "Bcc" in API response; use original payload values.)
|
||||
for recip in payload.cc_and_bcc_emails:
|
||||
recipient_status[recip.addr_spec] = AnymailRecipientStatus(
|
||||
message_id=message_id, status='sent')
|
||||
|
||||
# Change "sent" to "rejected" if Postmark reported an address as "Inactive".
|
||||
# Sadly, have to parse human-readable message to figure out if everyone got it:
|
||||
# "Message OK, but will not deliver to these inactive addresses: {addr_spec, ...}.
|
||||
# Inactive recipients are ones that have generated a hard bounce or a spam complaint."
|
||||
# Note that error message emails are addr_spec only (no display names) and forced lowercase.
|
||||
reject_addr_specs = self._addr_specs_from_error_msg(
|
||||
msg, r'inactive addresses:\s*(.*)\.\s*Inactive recipients')
|
||||
for reject_addr_spec in reject_addr_specs:
|
||||
@@ -107,7 +127,7 @@ class EmailBackend(AnymailRequestsBackend):
|
||||
raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response,
|
||||
backend=self)
|
||||
|
||||
return recipient_status
|
||||
return dict(recipient_status)
|
||||
|
||||
@staticmethod
|
||||
def _addr_specs_from_error_msg(error_msg, pattern):
|
||||
@@ -134,6 +154,7 @@ class PostmarkPayload(RequestsPayload):
|
||||
}
|
||||
self.server_token = backend.server_token # added to headers later, so esp_extra can override
|
||||
self.to_emails = []
|
||||
self.cc_and_bcc_emails = [] # need to track (separately) for parse_recipient_status
|
||||
self.merge_data = None
|
||||
super(PostmarkPayload, self).__init__(message, defaults, backend, headers=headers, *args, **kwargs)
|
||||
|
||||
@@ -197,6 +218,8 @@ class PostmarkPayload(RequestsPayload):
|
||||
self.data[field] = ', '.join([email.address for email in emails])
|
||||
if recipient_type == "to":
|
||||
self.to_emails = emails
|
||||
else:
|
||||
self.cc_and_bcc_emails += emails
|
||||
|
||||
def set_subject(self, subject):
|
||||
self.data["Subject"] = subject
|
||||
|
||||
@@ -549,6 +549,27 @@ class PostmarkBackendAnymailFeatureTests(PostmarkBackendMockAPITestCase):
|
||||
'abcdef01-2345-6789-0123-456789abcdef')
|
||||
self.assertEqual(msg.anymail_status.esp_response.content, response_content)
|
||||
|
||||
# noinspection PyUnresolvedReferences
|
||||
def test_send_without_to_attaches_anymail_status(self):
|
||||
"""The anymail_status should be attached even if there are no `to` recipients"""
|
||||
# Despite Postmark's docs, the "To" field is *not* required if cc or bcc is provided.
|
||||
response_content = b"""{
|
||||
"SubmittedAt": "2019-01-28T13:54:35.5813997-05:00",
|
||||
"MessageID":"abcdef01-2345-6789-0123-456789abcdef",
|
||||
"ErrorCode":0,
|
||||
"Message":"OK"
|
||||
}"""
|
||||
self.set_mock_response(raw=response_content)
|
||||
msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', cc=['cc@example.com'],)
|
||||
sent = msg.send()
|
||||
self.assertEqual(sent, 1)
|
||||
self.assertEqual(msg.anymail_status.status, {'sent'})
|
||||
self.assertEqual(msg.anymail_status.message_id, 'abcdef01-2345-6789-0123-456789abcdef')
|
||||
self.assertEqual(msg.anymail_status.recipients['cc@example.com'].status, 'sent')
|
||||
self.assertEqual(msg.anymail_status.recipients['cc@example.com'].message_id,
|
||||
'abcdef01-2345-6789-0123-456789abcdef')
|
||||
self.assertEqual(msg.anymail_status.esp_response.content, response_content)
|
||||
|
||||
# noinspection PyUnresolvedReferences
|
||||
def test_send_failed_anymail_status(self):
|
||||
""" If the send fails, anymail_status should contain initial values"""
|
||||
@@ -596,11 +617,11 @@ class PostmarkBackendRecipientsRefusedTests(PostmarkBackendMockAPITestCase):
|
||||
b'Inactive recipients are ones that have generated a hard bounce or a spam complaint."}'
|
||||
)
|
||||
msg = mail.EmailMessage('Subject', 'Body', 'from@example.com',
|
||||
['hardbounce@example.com', 'Hates Spam <spam@example.com>'])
|
||||
['HardBounce@example.com', 'Hates Spam <spam@example.com>'])
|
||||
with self.assertRaises(AnymailRecipientsRefused):
|
||||
msg.send()
|
||||
status = msg.anymail_status
|
||||
self.assertEqual(status.recipients['hardbounce@example.com'].status, 'rejected')
|
||||
self.assertEqual(status.recipients['HardBounce@example.com'].status, 'rejected')
|
||||
self.assertEqual(status.recipients['spam@example.com'].status, 'rejected')
|
||||
|
||||
def test_recipients_invalid(self):
|
||||
@@ -608,11 +629,11 @@ class PostmarkBackendRecipientsRefusedTests(PostmarkBackendMockAPITestCase):
|
||||
status_code=422,
|
||||
raw=b"""{"ErrorCode":300,"Message":"Invalid 'To' address: 'invalid@localhost'."}"""
|
||||
)
|
||||
msg = mail.EmailMessage('Subject', 'Body', 'from@example.com', ['invalid@localhost'])
|
||||
msg = mail.EmailMessage('Subject', 'Body', 'from@example.com', ['Invalid@LocalHost'])
|
||||
with self.assertRaises(AnymailRecipientsRefused):
|
||||
msg.send()
|
||||
status = msg.anymail_status
|
||||
self.assertEqual(status.recipients['invalid@localhost'].status, 'invalid')
|
||||
self.assertEqual(status.recipients['Invalid@LocalHost'].status, 'invalid')
|
||||
|
||||
def test_from_email_invalid(self):
|
||||
# Invalid 'From' address generates same Postmark ErrorCode 300 as invalid 'To',
|
||||
@@ -634,10 +655,10 @@ class PostmarkBackendRecipientsRefusedTests(PostmarkBackendMockAPITestCase):
|
||||
b'Inactive recipients are ones that have generated a hard bounce or a spam complaint."}'
|
||||
)
|
||||
msg = mail.EmailMessage('Subject', 'Body', 'from@example.com',
|
||||
['hardbounce@example.com', 'Hates Spam <spam@example.com>'])
|
||||
['HardBounce@example.com', 'Hates Spam <spam@example.com>'])
|
||||
msg.send(fail_silently=True)
|
||||
status = msg.anymail_status
|
||||
self.assertEqual(status.recipients['hardbounce@example.com'].status, 'rejected')
|
||||
self.assertEqual(status.recipients['HardBounce@example.com'].status, 'rejected')
|
||||
self.assertEqual(status.recipients['spam@example.com'].status, 'rejected')
|
||||
|
||||
@override_settings(ANYMAIL_IGNORE_RECIPIENT_STATUS=True)
|
||||
@@ -650,10 +671,10 @@ class PostmarkBackendRecipientsRefusedTests(PostmarkBackendMockAPITestCase):
|
||||
b'Inactive recipients are ones that have generated a hard bounce or a spam complaint. "}'
|
||||
)
|
||||
msg = mail.EmailMessage('Subject', 'Body', 'from@example.com',
|
||||
['hardbounce@example.com', 'Hates Spam <spam@example.com>'])
|
||||
['HardBounce@example.com', 'Hates Spam <spam@example.com>'])
|
||||
msg.send()
|
||||
status = msg.anymail_status
|
||||
self.assertEqual(status.recipients['hardbounce@example.com'].status, 'rejected')
|
||||
self.assertEqual(status.recipients['HardBounce@example.com'].status, 'rejected')
|
||||
self.assertEqual(status.recipients['spam@example.com'].status, 'rejected')
|
||||
|
||||
def test_mixed_response(self):
|
||||
@@ -669,11 +690,11 @@ class PostmarkBackendRecipientsRefusedTests(PostmarkBackendMockAPITestCase):
|
||||
b' Inactive recipients are ones that have generated a hard bounce or a spam complaint."}'
|
||||
)
|
||||
msg = mail.EmailMessage('Subject', 'Body', 'from@example.com',
|
||||
['hardbounce@example.com', 'valid@example.com', 'Hates Spam <spam@example.com>'])
|
||||
['HardBounce@example.com', 'valid@example.com', 'Hates Spam <spam@example.com>'])
|
||||
sent = msg.send()
|
||||
self.assertEqual(sent, 1) # one message sent, successfully, to 1 of 3 recipients
|
||||
status = msg.anymail_status
|
||||
self.assertEqual(status.recipients['hardbounce@example.com'].status, 'rejected')
|
||||
self.assertEqual(status.recipients['HardBounce@example.com'].status, 'rejected')
|
||||
self.assertEqual(status.recipients['valid@example.com'].status, 'sent')
|
||||
self.assertEqual(status.recipients['spam@example.com'].status, 'rejected')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user