diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 370015b..ee9b66c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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 diff --git a/anymail/backends/postmark.py b/anymail/backends/postmark.py index f46f6ca..635b89f 100644 --- a/anymail/backends/postmark.py +++ b/anymail/backends/postmark.py @@ -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) - for to in parse_address_list(to_header): - recipient_status[to.addr_spec.lower()] = AnymailRecipientStatus( + + # 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] = 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 diff --git a/tests/test_postmark_backend.py b/tests/test_postmark_backend.py index 87038fa..50bf1d3 100644 --- a/tests/test_postmark_backend.py +++ b/tests/test_postmark_backend.py @@ -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 ']) + ['HardBounce@example.com', 'Hates Spam ']) 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 ']) + ['HardBounce@example.com', 'Hates Spam ']) 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 ']) + ['HardBounce@example.com', 'Hates Spam ']) 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 ']) + ['HardBounce@example.com', 'valid@example.com', 'Hates Spam ']) 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')