diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 79b586a..6f5383c 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -3,23 +3,23 @@ We welcome all contributions: issue reports, bug fixes, documentation improvemen new features, ideas and suggestions, and anything else to help improve the package. Before posting **questions** or **issues** in GitHub, please check out -[*Getting support*][support] and [*Troubleshooting*][troubleshooting] +[_Getting support_][support] and [_Troubleshooting_][troubleshooting] in the Anymail docs. Also… -> …when you're reporting a problem or bug, it's *really helpful* to include: -> * which **ESP** you're using (Mailgun, SendGrid, etc.) -> * what **versions** of Anymail, Django, and Python you're running -> * any error messages and exception stack traces -> * the relevant portions of your code and settings -> * any [troubleshooting] you've tried +> …when you're reporting a problem or bug, it's _really helpful_ to include: +> +> - which **ESP** you're using (Mailgun, SendGrid, etc.) +> - what **versions** of Anymail, Django, and Python you're running +> - any error messages and exception stack traces +> - the relevant portions of your code and settings +> - any [troubleshooting] you've tried For more info on **pull requests** and the **development** environment, -please see [*Contributing*][contributing] in the docs. For significant +please see [_Contributing_][contributing] in the docs. For significant new features or breaking changes, it's always a good idea to propose the idea in the [discussions] forum before writing a lot of code. - [contributing]: https://anymail.readthedocs.io/en/stable/contributing/ [discussions]: https://github.com/anymail/django-anymail/discussions [support]: https://anymail.readthedocs.io/en/stable/help/#support diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index d54c2ba..98326cf 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1,9 +1,9 @@ Reporting an error? It's helpful to know: -* Anymail version -* ESP (Mailgun, SendGrid, etc.) -* Your ANYMAIL settings (change secrets to "redacted") -* Versions of Django, requests, python -* Exact error message and/or stack trace -* Any other relevant code and settings (e.g., for problems +- Anymail version +- ESP (Mailgun, SendGrid, etc.) +- Your ANYMAIL settings (change secrets to "redacted") +- Versions of Django, requests, python +- Exact error message and/or stack trace +- Any other relevant code and settings (e.g., for problems sending, your code that sends the message) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 31cf16f..b4599b9 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -3,15 +3,14 @@ name: integration-test on: pull_request: push: - branches: [ "main", "v[0-9]*" ] - tags: [ "v[0-9]*" ] + branches: ["main", "v[0-9]*"] + tags: ["v[0-9]*"] workflow_dispatch: schedule: # Weekly test (on branch main) every Thursday at 12:15 UTC. # (Used to monitor compatibility with ESP API changes.) - cron: "15 12 * * 4" - jobs: skip_duplicate_runs: # Avoid running the live integration tests twice on the same code diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dff9160..b15a9fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ name: release on: push: - tags: [ "v[0-9]*" ] + tags: ["v[0-9]*"] workflow_dispatch: jobs: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b4c084c..8ba0f8b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,15 +3,14 @@ name: test on: pull_request: push: - branches: [ "main", "v[0-9]*" ] - tags: [ "v[0-9]*" ] + branches: ["main", "v[0-9]*"] + tags: ["v[0-9]*"] workflow_dispatch: schedule: # Weekly test (on branch main) every Thursday at 12:00 UTC. # (Used to monitor compatibility with Django patches/dev and other dependencies.) - cron: "0 12 * * 4" - jobs: get-envlist: runs-on: ubuntu-20.04 diff --git a/ADDING_ESPS.md b/ADDING_ESPS.md index f48eade..6fd7b81 100644 --- a/ADDING_ESPS.md +++ b/ADDING_ESPS.md @@ -10,25 +10,23 @@ for you. This document adds general background and covers some design decisions that aren't necessarily obvious from the code. - ## Getting started -* Don't want to do *all* of this? **That's OK!** A partial PR +- Don't want to do _all_ of this? **That's OK!** A partial PR is better than no PR. And opening a work-in-progress PR early is a really good idea. -* Don't want to do *any* of this? Use GitHub issues to request +- Don't want to do _any_ of this? Use GitHub issues to request support for other ESPs in Anymail. -* It's often easiest to copy and modify the existing code - for an ESP with a similar API. There are some hints in each +- It's often easiest to copy and modify the existing code + for an ESP with a similar API. There are some hints in each section below what might be "similar". - ### Which ESPs? -Anymail is best suited to *transactional* ESP APIs. The Django core -mail package it builds on isn't a good match for most *bulk* mail APIs. +Anymail is best suited to _transactional_ ESP APIs. The Django core +mail package it builds on isn't a good match for most _bulk_ mail APIs. (If you can't specify an individual recipient email address and at -least some of the message content, it's probably not a transactional API.) +least some of the message content, it's probably not a transactional API.) Similarly, Anymail is best suited to ESPs that offer some value-added features beyond simply sending email. If you'd get exactly the same @@ -38,8 +36,7 @@ SMTP endpoint, there's really no need to add it to Anymail. We strongly prefer ESPs where we'll be able to run live integration tests regularly. That requires the ESP have a free tier (testing is extremely low volume), a sandbox API, or that they offer developer -accounts for open source projects like Anymail. - +accounts for open source projects like Anymail. ## EmailBackend and payload @@ -48,63 +45,61 @@ your code should be able to focus on the ESP-specific parts. You'll subclass a backend and a payload for your ESP implementation: -* Backend (subclass `AnymailBaseBackend` or `AnymailRequestsBackend`): +- Backend (subclass `AnymailBaseBackend` or `AnymailRequestsBackend`): implements Django's email API, orchestrates the overall sending process for multiple messages. - -* Payload (subclass `BasePayload` or `RequestsPayload`) + +- Payload (subclass `BasePayload` or `RequestsPayload`) implements conversion of a single Django `EmailMessage` to parameters for the ESP API. -Whether you start from the base or requests version depends on whether +Whether you start from the base or requests version depends on whether you'll be using an ESP client library or calling their HTTP API directly. - ### Client lib or HTTP API? Which to pick? It's a bit of a judgement call: -* Often, ESP Python client libraries don't seem to be actively maintained. +- Often, ESP Python client libraries don't seem to be actively maintained. Definitely avoid those. -* Some client libraries are just thin wrappers around Requests (or urllib). +- Some client libraries are just thin wrappers around Requests (or urllib). There's little value in using those, and you'll lose some optimizations built into `AnymailRequestsBackend`. -* Surprisingly often, client libraries (unintentionally) impose limitations +- Surprisingly often, client libraries (unintentionally) impose limitations that are more restrictive than than (or conflict with) the underlying ESP API. You should report those bugs against the library. (Or if they were already reported a long time ago, see the first point above.) -* Some ESP APIs have really complex (or obscure) payload formats, +- Some ESP APIs have really complex (or obscure) payload formats, or authorization schemes that are non-trivial to implement in Requests. - If the client library handles this for you, it's a better choice. + If the client library handles this for you, it's a better choice. When in doubt, it's usually fine to use `AnymailRequestsBackend` and write directly to the HTTP API. - ### Requests backend (using HTTP API) Good staring points for similar ESP APIs: -* JSON payload: Postmark -* Form-encoded payload: Mailgun + +- JSON payload: Postmark +- Form-encoded payload: Mailgun Different API endpoints for (e.g.,) template vs. regular send? -Implement `get_api_endpoint()` in your Payload. +Implement `get_api_endpoint()` in your Payload. Need to encode JSON in the payload? Use `self.serialize_json()` (it has some extra error handling). Need to parse JSON in the API response? Use `self.deserialize_json_response()` (same reason). - ### Base backend (using client lib) Good starting points: Test backend; SparkPost -Don't forget add an `'extras_require'` entry for your ESP in setup.py. +Don't forget add an `'extras_require'` entry for your ESP in setup.py. Also update `'tests_require'`. If the client lib supports the notion of a reusable API "connection" @@ -112,13 +107,11 @@ If the client lib supports the notion of a reusable API "connection" API state caching. See the notes in the base implementation. (The RequestsBackend implements this using Requests sessions.) - ### Payloads Look for the "Abstract implementation" comment in `base.BasePayload`. Your payload should consider implementing everything below there. - #### Email addresses All payload methods dealing with email addresses (recipients, from, etc.) are @@ -129,10 +122,10 @@ passed `anymail.utils.EmailAddress` objects, so you don't have to parse them. For recipients, you can implement whichever of these Payload methods is most convenient for the ESP API: -* `set_to(emails)`, `set_cc(emails)`, and `set_bcc(emails)` -* `set_recipients(type, emails)` -* `add_recipient(type, email)` +- `set_to(emails)`, `set_cc(emails)`, and `set_bcc(emails)` +- `set_recipients(type, emails)` +- `add_recipient(type, email)` #### Attachments @@ -147,101 +140,98 @@ with angle brackets, and `att.cid` is without angle brackets. Use `att.base64content` if your ESP wants base64-encoded data. - #### AnymailUnsupportedFeature and validating parameters Should your payload use `self.unsupported_feature()`? The rule of thumb is: -* If it *cannot be accurately communicated* to the ESP API, that's unsupported. +- If it _cannot be accurately communicated_ to the ESP API, that's unsupported. E.g., the user provided multiple `tags` but the ESP's "Tag" parameter only accepts a (single) string value. -* Anymail avoids enforcing ESP policies (because these tend to change over time, and - we don't want to update our code). So if it *can* be accurately communicated to the - ESP API, that's *not* unsupported---even if the ESP docs say it's not allowed. +- Anymail avoids enforcing ESP policies (because these tend to change over time, and + we don't want to update our code). So if it _can_ be accurately communicated to the + ESP API, that's _not_ unsupported---even if the ESP docs say it's not allowed. E.g., the user provided 10 `tags`, the ESP's "Tags" parameter accepts a list, but is documented maximum 3 tags. Anymail should pass the list of 10 tags, and let the ESP error if it chooses. Similarly, Anymail doesn't enforce allowed attachment types, maximum attachment size, -maximum number of recipients, etc. That's the ESP's responsibility. +maximum number of recipients, etc. That's the ESP's responsibility. -One exception: if the ESP mis-handles certain input (e.g., drops the message -but returns "success"; mangles email display names), and seems unlikely to fix the problem, -we'll typically add a warning or workaround to Anymail. +One exception: if the ESP mis-handles certain input (e.g., drops the message but +returns "success"; mangles email display names), and seems unlikely to fix the problem, +we'll typically add a warning or workaround to Anymail. (As well as reporting the problem to the ESP.) - #### Batch send and splitting `to` -One of the more complicated Payload functions is handling multiple `to` addresses properly. +One of the more complicated Payload functions is handling multiple `to` addresses +properly. -If the user has set `merge_data`, a separate message should get sent to each `to` address, -and recipients should not see the full To list. If `merge_data` is not set, a single message -should be sent with all addresses in the To header. +If the user has set `merge_data`, a separate message should get sent to each `to` +address, and recipients should not see the full To list. If `merge_data` is not set, +a single message should be sent with all addresses in the To header. Most backends handle this in the Payload's `serialize_data` method, by restructuring the payload if `merge_data` is not None. - #### Tests Every backend needs mock tests, that use a mocked API to verify the ESP is being called -correctly. It's often easiest to copy and modify the backend tests for an ESP with a similar -API. - -Ideally, every backend should also have live integration tests, because sometimes the docs -don't quite match the real world. (And because ESPs have been known to change APIs without -notice.) Anymail's CI runs the live integration tests at least weekly. +correctly. It's often easiest to copy and modify the backend tests for an ESP with +a similar API. +Ideally, every backend should also have live integration tests, because sometimes the +docs don't quite match the real world. (And because ESPs have been known to change APIs +without notice.) Anymail's CI runs the live integration tests at least weekly. ## Webhooks -ESP webhook documentation is *almost always* vague on at least some aspects of the webhook -event data, and example payloads in their docs are often outdated (and/or manually constructed -and inaccurate). - -Runscope (or similar) is an extremely useful tool for collecting actual webhook payloads. +ESP webhook documentation is _almost always_ vague on at least some aspects of the +webhook event data, and example payloads in their docs are often outdated (and/or +manually constructed and inaccurate). +Runscope (or similar) is an extremely useful tool for collecting actual webhook +payloads. ### Tracking webhooks Good starting points: -* JSON event payload: SendGrid, Postmark -* Form data event payload: Mailgun - +- JSON event payload: SendGrid, Postmark +- Form data event payload: Mailgun + (more to come) ### Inbound webhooks -Raw MIME vs. ESP-parsed fields? If you're given both, the raw MIME is usually easier to work with. +Raw MIME vs. ESP-parsed fields? If you're given both, the raw MIME is usually easier +to work with. (more to come) - ## Project goals Anymail aims to: -* Normalize common transactional ESP functionality (to simplify +- Normalize common transactional ESP functionality (to simplify switching between ESPs) -* But allow access to the full ESP feature set, through - `esp_extra` (so Anymail doesn't force users into +- But allow access to the full ESP feature set, through + `esp_extra` (so Anymail doesn't force users into least-common-denominator functionality, or prevent use of newly-released ESP features) -* Present a Pythonic, Djangotic API, and play well with Django +- Present a Pythonic, Djangotic API, and play well with Django and other Django reusable apps -* Maintain compatibility with all currently supported Django versions---and - even unsupported minor versions in between (so Anymail isn't the package +- Maintain compatibility with all currently supported Django versions---and + even unsupported minor versions in between (so Anymail isn't the package that forces you to upgrade Django---or that prevents you from upgrading when you're ready) -Many of these goals incorporate lessons learned from Anymail's predecessor -Djrill project. And they mean that django-anymail is biased toward implementing -*relatively* thin wrappers over ESP transactional sending, tracking, and receiving APIs. +Many of these goals incorporate lessons learned from Anymail's predecessor +Djrill project. And they mean that django-anymail is biased toward implementing +_relatively_ thin wrappers over ESP transactional sending, tracking, and receiving APIs. Anything that would add Django models to Anymail is probably out of scope. (But could be a great companion package.) diff --git a/anymail/__init__.py b/anymail/__init__.py index 41ebe06..f7032f1 100644 --- a/anymail/__init__.py +++ b/anymail/__init__.py @@ -1,7 +1,7 @@ # Expose package version at root of package -from ._version import __version__, VERSION # NOQA: F401 - - from django import VERSION as DJANGO_VERSION + +from ._version import VERSION, __version__ # NOQA: F401 + if DJANGO_VERSION < (3, 2, 0): - default_app_config = 'anymail.apps.AnymailBaseConfig' + default_app_config = "anymail.apps.AnymailBaseConfig" diff --git a/anymail/_version.py b/anymail/_version.py index 540bc32..2f2fbc6 100644 --- a/anymail/_version.py +++ b/anymail/_version.py @@ -1,3 +1,7 @@ VERSION = (9, 0) -__version__ = '.'.join([str(x) for x in VERSION]) # major.minor.patch or major.minor.devN -__minor_version__ = '.'.join([str(x) for x in VERSION[:2]]) # Sphinx's X.Y "version" + +#: major.minor.patch or major.minor.devN +__version__ = ".".join([str(x) for x in VERSION]) + +#: Sphinx's X.Y "version" +__minor_version__ = ".".join([str(x) for x in VERSION[:2]]) diff --git a/anymail/apps.py b/anymail/apps.py index d0bdc14..2367cd1 100644 --- a/anymail/apps.py +++ b/anymail/apps.py @@ -5,7 +5,7 @@ from .checks import check_deprecated_settings, check_insecure_settings class AnymailBaseConfig(AppConfig): - name = 'anymail' + name = "anymail" verbose_name = "Anymail" def ready(self): diff --git a/anymail/backends/amazon_ses.py b/anymail/backends/amazon_ses.py index 9063350..d6eeec2 100644 --- a/anymail/backends/amazon_ses.py +++ b/anymail/backends/amazon_ses.py @@ -1,18 +1,20 @@ -from email.charset import Charset, QP +from email.charset import QP, Charset from email.mime.text import MIMEText -from .base import AnymailBaseBackend, BasePayload from .._version import __version__ from ..exceptions import AnymailAPIError, AnymailImproperlyInstalled from ..message import AnymailRecipientStatus -from ..utils import get_anymail_setting, UNSET +from ..utils import UNSET, get_anymail_setting +from .base import AnymailBaseBackend, BasePayload try: import boto3 from botocore.client import Config from botocore.exceptions import BotoCoreError, ClientError, ConnectionError except ImportError as err: - raise AnymailImproperlyInstalled(missing_package='boto3', backend='amazon_ses') from err + raise AnymailImproperlyInstalled( + missing_package="boto3", backend="amazon_ses" + ) from err # boto3 has several root exception classes; this is meant to cover all of them @@ -29,19 +31,34 @@ class EmailBackend(AnymailBaseBackend): def __init__(self, **kwargs): """Init options from Django settings""" super().__init__(**kwargs) - # AMAZON_SES_CLIENT_PARAMS is optional - boto3 can find credentials several other ways - self.session_params, self.client_params = _get_anymail_boto3_params(kwargs=kwargs) - self.configuration_set_name = get_anymail_setting("configuration_set_name", esp_name=self.esp_name, - kwargs=kwargs, allow_bare=False, default=None) - self.message_tag_name = get_anymail_setting("message_tag_name", esp_name=self.esp_name, - kwargs=kwargs, allow_bare=False, default=None) + # AMAZON_SES_CLIENT_PARAMS is optional + # (boto3 can find credentials several other ways) + self.session_params, self.client_params = _get_anymail_boto3_params( + kwargs=kwargs + ) + self.configuration_set_name = get_anymail_setting( + "configuration_set_name", + esp_name=self.esp_name, + kwargs=kwargs, + allow_bare=False, + default=None, + ) + self.message_tag_name = get_anymail_setting( + "message_tag_name", + esp_name=self.esp_name, + kwargs=kwargs, + allow_bare=False, + default=None, + ) self.client = None def open(self): if self.client: return False # already exists try: - self.client = boto3.session.Session(**self.session_params).client("ses", **self.client_params) + self.client = boto3.session.Session(**self.session_params).client( + "ses", **self.client_params + ) except BOTO_BASE_ERRORS: if not self.fail_silently: raise @@ -51,7 +68,7 @@ class EmailBackend(AnymailBaseBackend): def close(self): if self.client is None: return - # self.client.close() # boto3 doesn't currently seem to support (or require) this + # self.client.close() # boto3 doesn't support (or require) client shutdown self.client = None def build_message_payload(self, message, defaults): @@ -66,9 +83,15 @@ class EmailBackend(AnymailBaseBackend): try: response = payload.call_send_api(self.client) except BOTO_BASE_ERRORS as err: - # ClientError has a response attr with parsed json error response (other errors don't) - raise AnymailAPIError(str(err), backend=self, email_message=message, payload=payload, - response=getattr(err, 'response', None)) from err + # ClientError has a response attr with parsed json error response + # (other errors don't) + raise AnymailAPIError( + str(err), + backend=self, + email_message=message, + payload=payload, + response=getattr(err, "response", None), + ) from err return response def parse_recipient_status(self, response, payload, message): @@ -105,15 +128,19 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload): # (which is Django email's default for most non-ASCII bodies) # - you are using an SES ConfigurationSet with open or click tracking enabled # then SES replaces the non-ASCII characters with question marks as it rewrites - # the message to add tracking. Forcing `CTE: quoted-printable` avoids the problem. - # (https://forums.aws.amazon.com/thread.jspa?threadID=287048) + # the message to add tracking. Forcing `CTE: quoted-printable` avoids the + # problem. (https://forums.aws.amazon.com/thread.jspa?threadID=287048) for part in self.mime_message.walk(): - if part.get_content_maintype() == "text" and part["Content-Transfer-Encoding"] == "8bit": + if ( + part.get_content_maintype() == "text" + and part["Content-Transfer-Encoding"] == "8bit" + ): content = part.get_payload() del part["Content-Transfer-Encoding"] qp_charset = Charset(part.get_content_charset("us-ascii")) qp_charset.body_encoding = QP - # (can't use part.set_payload, because SafeMIMEText can undo this workaround) + # (can't use part.set_payload, because SafeMIMEText can undo + # this workaround) MIMEText.set_payload(part, content, charset=qp_charset) def call_send_api(self, ses_client): @@ -121,9 +148,7 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload): # Any non-ASCII characters in recipient domains must be encoded with Punycode. # (Amazon SES doesn't support non-ASCII recipient usernames.) self.params["Destinations"] = [email.address for email in self.all_recipients] - self.params["RawMessage"] = { - "Data": self.mime_message.as_bytes() - } + self.params["RawMessage"] = {"Data": self.mime_message.as_bytes()} return ses_client.send_raw_email(**self.params) def parse_recipient_status(self, response): @@ -132,23 +157,34 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload): except (KeyError, TypeError) as err: raise AnymailAPIError( "%s parsing Amazon SES send result %r" % (str(err), response), - backend=self.backend, email_message=self.message, payload=self) from None + backend=self.backend, + email_message=self.message, + payload=self, + ) from None - recipient_status = AnymailRecipientStatus(message_id=message_id, status="queued") - return {recipient.addr_spec: recipient_status for recipient in self.all_recipients} + recipient_status = AnymailRecipientStatus( + message_id=message_id, status="queued" + ) + return { + recipient.addr_spec: recipient_status for recipient in self.all_recipients + } # Standard EmailMessage attrs... - # These all get rolled into the RFC-5322 raw mime directly via EmailMessage.message() + # These all get rolled into the RFC-5322 raw mime directly via + # EmailMessage.message() def _no_send_defaults(self, attr): # Anymail global send defaults don't work for standard attrs, because the # merged/computed value isn't forced back into the EmailMessage. if attr in self.defaults: - self.unsupported_feature("Anymail send defaults for '%s' with Amazon SES" % attr) + self.unsupported_feature( + "Anymail send defaults for '%s' with Amazon SES" % attr + ) def set_from_email_list(self, emails): - # Although Amazon SES will send messages with any From header, it can only parse Source - # if the From header is a single email. Explicit Source avoids an "Illegal address" error: + # Although Amazon SES will send messages with any From header, it can only parse + # Source if the From header is a single email. Explicit Source avoids an + # "Illegal address" error: if len(emails) > 1: self.params["Source"] = emails[0].addr_spec # (else SES will look at the (single) address in the From header) @@ -212,29 +248,38 @@ class AmazonSESSendRawEmailPayload(AmazonSESBasePayload): # notifications for SES *events* but not SES *notifications*. (Got that?) # Message Tags also allow *very* limited characters in both name and value. # Message Tags can be sent with any SES send call. - # (See "How do message tags work?" in https://aws.amazon.com/blogs/ses/introducing-sending-metrics/ + # (See "How do message tags work?" in + # https://aws.amazon.com/blogs/ses/introducing-sending-metrics/ # and https://forums.aws.amazon.com/thread.jspa?messageID=782922.) - # To support reliable retrieval in webhooks, just use custom headers for metadata. + # To support reliable retrieval in webhooks, just use custom headers for + # metadata. self.mime_message["X-Metadata"] = self.serialize_json(metadata) def set_tags(self, tags): - # See note about Amazon SES Message Tags and custom headers in set_metadata above. - # To support reliable retrieval in webhooks, use custom headers for tags. + # See note about Amazon SES Message Tags and custom headers in set_metadata + # above. To support reliable retrieval in webhooks, use custom headers for tags. # (There are no restrictions on number or content for custom header tags.) for tag in tags: - self.mime_message.add_header("X-Tag", tag) # creates multiple X-Tag headers, one per tag + # creates multiple X-Tag headers, one per tag: + self.mime_message.add_header("X-Tag", tag) # Also *optionally* pass a single Message Tag if the AMAZON_SES_MESSAGE_TAG_NAME - # Anymail setting is set (default no). The AWS API restricts tag content in this case. - # (This is useful for dashboard segmentation; use esp_extra["Tags"] for anything more complex.) + # Anymail setting is set (default no). The AWS API restricts tag content in this + # case. (This is useful for dashboard segmentation; use esp_extra["Tags"] for + # anything more complex.) if tags and self.backend.message_tag_name is not None: if len(tags) > 1: - self.unsupported_feature("multiple tags with the AMAZON_SES_MESSAGE_TAG_NAME setting") + self.unsupported_feature( + "multiple tags with the AMAZON_SES_MESSAGE_TAG_NAME setting" + ) self.params.setdefault("Tags", []).append( - {"Name": self.backend.message_tag_name, "Value": tags[0]}) + {"Name": self.backend.message_tag_name, "Value": tags[0]} + ) def set_template_id(self, template_id): - raise NotImplementedError("AmazonSESSendRawEmailPayload should not have been used with template_id") + raise NotImplementedError( + "AmazonSESSendRawEmailPayload should not have been used with template_id" + ) def set_merge_data(self, merge_data): self.unsupported_feature("merge_data without template_id") @@ -254,15 +299,24 @@ class AmazonSESSendBulkTemplatedEmailPayload(AmazonSESBasePayload): # include any 'cc' or 'bcc' in every destination cc_and_bcc_addresses = {} if self.recipients["cc"]: - cc_and_bcc_addresses["CcAddresses"] = [cc.address for cc in self.recipients["cc"]] + cc_and_bcc_addresses["CcAddresses"] = [ + cc.address for cc in self.recipients["cc"] + ] if self.recipients["bcc"]: - cc_and_bcc_addresses["BccAddresses"] = [bcc.address for bcc in self.recipients["bcc"]] + cc_and_bcc_addresses["BccAddresses"] = [ + bcc.address for bcc in self.recipients["bcc"] + ] # set up destination and data for each 'to' - self.params["Destinations"] = [{ - "Destination": dict(ToAddresses=[to.address], **cc_and_bcc_addresses), - "ReplacementTemplateData": self.serialize_json(self.merge_data.get(to.addr_spec, {})) - } for to in self.recipients["to"]] + self.params["Destinations"] = [ + { + "Destination": dict(ToAddresses=[to.address], **cc_and_bcc_addresses), + "ReplacementTemplateData": self.serialize_json( + self.merge_data.get(to.addr_spec, {}) + ), + } + for to in self.recipients["to"] + ] return ses_client.send_bulk_templated_email(**self.params) @@ -272,25 +326,33 @@ class AmazonSESSendBulkTemplatedEmailPayload(AmazonSESBasePayload): anymail_statuses = [ AnymailRecipientStatus( message_id=status.get("MessageId", None), - status='queued' if status.get("Status") == "Success" else 'failed') + status="queued" if status.get("Status") == "Success" else "failed", + ) for status in response["Status"] ] except (KeyError, TypeError) as err: raise AnymailAPIError( "%s parsing Amazon SES send result %r" % (str(err), response), - backend=self.backend, email_message=self.message, payload=self) from None + backend=self.backend, + email_message=self.message, + payload=self, + ) from None to_addrs = [to.addr_spec for to in self.recipients["to"]] if len(anymail_statuses) != len(to_addrs): raise AnymailAPIError( - "Sent to %d destinations, but only %d statuses in Amazon SES send result %r" - % (len(to_addrs), len(anymail_statuses), response), - backend=self.backend, email_message=self.message, payload=self) + "Sent to %d destinations, but only %d statuses in Amazon SES" + " send result %r" % (len(to_addrs), len(anymail_statuses), response), + backend=self.backend, + email_message=self.message, + payload=self, + ) return dict(zip(to_addrs, anymail_statuses)) def set_from_email(self, email): - self.params["Source"] = email.address # this will RFC2047-encode display_name if needed + # this will RFC2047-encode display_name if needed: + self.params["Source"] = email.address def set_recipients(self, recipient_type, emails): # late-bound in call_send_api @@ -330,16 +392,23 @@ class AmazonSESSendBulkTemplatedEmailPayload(AmazonSESBasePayload): self.unsupported_feature("metadata with template") def set_tags(self, tags): - # no custom headers with SendBulkTemplatedEmail, but support AMAZON_SES_MESSAGE_TAG_NAME if used - # (see tags/metadata in AmazonSESSendRawEmailPayload for more info) + # no custom headers with SendBulkTemplatedEmail, but support + # AMAZON_SES_MESSAGE_TAG_NAME if used (see tags/metadata in + # AmazonSESSendRawEmailPayload for more info) if tags: if self.backend.message_tag_name is not None: if len(tags) > 1: - self.unsupported_feature("multiple tags with the AMAZON_SES_MESSAGE_TAG_NAME setting") - self.params["DefaultTags"] = [{"Name": self.backend.message_tag_name, "Value": tags[0]}] + self.unsupported_feature( + "multiple tags with the AMAZON_SES_MESSAGE_TAG_NAME setting" + ) + self.params["DefaultTags"] = [ + {"Name": self.backend.message_tag_name, "Value": tags[0]} + ] else: self.unsupported_feature( - "tags with template (unless using the AMAZON_SES_MESSAGE_TAG_NAME setting)") + "tags with template (unless using the" + " AMAZON_SES_MESSAGE_TAG_NAME setting)" + ) def set_template_id(self, template_id): self.params["Template"] = template_id @@ -363,13 +432,20 @@ def _get_anymail_boto3_params(esp_name=EmailBackend.esp_name, kwargs=None): May remove keys from kwargs, but won't modify original settings """ # (shared with ..webhooks.amazon_ses) - session_params = get_anymail_setting("session_params", esp_name=esp_name, kwargs=kwargs, default={}) - client_params = get_anymail_setting("client_params", esp_name=esp_name, kwargs=kwargs, default={}) + session_params = get_anymail_setting( + "session_params", esp_name=esp_name, kwargs=kwargs, default={} + ) + client_params = get_anymail_setting( + "client_params", esp_name=esp_name, kwargs=kwargs, default={} + ) # Add Anymail user-agent, and convert config dict to botocore.client.Config client_params = client_params.copy() # don't modify source - config = Config(user_agent_extra="django-anymail/{version}-{esp}".format( - esp=esp_name.lower().replace(" ", "-"), version=__version__)) + config = Config( + user_agent_extra="django-anymail/{version}-{esp}".format( + esp=esp_name.lower().replace(" ", "-"), version=__version__ + ) + ) if "config" in client_params: # convert config dict to botocore.client.Config if needed client_params_config = client_params["config"] diff --git a/anymail/backends/base.py b/anymail/backends/base.py index a9a860c..0168fe1 100644 --- a/anymail/backends/base.py +++ b/anymail/backends/base.py @@ -3,17 +3,31 @@ from datetime import date, datetime, timezone from django.conf import settings from django.core.mail.backends.base import BaseEmailBackend -from django.utils.timezone import is_naive, get_current_timezone, make_aware +from django.utils.timezone import get_current_timezone, is_naive, make_aware from requests.structures import CaseInsensitiveDict from ..exceptions import ( - AnymailCancelSend, AnymailError, AnymailUnsupportedFeature, AnymailRecipientsRefused, - AnymailSerializationError) + AnymailCancelSend, + AnymailError, + AnymailRecipientsRefused, + AnymailSerializationError, + AnymailUnsupportedFeature, +) from ..message import AnymailStatus -from ..signals import pre_send, post_send +from ..signals import post_send, pre_send from ..utils import ( - Attachment, UNSET, combine, last, get_anymail_setting, parse_address_list, parse_single_address, - force_non_lazy, force_non_lazy_list, force_non_lazy_dict, is_lazy) + UNSET, + Attachment, + combine, + force_non_lazy, + force_non_lazy_dict, + force_non_lazy_list, + get_anymail_setting, + is_lazy, + last, + parse_address_list, + parse_single_address, +) class AnymailBaseBackend(BaseEmailBackend): @@ -24,17 +38,23 @@ class AnymailBaseBackend(BaseEmailBackend): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.ignore_unsupported_features = get_anymail_setting('ignore_unsupported_features', - kwargs=kwargs, default=False) - self.ignore_recipient_status = get_anymail_setting('ignore_recipient_status', - kwargs=kwargs, default=False) - self.debug_api_requests = get_anymail_setting('debug_api_requests', # generate debug output - kwargs=kwargs, default=False) + self.ignore_unsupported_features = get_anymail_setting( + "ignore_unsupported_features", kwargs=kwargs, default=False + ) + self.ignore_recipient_status = get_anymail_setting( + "ignore_recipient_status", kwargs=kwargs, default=False + ) + self.debug_api_requests = get_anymail_setting( + "debug_api_requests", kwargs=kwargs, default=False + ) # Merge SEND_DEFAULTS and _SEND_DEFAULTS settings - send_defaults = get_anymail_setting('send_defaults', default={}) # but not from kwargs - esp_send_defaults = get_anymail_setting('send_defaults', esp_name=self.esp_name, - kwargs=kwargs, default=None) + send_defaults = get_anymail_setting( + "send_defaults", default={} # but not from kwargs + ) + esp_send_defaults = get_anymail_setting( + "send_defaults", esp_name=self.esp_name, kwargs=kwargs, default=None + ) if esp_send_defaults is not None: send_defaults = send_defaults.copy() send_defaults.update(esp_send_defaults) @@ -128,7 +148,9 @@ class AnymailBaseBackend(BaseEmailBackend): message.anymail_status.set_recipient_status(recipient_status) self.run_post_send(message) # send signal before raising status errors - self.raise_for_recipient_status(message.anymail_status, response, payload, message) + self.raise_for_recipient_status( + message.anymail_status, response, payload, message + ) return True @@ -143,8 +165,12 @@ class AnymailBaseBackend(BaseEmailBackend): def run_post_send(self, message): """Send post_send signal to all receivers""" results = post_send.send_robust( - self.__class__, message=message, status=message.anymail_status, esp_name=self.esp_name) - for (receiver, response) in results: + self.__class__, + message=message, + status=message.anymail_status, + esp_name=self.esp_name, + ) + for receiver, response in results: if isinstance(response, Exception): raise response @@ -161,8 +187,10 @@ class AnymailBaseBackend(BaseEmailBackend): :param defaults: dict :return: :class:BasePayload """ - raise NotImplementedError("%s.%s must implement build_message_payload" % - (self.__class__.__module__, self.__class__.__name__)) + raise NotImplementedError( + "%s.%s must implement build_message_payload" + % (self.__class__.__module__, self.__class__.__name__) + ) def post_to_esp(self, payload, message): """Post payload to ESP send API endpoint, and return the raw response. @@ -173,25 +201,35 @@ class AnymailBaseBackend(BaseEmailBackend): Can raise AnymailAPIError (or derived exception) for problems posting to the ESP """ - raise NotImplementedError("%s.%s must implement post_to_esp" % - (self.__class__.__module__, self.__class__.__name__)) + raise NotImplementedError( + "%s.%s must implement post_to_esp" + % (self.__class__.__module__, self.__class__.__name__) + ) def parse_recipient_status(self, response, payload, message): """Return a dict mapping email to AnymailRecipientStatus for each recipient. Can raise AnymailAPIError (or derived exception) if response is unparsable """ - raise NotImplementedError("%s.%s must implement parse_recipient_status" % - (self.__class__.__module__, self.__class__.__name__)) + raise NotImplementedError( + "%s.%s must implement parse_recipient_status" + % (self.__class__.__module__, self.__class__.__name__) + ) def raise_for_recipient_status(self, anymail_status, response, payload, message): - """If *all* recipients are refused or invalid, raises AnymailRecipientsRefused""" + """ + If *all* recipients are refused or invalid, raises AnymailRecipientsRefused + """ if not self.ignore_recipient_status: - # Error if *all* recipients are invalid or refused - # (This behavior parallels smtplib.SMTPRecipientsRefused from Django's SMTP EmailBackend) + # Error if *all* recipients are invalid or refused. (This behavior parallels + # smtplib.SMTPRecipientsRefused from Django's SMTP EmailBackend.) if anymail_status.status.issubset({"invalid", "rejected"}): - raise AnymailRecipientsRefused(email_message=message, payload=payload, response=response, - backend=self) + raise AnymailRecipientsRefused( + email_message=message, + payload=payload, + response=response, + backend=self, + ) @property def esp_name(self): @@ -202,8 +240,10 @@ class AnymailBaseBackend(BaseEmailBackend): esp_name = "Postmark" esp_name = "SendGrid" # (use ESP's preferred capitalization) """ - raise NotImplementedError("%s.%s must declare esp_name class attr" % - (self.__class__.__module__, self.__class__.__name__)) + raise NotImplementedError( + "%s.%s must declare esp_name class attr" + % (self.__class__.__module__, self.__class__.__name__) + ) class BasePayload: @@ -222,36 +262,36 @@ class BasePayload: # the combined/converted results for each attr. base_message_attrs = ( # Standard EmailMessage/EmailMultiAlternatives props - ('from_email', last, parse_address_list), # multiple from_emails are allowed - ('to', combine, parse_address_list), - ('cc', combine, parse_address_list), - ('bcc', combine, parse_address_list), - ('subject', last, force_non_lazy), - ('reply_to', combine, parse_address_list), - ('extra_headers', combine, force_non_lazy_dict), - ('body', last, force_non_lazy), # special handling below checks message.content_subtype - ('alternatives', combine, 'prepped_alternatives'), - ('attachments', combine, 'prepped_attachments'), + ("from_email", last, parse_address_list), # multiple from_emails are allowed + ("to", combine, parse_address_list), + ("cc", combine, parse_address_list), + ("bcc", combine, parse_address_list), + ("subject", last, force_non_lazy), + ("reply_to", combine, parse_address_list), + ("extra_headers", combine, force_non_lazy_dict), + ("body", last, force_non_lazy), # set_body handles content_subtype + ("alternatives", combine, "prepped_alternatives"), + ("attachments", combine, "prepped_attachments"), ) anymail_message_attrs = ( # Anymail expando-props - ('envelope_sender', last, parse_single_address), - ('metadata', combine, force_non_lazy_dict), - ('send_at', last, 'aware_datetime'), - ('tags', combine, force_non_lazy_list), - ('track_clicks', last, None), - ('track_opens', last, None), - ('template_id', last, force_non_lazy), - ('merge_data', combine, force_non_lazy_dict), - ('merge_global_data', combine, force_non_lazy_dict), - ('merge_metadata', combine, force_non_lazy_dict), - ('esp_extra', combine, force_non_lazy_dict), + ("envelope_sender", last, parse_single_address), + ("metadata", combine, force_non_lazy_dict), + ("send_at", last, "aware_datetime"), + ("tags", combine, force_non_lazy_list), + ("track_clicks", last, None), + ("track_opens", last, None), + ("template_id", last, force_non_lazy), + ("merge_data", combine, force_non_lazy_dict), + ("merge_global_data", combine, force_non_lazy_dict), + ("merge_metadata", combine, force_non_lazy_dict), + ("esp_extra", combine, force_non_lazy_dict), ) esp_message_attrs = () # subclasses can override # If any of these attrs are set on a message, treat the message # as a batch send (separate message for each `to` recipient): - batch_attrs = ('merge_data', 'merge_metadata') + batch_attrs = ("merge_data", "merge_metadata") def __init__(self, message, defaults, backend): self.message = message @@ -262,11 +302,16 @@ class BasePayload: self.init_payload() - # we should consider hoisting the first text/html out of alternatives into set_html_body - message_attrs = self.base_message_attrs + self.anymail_message_attrs + self.esp_message_attrs + # we should consider hoisting the first text/html + # out of alternatives into set_html_body + message_attrs = ( + self.base_message_attrs + + self.anymail_message_attrs + + self.esp_message_attrs + ) for attr, combiner, converter in message_attrs: value = getattr(message, attr, UNSET) - if attr in ('to', 'cc', 'bcc', 'reply_to') and value is not UNSET: + if attr in ("to", "cc", "bcc", "reply_to") and value is not UNSET: self.validate_not_bare_string(attr, value) if combiner is not None: default_value = self.defaults.get(attr, UNSET) @@ -281,16 +326,17 @@ class BasePayload: else: value = converter(value) if value is not UNSET: - if attr == 'from_email': + if attr == "from_email": setter = self.set_from_email_list - elif attr == 'extra_headers': + elif attr == "extra_headers": setter = self.process_extra_headers else: - # AttributeError here? Your Payload subclass is missing a set_ implementation - setter = getattr(self, 'set_%s' % attr) + # AttributeError here? Your Payload subclass is missing + # a set_ implementation + setter = getattr(self, "set_%s" % attr) setter(value) if attr in self.batch_attrs: - self._batch_attrs_used[attr] = (value is not UNSET) + self._batch_attrs_used[attr] = value is not UNSET def is_batch(self): """ @@ -301,45 +347,62 @@ class BasePayload: inside a set_ method or during __init__). """ batch_attrs_used = self._batch_attrs_used.values() - assert UNSET not in batch_attrs_used, "Cannot call is_batch before all attributes processed" + assert ( + UNSET not in batch_attrs_used + ), "Cannot call is_batch before all attributes processed" return any(batch_attrs_used) def unsupported_feature(self, feature): if not self.backend.ignore_unsupported_features: - raise AnymailUnsupportedFeature("%s does not support %s" % (self.esp_name, feature), - email_message=self.message, payload=self, backend=self.backend) + raise AnymailUnsupportedFeature( + "%s does not support %s" % (self.esp_name, feature), + email_message=self.message, + payload=self, + backend=self.backend, + ) def process_extra_headers(self, headers): # Handle some special-case headers, and pass the remainder to set_extra_headers. # (Subclasses shouldn't need to override this.) - headers = CaseInsensitiveDict(headers) # email headers are case-insensitive per RFC-822 et seq - reply_to = headers.pop('Reply-To', None) + # email headers are case-insensitive per RFC-822 et seq: + headers = CaseInsensitiveDict(headers) + + reply_to = headers.pop("Reply-To", None) if reply_to: # message.extra_headers['Reply-To'] will override message.reply_to # (because the extra_headers attr is processed after reply_to). # This matches the behavior of Django's EmailMessage.message(). - self.set_reply_to(parse_address_list([reply_to], field="extra_headers['Reply-To']")) + self.set_reply_to( + parse_address_list([reply_to], field="extra_headers['Reply-To']") + ) - if 'From' in headers: - # If message.extra_headers['From'] is supplied, it should override message.from_email, - # but message.from_email should be used as the envelope_sender. See: - # - https://code.djangoproject.com/ticket/9214 - # - https://github.com/django/django/blob/1.8/django/core/mail/message.py#L269 - # - https://github.com/django/django/blob/1.8/django/core/mail/backends/smtp.py#L118 - header_from = parse_address_list(headers.pop('From'), field="extra_headers['From']") - envelope_sender = parse_single_address(self.message.from_email, field="from_email") # must be single + if "From" in headers: + # If message.extra_headers['From'] is supplied, it should override + # message.from_email, but message.from_email should be used as the + # envelope_sender. See: + # https://code.djangoproject.com/ticket/9214 + # https://github.com/django/django/blob/1.8/django/core/mail/message.py#L269 + # https://github.com/django/django/blob/1.8/django/core/mail/backends/smtp.py#L118 + header_from = parse_address_list( + headers.pop("From"), field="extra_headers['From']" + ) + # sender must be single: + envelope_sender = parse_single_address( + self.message.from_email, field="from_email" + ) self.set_from_email_list(header_from) self.set_envelope_sender(envelope_sender) - if 'To' in headers: - # If message.extra_headers['To'] is supplied, message.to is used only as the envelope - # recipients (SMTP.sendmail to_addrs), and the header To is spoofed. See: - # - https://github.com/django/django/blob/1.8/django/core/mail/message.py#L270 - # - https://github.com/django/django/blob/1.8/django/core/mail/backends/smtp.py#L119-L120 + if "To" in headers: + # If message.extra_headers['To'] is supplied, message.to is used only as + # the envelope recipients (SMTP.sendmail to_addrs), and the header To is + # spoofed. See: + # https://github.com/django/django/blob/1.8/django/core/mail/message.py#L270 + # https://github.com/django/django/blob/1.8/django/core/mail/backends/smtp.py#L119-L120 # No current ESP supports this, so this code is mainly here to flag # the SMTP backend's behavior as an unsupported feature in Anymail: - header_to = headers.pop('To') + header_to = headers.pop("To") self.set_spoofed_to_header(header_to) if headers: @@ -363,20 +426,25 @@ class BasePayload: # TypeError: can only concatenate list (not "str") to list # TypeError: Can't convert 'list' object to str implicitly if isinstance(value, str) or is_lazy(value): - raise TypeError('"{attr}" attribute must be a list or other iterable'.format(attr=attr)) + raise TypeError( + '"{attr}" attribute must be a list or other iterable'.format(attr=attr) + ) # # Attribute converters # def prepped_alternatives(self, alternatives): - return [(force_non_lazy(content), mimetype) - for (content, mimetype) in alternatives] + return [ + (force_non_lazy(content), mimetype) for (content, mimetype) in alternatives + ] def prepped_attachments(self, attachments): str_encoding = self.message.encoding or settings.DEFAULT_CHARSET - return [Attachment(attachment, str_encoding) # (handles lazy content, filename) - for attachment in attachments] + return [ + Attachment(attachment, str_encoding) # (handles lazy content, filename) + for attachment in attachments + ] def aware_datetime(self, value): """Converts a date or datetime or timestamp to an aware datetime. @@ -406,12 +474,14 @@ class BasePayload: # def init_payload(self): - raise NotImplementedError("%s.%s must implement init_payload" % - (self.__class__.__module__, self.__class__.__name__)) + raise NotImplementedError( + "%s.%s must implement init_payload" + % (self.__class__.__module__, self.__class__.__name__) + ) def set_from_email_list(self, emails): - # If your backend supports multiple from emails, override this to handle the whole list; - # otherwise just implement set_from_email + # If your backend supports multiple from emails, override this to handle + # the whole list; otherwise just implement set_from_email if len(emails) > 1: self.unsupported_feature("multiple from emails") # fall through if ignoring unsupported features @@ -419,36 +489,42 @@ class BasePayload: self.set_from_email(emails[0]) def set_from_email(self, email): - raise NotImplementedError("%s.%s must implement set_from_email or set_from_email_list" % - (self.__class__.__module__, self.__class__.__name__)) + raise NotImplementedError( + "%s.%s must implement set_from_email or set_from_email_list" + % (self.__class__.__module__, self.__class__.__name__) + ) def set_to(self, emails): - return self.set_recipients('to', emails) + return self.set_recipients("to", emails) def set_cc(self, emails): - return self.set_recipients('cc', emails) + return self.set_recipients("cc", emails) def set_bcc(self, emails): - return self.set_recipients('bcc', emails) + return self.set_recipients("bcc", emails) def set_recipients(self, recipient_type, emails): for email in emails: self.add_recipient(recipient_type, email) def add_recipient(self, recipient_type, email): - raise NotImplementedError("%s.%s must implement add_recipient, set_recipients, or set_{to,cc,bcc}" % - (self.__class__.__module__, self.__class__.__name__)) + raise NotImplementedError( + "%s.%s must implement add_recipient, set_recipients, or set_{to,cc,bcc}" + % (self.__class__.__module__, self.__class__.__name__) + ) def set_subject(self, subject): - raise NotImplementedError("%s.%s must implement set_subject" % - (self.__class__.__module__, self.__class__.__name__)) + raise NotImplementedError( + "%s.%s must implement set_subject" + % (self.__class__.__module__, self.__class__.__name__) + ) def set_reply_to(self, emails): - self.unsupported_feature('reply_to') + self.unsupported_feature("reply_to") def set_extra_headers(self, headers): # headers is a CaseInsensitiveDict, and is a copy (so is safe to modify) - self.unsupported_feature('extra_headers') + self.unsupported_feature("extra_headers") def set_body(self, body): # Interpret message.body depending on message.content_subtype. @@ -463,12 +539,16 @@ class BasePayload: self.add_alternative(body, "text/%s" % content_subtype) def set_text_body(self, body): - raise NotImplementedError("%s.%s must implement set_text_body" % - (self.__class__.__module__, self.__class__.__name__)) + raise NotImplementedError( + "%s.%s must implement set_text_body" + % (self.__class__.__module__, self.__class__.__name__) + ) def set_html_body(self, body): - raise NotImplementedError("%s.%s must implement set_html_body" % - (self.__class__.__module__, self.__class__.__name__)) + raise NotImplementedError( + "%s.%s must implement set_html_body" + % (self.__class__.__module__, self.__class__.__name__) + ) def set_alternatives(self, alternatives): # Handle treating first text/{plain,html} alternatives as bodies. @@ -499,12 +579,15 @@ class BasePayload: self.add_attachment(attachment) def add_attachment(self, attachment): - raise NotImplementedError("%s.%s must implement add_attachment or set_attachments" % - (self.__class__.__module__, self.__class__.__name__)) + raise NotImplementedError( + "%s.%s must implement add_attachment or set_attachments" + % (self.__class__.__module__, self.__class__.__name__) + ) def set_spoofed_to_header(self, header_to): - # In the unlikely case an ESP supports *completely replacing* the To message header - # without altering the actual envelope recipients, the backend can implement this. + # In the unlikely case an ESP supports *completely replacing* the To message + # header without altering the actual envelope recipients, the backend can + # implement this. self.unsupported_feature("spoofing `To` header") # Anymail-specific payload construction @@ -557,13 +640,18 @@ class BasePayload: return json.dumps(data, default=self._json_default) except TypeError as err: # Add some context to the "not JSON serializable" message - raise AnymailSerializationError(orig_err=err, email_message=self.message, - backend=self.backend, payload=self) from None + raise AnymailSerializationError( + orig_err=err, + email_message=self.message, + backend=self.backend, + payload=self, + ) from None @staticmethod def _json_default(o): """json.dump default function that handles some common Payload data types""" if isinstance(o, CaseInsensitiveDict): # used for headers return dict(o) - raise TypeError("Object of type '%s' is not JSON serializable" % - o.__class__.__name__) + raise TypeError( + "Object of type '%s' is not JSON serializable" % o.__class__.__name__ + ) diff --git a/anymail/backends/base_requests.py b/anymail/backends/base_requests.py index c0b9ff9..3bc7ab1 100644 --- a/anymail/backends/base_requests.py +++ b/anymail/backends/base_requests.py @@ -3,9 +3,10 @@ from urllib.parse import urljoin import requests from anymail.utils import get_anymail_setting -from .base import AnymailBaseBackend, BasePayload + from .._version import __version__ from ..exceptions import AnymailRequestsAPIError +from .base import AnymailBaseBackend, BasePayload class AnymailRequestsBackend(AnymailBaseBackend): @@ -16,7 +17,9 @@ class AnymailRequestsBackend(AnymailBaseBackend): def __init__(self, api_url, **kwargs): """Init options from Django settings""" self.api_url = api_url - self.timeout = get_anymail_setting('requests_timeout', kwargs=kwargs, default=30) + self.timeout = get_anymail_setting( + "requests_timeout", kwargs=kwargs, default=30 + ) super().__init__(**kwargs) self.session = None @@ -49,7 +52,10 @@ class AnymailRequestsBackend(AnymailBaseBackend): raise RuntimeError( "Session has not been opened in {class_name}._send. " "(This is either an implementation error in {class_name}, " - "or you are incorrectly calling _send directly.)".format(class_name=class_name)) + "or you are incorrectly calling _send directly.)".format( + class_name=class_name + ) + ) return super()._send(message) def create_session(self): @@ -61,11 +67,13 @@ class AnymailRequestsBackend(AnymailBaseBackend): session = requests.Session() session.headers["User-Agent"] = "django-anymail/{version}-{esp} {orig}".format( - esp=self.esp_name.lower(), version=__version__, - orig=session.headers.get("User-Agent", "")) + esp=self.esp_name.lower(), + version=__version__, + orig=session.headers.get("User-Agent", ""), + ) if self.debug_api_requests: - session.hooks['response'].append(self._dump_api_request) + session.hooks["response"].append(self._dump_api_request) return session @@ -79,16 +87,20 @@ class AnymailRequestsBackend(AnymailBaseBackend): Can raise AnymailRequestsAPIError for HTTP errors in the post """ params = payload.get_request_params(self.api_url) - params.setdefault('timeout', self.timeout) + params.setdefault("timeout", self.timeout) try: response = self.session.request(**params) except requests.RequestException as err: # raise an exception that is both AnymailRequestsAPIError # and the original requests exception type - exc_class = type('AnymailRequestsAPIError', (AnymailRequestsAPIError, type(err)), {}) + exc_class = type( + "AnymailRequestsAPIError", (AnymailRequestsAPIError, type(err)), {} + ) raise exc_class( - "Error posting to %s:" % params.get('url', ''), - email_message=message, payload=payload) from err + "Error posting to %s:" % params.get("url", ""), + email_message=message, + payload=payload, + ) from err self.raise_for_status(response, payload, message) return response @@ -101,8 +113,8 @@ class AnymailRequestsBackend(AnymailBaseBackend): """ if response.status_code < 200 or response.status_code >= 300: raise AnymailRequestsAPIError( - email_message=message, payload=payload, - response=response, backend=self) + email_message=message, payload=payload, response=response, backend=self + ) def deserialize_json_response(self, response, payload, message): """Deserialize an ESP API response that's in json. @@ -112,9 +124,13 @@ class AnymailRequestsBackend(AnymailBaseBackend): try: return response.json() except ValueError as err: - raise AnymailRequestsAPIError("Invalid JSON in %s API response" % self.esp_name, - email_message=message, payload=payload, response=response, - backend=self) from err + raise AnymailRequestsAPIError( + "Invalid JSON in %s API response" % self.esp_name, + email_message=message, + payload=payload, + response=response, + backend=self, + ) from err @staticmethod def _dump_api_request(response, **kwargs): @@ -125,31 +141,52 @@ class AnymailRequestsBackend(AnymailBaseBackend): # in http://docs.python-requests.org/en/v3.0.0/api/#api-changes) request = response.request # a PreparedRequest print("\n===== Anymail API request") - print("{method} {url}\n{headers}".format( - method=request.method, url=request.url, - headers="".join("{header}: {value}\n".format(header=header, value=value) - for (header, value) in request.headers.items()), - )) + print( + "{method} {url}\n{headers}".format( + method=request.method, + url=request.url, + headers="".join( + "{header}: {value}\n".format(header=header, value=value) + for (header, value) in request.headers.items() + ), + ) + ) if request.body is not None: - body_text = (request.body if isinstance(request.body, str) - else request.body.decode("utf-8", errors="replace") - ).replace("\r\n", "\n") + body_text = ( + request.body + if isinstance(request.body, str) + else request.body.decode("utf-8", errors="replace") + ).replace("\r\n", "\n") print(body_text) print("\n----- Response") - print("HTTP {status} {reason}\n{headers}\n{body}".format( - status=response.status_code, reason=response.reason, - headers="".join("{header}: {value}\n".format(header=header, value=value) - for (header, value) in response.headers.items()), - body=response.text, # Let Requests decode body content for us - )) + print( + "HTTP {status} {reason}\n{headers}\n{body}".format( + status=response.status_code, + reason=response.reason, + headers="".join( + "{header}: {value}\n".format(header=header, value=value) + for (header, value) in response.headers.items() + ), + body=response.text, # Let Requests decode body content for us + ) + ) class RequestsPayload(BasePayload): """Abstract Payload for AnymailRequestsBackend""" - def __init__(self, message, defaults, backend, - method="POST", params=None, data=None, - headers=None, files=None, auth=None): + def __init__( + self, + message, + defaults, + backend, + method="POST", + params=None, + data=None, + headers=None, + files=None, + auth=None, + ): self.method = method self.params = params self.data = data @@ -183,7 +220,10 @@ class RequestsPayload(BasePayload): ) def get_api_endpoint(self): - """Returns a str that should be joined to the backend's api_url for sending this payload.""" + """ + Returns a str that should be joined to the backend's api_url + for sending this payload. + """ return None def serialize_data(self): diff --git a/anymail/backends/console.py b/anymail/backends/console.py index 5fa9ffe..2948852 100644 --- a/anymail/backends/console.py +++ b/anymail/backends/console.py @@ -1,4 +1,5 @@ import uuid + from django.core.mail.backends.console import EmailBackend as DjangoConsoleBackend from ..exceptions import AnymailError diff --git a/anymail/backends/mailgun.py b/anymail/backends/mailgun.py index 914b864..7fe6409 100644 --- a/anymail/backends/mailgun.py +++ b/anymail/backends/mailgun.py @@ -4,22 +4,25 @@ from urllib.parse import quote from requests import Request -from .base_requests import AnymailRequestsBackend, RequestsPayload from ..exceptions import AnymailError, AnymailRequestsAPIError from ..message import AnymailRecipientStatus from ..utils import get_anymail_setting, rfc2822date +from .base_requests import AnymailRequestsBackend, RequestsPayload # Feature-detect whether requests (urllib3) correctly uses RFC 7578 encoding for non- # ASCII filenames in Content-Disposition headers. (This was fixed in urllib3 v1.25.) # See MailgunPayload.get_request_params for info (and a workaround on older versions). -# (Note: when this workaround is removed, please also remove the "old_urllib3" tox envs.) +# (Note: when this workaround is removed, please also remove "old_urllib3" tox envs.) def is_requests_rfc_5758_compliant(): - request = Request(method='POST', url='https://www.example.com', - files=[('attachment', ('\N{NOT SIGN}.txt', 'test', 'text/plain'))]) + request = Request( + method="POST", + url="https://www.example.com", + files=[("attachment", ("\N{NOT SIGN}.txt", "test", "text/plain"))], + ) prepared = request.prepare() form_data = prepared.body # bytes - return b'filename*=' not in form_data + return b"filename*=" not in form_data REQUESTS_IS_RFC_7578_COMPLIANT = is_requests_rfc_5758_compliant() @@ -35,11 +38,22 @@ class EmailBackend(AnymailRequestsBackend): def __init__(self, **kwargs): """Init options from Django settings""" esp_name = self.esp_name - self.api_key = get_anymail_setting('api_key', esp_name=esp_name, kwargs=kwargs, allow_bare=True) - self.sender_domain = get_anymail_setting('sender_domain', esp_name=esp_name, kwargs=kwargs, - allow_bare=True, default=None) - api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs, - default="https://api.mailgun.net/v3") + self.api_key = get_anymail_setting( + "api_key", esp_name=esp_name, kwargs=kwargs, allow_bare=True + ) + self.sender_domain = get_anymail_setting( + "sender_domain", + esp_name=esp_name, + kwargs=kwargs, + allow_bare=True, + default=None, + ) + api_url = get_anymail_setting( + "api_url", + esp_name=esp_name, + kwargs=kwargs, + default="https://api.mailgun.net/v3", + ) if not api_url.endswith("/"): api_url += "/" super().__init__(api_url, **kwargs) @@ -55,9 +69,13 @@ class EmailBackend(AnymailRequestsBackend): "Unknown sender domain {sender_domain!r}.\n" "Check the domain is verified with Mailgun, and that the ANYMAIL" " MAILGUN_API_URL setting {api_url!r} is the correct region.".format( - sender_domain=payload.sender_domain, api_url=self.api_url), - email_message=message, payload=payload, - response=response, backend=self) + sender_domain=payload.sender_domain, api_url=self.api_url + ), + email_message=message, + payload=payload, + response=response, + backend=self, + ) super().raise_for_status(response, payload, message) @@ -68,8 +86,11 @@ class EmailBackend(AnymailRequestsBackend): "Invalid Mailgun API endpoint %r.\n" "Check your ANYMAIL MAILGUN_SENDER_DOMAIN" " and MAILGUN_API_URL settings." % response.url, - email_message=message, payload=payload, - response=response, backend=self) + email_message=message, + payload=payload, + response=response, + backend=self, + ) def parse_recipient_status(self, response, payload, message): # The *only* 200 response from Mailgun seems to be: @@ -86,20 +107,27 @@ class EmailBackend(AnymailRequestsBackend): message_id = parsed_response["id"] mailgun_message = parsed_response["message"] except (KeyError, TypeError) as err: - raise AnymailRequestsAPIError("Invalid Mailgun API response format", - email_message=message, payload=payload, response=response, - backend=self) from err + raise AnymailRequestsAPIError( + "Invalid Mailgun API response format", + email_message=message, + payload=payload, + response=response, + backend=self, + ) from err if not mailgun_message.startswith("Queued"): - raise AnymailRequestsAPIError("Unrecognized Mailgun API message '%s'" % mailgun_message, - email_message=message, payload=payload, response=response, - backend=self) + raise AnymailRequestsAPIError( + "Unrecognized Mailgun API message '%s'" % mailgun_message, + email_message=message, + payload=payload, + response=response, + backend=self, + ) # Simulate a per-recipient status of "queued": status = AnymailRecipientStatus(message_id=message_id, status="queued") return {recipient.addr_spec: status for recipient in payload.all_recipients} class MailgunPayload(RequestsPayload): - def __init__(self, message, defaults, backend, *args, **kwargs): auth = ("api", backend.api_key) self.sender_domain = backend.sender_domain @@ -116,22 +144,32 @@ class MailgunPayload(RequestsPayload): def get_api_endpoint(self): if self.sender_domain is None: - raise AnymailError("Cannot call Mailgun unknown sender domain. " - "Either provide valid `from_email`, " - "or set `message.esp_extra={'sender_domain': 'example.com'}`", - backend=self.backend, email_message=self.message, payload=self) - if '/' in self.sender_domain or '%2f' in self.sender_domain.lower(): + raise AnymailError( + "Cannot call Mailgun unknown sender domain. " + "Either provide valid `from_email`, " + "or set `message.esp_extra={'sender_domain': 'example.com'}`", + backend=self.backend, + email_message=self.message, + payload=self, + ) + if "/" in self.sender_domain or "%2f" in self.sender_domain.lower(): # Mailgun returns a cryptic 200-OK "Mailgun Magnificent API" response # if '/' (or even %-encoded '/') confuses it about the API endpoint. - raise AnymailError("Invalid '/' in sender domain '%s'" % self.sender_domain, - backend=self.backend, email_message=self.message, payload=self) - return "%s/messages" % quote(self.sender_domain, safe='') + raise AnymailError( + "Invalid '/' in sender domain '%s'" % self.sender_domain, + backend=self.backend, + email_message=self.message, + payload=self, + ) + return "%s/messages" % quote(self.sender_domain, safe="") def get_request_params(self, api_url): params = super().get_request_params(api_url) - non_ascii_filenames = [filename - for (field, (filename, content, mimetype)) in params["files"] - if filename is not None and not isascii(filename)] + non_ascii_filenames = [ + filename + for (field, (filename, content, mimetype)) in params["files"] + if filename is not None and not isascii(filename) + ] if non_ascii_filenames and not REQUESTS_IS_RFC_7578_COMPLIANT: # Workaround https://github.com/requests/requests/issues/4652: # Mailgun expects RFC 7578 compliant multipart/form-data, and is confused @@ -139,7 +177,7 @@ class MailgunPayload(RequestsPayload): # ("filename*=utf-8''...") in Content-Disposition headers. # The workaround is to pre-generate the (non-compliant) form-data body, and # replace 'filename*={RFC 2231 encoded}' with 'filename="{UTF-8 bytes}"'. - # Replace _only_ the filenames that will be problems (not all "filename*=...") + # Replace _only_ filenames that will be problems (not all "filename*=...") # to minimize potential side effects--e.g., in attached messages that might # have their own attachments with (correctly) RFC 2231 encoded filenames. prepared = Request(**params).prepare() @@ -147,10 +185,12 @@ class MailgunPayload(RequestsPayload): for filename in non_ascii_filenames: # text rfc2231_filename = encode_rfc2231(filename, charset="utf-8") form_data = form_data.replace( - b'filename*=' + rfc2231_filename.encode("utf-8"), - b'filename="' + filename.encode("utf-8") + b'"') + b"filename*=" + rfc2231_filename.encode("utf-8"), + b'filename="' + filename.encode("utf-8") + b'"', + ) params["data"] = form_data - params["headers"]["Content-Type"] = prepared.headers["Content-Type"] # multipart/form-data; boundary=... + # Content-Type: multipart/form-data; boundary=... + params["headers"]["Content-Type"] = prepared.headers["Content-Type"] params["files"] = None # these are now in the form_data body return params @@ -181,32 +221,39 @@ class MailgunPayload(RequestsPayload): # are available as `%recipient.KEY%` virtually anywhere in the message # (including header fields and other parameters). # - # Anymail needs both mechanisms to map its normalized metadata and template merge_data - # features to Mailgun: + # Anymail needs both mechanisms to map its normalized metadata and template + # merge_data features to Mailgun: # (1) Anymail's `metadata` maps directly to Mailgun's custom data, where it can be # accessed from webhooks. # (2) Anymail's `merge_metadata` (per-recipient metadata for batch sends) maps # *indirectly* through recipient-variables to Mailgun's custom data. To avoid - # conflicts, the recipient-variables mapping prepends 'v:' to merge_metadata keys. - # (E.g., Mailgun's custom-data "user" is set to "%recipient.v:user", which picks - # up its per-recipient value from Mailgun's `recipient-variables[to_email]["v:user"]`.) - # (3) Anymail's `merge_data` (per-recipient template substitutions) maps directly to - # Mailgun's `recipient-variables`, where it can be referenced in on-the-fly templates. - # (4) Anymail's `merge_global_data` (global template substitutions) is copied to - # Mailgun's `recipient-variables` for every recipient, as the default for missing - # `merge_data` keys. + # conflicts, the recipient-variables mapping prepends 'v:' to merge_metadata + # keys. (E.g., Mailgun's custom-data "user" is set to "%recipient.v:user", + # which picks up its per-recipient value from Mailgun's + # `recipient-variables[to_email]["v:user"]`.) + # (3) Anymail's `merge_data` (per-recipient template substitutions) maps directly + # to Mailgun's `recipient-variables`, where it can be referenced in on-the-fly + # templates. + # (4) Anymail's `merge_global_data` (global template substitutions) is copied + # to Mailgun's `recipient-variables` for every recipient, as the default + # for missing `merge_data` keys. # (5) Only if a stored template is used, `merge_data` and `merge_global_data` are - # *also* mapped *indirectly* through recipient-variables to Mailgun's custom data, - # where they can be referenced in handlebars {{substitutions}}. + # *also* mapped *indirectly* through recipient-variables to Mailgun's custom + # data, where they can be referenced in handlebars {{substitutions}}. # (E.g., Mailgun's custom-data "name" is set to "%recipient.name%", which picks - # up its per-recipient value from Mailgun's `recipient-variables[to_email]["name"]`.) + # up its per-recipient value from Mailgun's + # `recipient-variables[to_email]["name"]`.) # # If Anymail's `merge_data`, `template_id` (stored templates) and `metadata` (or - # `merge_metadata`) are used together, there's a possibility of conflicting keys in - # Mailgun's custom data. Anymail treats that conflict as an unsupported feature error. + # `merge_metadata`) are used together, there's a possibility of conflicting keys + # in Mailgun's custom data. Anymail treats that conflict as an unsupported feature + # error. def populate_recipient_variables(self): - """Populate Mailgun recipient-variables and custom data from merge data and metadata""" + """ + Populate Mailgun recipient-variables and custom data + from merge data and metadata + """ # (numbers refer to detailed explanation above) # Mailgun parameters to construct: recipient_variables = {} @@ -217,53 +264,66 @@ class MailgunPayload(RequestsPayload): # (2) merge_metadata --> Mailgun custom_data via recipient_variables if self.merge_metadata: - def vkey(key): # 'v:key' - return 'v:{}'.format(key) - merge_metadata_keys = flatset( # all keys used in any recipient's merge_metadata - recipient_data.keys() for recipient_data in self.merge_metadata.values()) - custom_data.update({ # custom_data['key'] = '%recipient.v:key%' indirection - key: '%recipient.{}%'.format(vkey(key)) - for key in merge_metadata_keys}) - base_recipient_data = { # defaults for each recipient must cover all keys - vkey(key): self.metadata.get(key, '') - for key in merge_metadata_keys} + def vkey(key): # 'v:key' + return "v:{}".format(key) + + # all keys used in any recipient's merge_metadata: + merge_metadata_keys = flatset( + recipient_data.keys() for recipient_data in self.merge_metadata.values() + ) + # custom_data['key'] = '%recipient.v:key%' indirection: + custom_data.update( + {key: "%recipient.{}%".format(vkey(key)) for key in merge_metadata_keys} + ) + # defaults for each recipient must cover all keys: + base_recipient_data = { + vkey(key): self.metadata.get(key, "") for key in merge_metadata_keys + } for email in self.to_emails: this_recipient_data = base_recipient_data.copy() - this_recipient_data.update({ - vkey(key): value - for key, value in self.merge_metadata.get(email, {}).items()}) + this_recipient_data.update( + { + vkey(key): value + for key, value in self.merge_metadata.get(email, {}).items() + } + ) recipient_variables.setdefault(email, {}).update(this_recipient_data) # (3) and (4) merge_data, merge_global_data --> Mailgun recipient_variables if self.merge_data or self.merge_global_data: - merge_data_keys = flatset( # all keys used in any recipient's merge_data - recipient_data.keys() for recipient_data in self.merge_data.values()) + # all keys used in any recipient's merge_data: + merge_data_keys = flatset( + recipient_data.keys() for recipient_data in self.merge_data.values() + ) merge_data_keys = merge_data_keys.union(self.merge_global_data.keys()) - base_recipient_data = { # defaults for each recipient must cover all keys - key: self.merge_global_data.get(key, '') - for key in merge_data_keys} + # defaults for each recipient must cover all keys: + base_recipient_data = { + key: self.merge_global_data.get(key, "") for key in merge_data_keys + } for email in self.to_emails: this_recipient_data = base_recipient_data.copy() this_recipient_data.update(self.merge_data.get(email, {})) recipient_variables.setdefault(email, {}).update(this_recipient_data) # (5) if template, also map Mailgun custom_data to per-recipient_variables - if self.data.get('template') is not None: + if self.data.get("template") is not None: conflicts = merge_data_keys.intersection(custom_data.keys()) if conflicts: self.unsupported_feature( - "conflicting merge_data and metadata keys (%s) when using template_id" - % ', '.join("'%s'" % key for key in conflicts)) - custom_data.update({ # custom_data['key'] = '%recipient.key%' indirection - key: '%recipient.{}%'.format(key) - for key in merge_data_keys}) + "conflicting merge_data and metadata keys (%s)" + " when using template_id" + % ", ".join("'%s'" % key for key in conflicts) + ) + # custom_data['key'] = '%recipient.key%' indirection: + custom_data.update( + {key: "%recipient.{}%".format(key) for key in merge_data_keys} + ) # populate Mailgun params - self.data.update({'v:%s' % key: value - for key, value in custom_data.items()}) + self.data.update({"v:%s" % key: value for key, value in custom_data.items()}) if recipient_variables or self.is_batch(): - self.data['recipient-variables'] = self.serialize_json(recipient_variables) + self.data["recipient-variables"] = self.serialize_json(recipient_variables) # # Payload construction @@ -285,9 +345,11 @@ class MailgunPayload(RequestsPayload): assert recipient_type in ["to", "cc", "bcc"] if emails: self.data[recipient_type] = [email.address for email in emails] - self.all_recipients += emails # used for backend.parse_recipient_status - if recipient_type == 'to': - self.to_emails = [email.addr_spec for email in emails] # used for populate_recipient_variables + # used for backend.parse_recipient_status: + self.all_recipients += emails + if recipient_type == "to": + # used for populate_recipient_variables: + self.to_emails = [email.addr_spec for email in emails] def set_subject(self, subject): self.data["subject"] = subject @@ -306,7 +368,8 @@ class MailgunPayload(RequestsPayload): def set_html_body(self, body): if "html" in self.data: - # second html body could show up through multiple alternatives, or html body + alternative + # second html body could show up through multiple alternatives, + # or html body + alternative self.unsupported_feature("multiple html parts") self.data["html"] = body @@ -330,9 +393,7 @@ class MailgunPayload(RequestsPayload): name = attachment.name if not name: self.unsupported_feature("attachments without filenames") - self.files.append( - (field, (name, attachment.content, attachment.mimetype)) - ) + self.files.append((field, (name, attachment.content, attachment.mimetype))) def set_envelope_sender(self, email): # Only the domain is used diff --git a/anymail/backends/mailjet.py b/anymail/backends/mailjet.py index 0ffeb21..a672f89 100644 --- a/anymail/backends/mailjet.py +++ b/anymail/backends/mailjet.py @@ -1,7 +1,7 @@ -from .base_requests import AnymailRequestsBackend, RequestsPayload from ..exceptions import AnymailRequestsAPIError from ..message import AnymailRecipientStatus from ..utils import get_anymail_setting, update_deep +from .base_requests import AnymailRequestsBackend, RequestsPayload class EmailBackend(AnymailRequestsBackend): @@ -14,10 +14,18 @@ class EmailBackend(AnymailRequestsBackend): def __init__(self, **kwargs): """Init options from Django settings""" esp_name = self.esp_name - self.api_key = get_anymail_setting('api_key', esp_name=esp_name, kwargs=kwargs, allow_bare=True) - self.secret_key = get_anymail_setting('secret_key', esp_name=esp_name, kwargs=kwargs, allow_bare=True) - api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs, - default="https://api.mailjet.com/v3.1/") + self.api_key = get_anymail_setting( + "api_key", esp_name=esp_name, kwargs=kwargs, allow_bare=True + ) + self.secret_key = get_anymail_setting( + "secret_key", esp_name=esp_name, kwargs=kwargs, allow_bare=True + ) + api_url = get_anymail_setting( + "api_url", + esp_name=esp_name, + kwargs=kwargs, + default="https://api.mailjet.com/v3.1/", + ) if not api_url.endswith("/"): api_url += "/" super().__init__(api_url, **kwargs) @@ -37,42 +45,57 @@ class EmailBackend(AnymailRequestsBackend): # Global error? (no messages sent) if "ErrorCode" in parsed_response: - raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response, backend=self) + raise AnymailRequestsAPIError( + email_message=message, payload=payload, response=response, backend=self + ) recipient_status = {} try: for result in parsed_response["Messages"]: - status = 'sent' if result["Status"] == 'success' else 'failed' # Status is 'success' or 'error' - recipients = result.get("To", []) + result.get("Cc", []) + result.get("Bcc", []) + # result["Status"] is "success" or "error" + status = "sent" if result["Status"] == "success" else "failed" + recipients = ( + result.get("To", []) + result.get("Cc", []) + result.get("Bcc", []) + ) for recipient in recipients: - email = recipient['Email'] - message_id = str(recipient['MessageID']) # MessageUUID isn't yet useful for other Mailjet APIs - recipient_status[email] = AnymailRecipientStatus(message_id=message_id, status=status) - # Note that for errors, Mailjet doesn't identify the problem recipients. - # This can occur with a batch send. We patch up the missing recipients below. + email = recipient["Email"] + # other Mailjet APIs expect MessageID (not MessageUUID) + message_id = str(recipient["MessageID"]) + recipient_status[email] = AnymailRecipientStatus( + message_id=message_id, status=status + ) + # For errors, Mailjet doesn't identify the problem recipients. (This + # can occur with a batch send.) Patch up the missing recipients below. except (KeyError, TypeError) as err: - raise AnymailRequestsAPIError("Invalid Mailjet API response format", - email_message=message, payload=payload, response=response, - backend=self) from err + raise AnymailRequestsAPIError( + "Invalid Mailjet API response format", + email_message=message, + payload=payload, + response=response, + backend=self, + ) from err # Any recipient who wasn't reported as a 'success' must have been an error: for email in payload.recipients: if email.addr_spec not in recipient_status: - recipient_status[email.addr_spec] = AnymailRecipientStatus(message_id=None, status='failed') + recipient_status[email.addr_spec] = AnymailRecipientStatus( + message_id=None, status="failed" + ) return recipient_status class MailjetPayload(RequestsPayload): - def __init__(self, message, defaults, backend, *args, **kwargs): auth = (backend.api_key, backend.secret_key) http_headers = { - 'Content-Type': 'application/json', + "Content-Type": "application/json", } self.recipients = [] # for backend parse_recipient_status self.metadata = None - super().__init__(message, defaults, backend, auth=auth, headers=http_headers, *args, **kwargs) + super().__init__( + message, defaults, backend, auth=auth, headers=http_headers, *args, **kwargs + ) def get_api_endpoint(self): return "send" @@ -122,9 +145,9 @@ class MailjetPayload(RequestsPayload): # This case shouldn't happen. Please file a bug report if it does. raise AssertionError("set_to called with non-empty Messages list") if emails: - self.data["Messages"].append({ - "To": [self._mailjet_email(email) for email in emails] - }) + self.data["Messages"].append( + {"To": [self._mailjet_email(email) for email in emails]} + ) self.recipients += emails else: # Mailjet requires a To list; cc-only messages aren't possible @@ -132,12 +155,16 @@ class MailjetPayload(RequestsPayload): def set_cc(self, emails): if emails: - self.data["Globals"]["Cc"] = [self._mailjet_email(email) for email in emails] + self.data["Globals"]["Cc"] = [ + self._mailjet_email(email) for email in emails + ] self.recipients += emails def set_bcc(self, emails): if emails: - self.data["Globals"]["Bcc"] = [self._mailjet_email(email) for email in emails] + self.data["Globals"]["Bcc"] = [ + self._mailjet_email(email) for email in emails + ] self.recipients += emails def set_subject(self, subject): @@ -159,7 +186,8 @@ class MailjetPayload(RequestsPayload): def set_html_body(self, body): if body is not None: if "HTMLPart" in self.data["Globals"]: - # second html body could show up through multiple alternatives, or html body + alternative + # second html body could show up through multiple alternatives, + # or html body + alternative self.unsupported_feature("multiple html parts") self.data["Globals"]["HTMLPart"] = body @@ -183,7 +211,7 @@ class MailjetPayload(RequestsPayload): def set_metadata(self, metadata): # Mailjet expects a single string payload self.data["Globals"]["EventPayload"] = self.serialize_json(metadata) - self.metadata = metadata # keep original in case we need to merge with merge_metadata + self.metadata = metadata # save for set_merge_metadata def set_merge_metadata(self, merge_metadata): self._burst_for_batch_send() @@ -204,7 +232,7 @@ class MailjetPayload(RequestsPayload): if len(tags) > 0: self.data["Globals"]["CustomCampaign"] = tags[0] if len(tags) > 1: - self.unsupported_feature('multiple tags (%r)' % tags) + self.unsupported_feature("multiple tags (%r)" % tags) def set_track_clicks(self, track_clicks): self.data["Globals"]["TrackClicks"] = "enabled" if track_clicks else "disabled" @@ -213,7 +241,8 @@ class MailjetPayload(RequestsPayload): self.data["Globals"]["TrackOpens"] = "enabled" if track_opens else "disabled" def set_template_id(self, template_id): - self.data["Globals"]["TemplateID"] = int(template_id) # Mailjet requires integer (not string) + # Mailjet requires integer (not string) TemplateID: + self.data["Globals"]["TemplateID"] = int(template_id) self.data["Globals"]["TemplateLanguage"] = True def set_merge_data(self, merge_data): diff --git a/anymail/backends/mandrill.py b/anymail/backends/mandrill.py index 9a5814c..6aba287 100644 --- a/anymail/backends/mandrill.py +++ b/anymail/backends/mandrill.py @@ -2,9 +2,8 @@ import warnings from datetime import datetime from ..exceptions import AnymailRequestsAPIError, AnymailWarning -from ..message import AnymailRecipientStatus, ANYMAIL_STATUSES -from ..utils import last, combine, get_anymail_setting - +from ..message import ANYMAIL_STATUSES, AnymailRecipientStatus +from ..utils import combine, get_anymail_setting, last from .base_requests import AnymailRequestsBackend, RequestsPayload @@ -18,9 +17,15 @@ class EmailBackend(AnymailRequestsBackend): def __init__(self, **kwargs): """Init options from Django settings""" esp_name = self.esp_name - self.api_key = get_anymail_setting('api_key', esp_name=esp_name, kwargs=kwargs, allow_bare=True) - api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs, - default="https://mandrillapp.com/api/1.0") + self.api_key = get_anymail_setting( + "api_key", esp_name=esp_name, kwargs=kwargs, allow_bare=True + ) + api_url = get_anymail_setting( + "api_url", + esp_name=esp_name, + kwargs=kwargs, + default="https://mandrillapp.com/api/1.0", + ) if not api_url.endswith("/"): api_url += "/" super().__init__(api_url, **kwargs) @@ -32,18 +37,26 @@ class EmailBackend(AnymailRequestsBackend): parsed_response = self.deserialize_json_response(response, payload, message) recipient_status = {} try: - # Mandrill returns a list of { email, status, _id, reject_reason } for each recipient + # Mandrill returns a list of { email, status, _id, reject_reason } + # for each recipient for item in parsed_response: - email = item['email'] - status = item['status'] + email = item["email"] + status = item["status"] if status not in ANYMAIL_STATUSES: - status = 'unknown' - message_id = item.get('_id', None) # can be missing for invalid/rejected recipients - recipient_status[email] = AnymailRecipientStatus(message_id=message_id, status=status) + status = "unknown" + # "_id" can be missing for invalid/rejected recipients: + message_id = item.get("_id", None) + recipient_status[email] = AnymailRecipientStatus( + message_id=message_id, status=status + ) except (KeyError, TypeError) as err: - raise AnymailRequestsAPIError("Invalid Mandrill API response format", - email_message=message, payload=payload, response=response, - backend=self) from err + raise AnymailRequestsAPIError( + "Invalid Mandrill API response format", + email_message=message, + payload=payload, + response=response, + backend=self, + ) from err return recipient_status @@ -60,19 +73,18 @@ def encode_date_for_mandrill(dt): dt = dt.replace(microsecond=0) if dt.utcoffset() is not None: dt = (dt - dt.utcoffset()).replace(tzinfo=None) - return dt.isoformat(' ') + return dt.isoformat(" ") else: return dt class MandrillPayload(RequestsPayload): - def __init__(self, *args, **kwargs): self.esp_extra = {} # late-bound in serialize_data super().__init__(*args, **kwargs) def get_api_endpoint(self): - if 'template_name' in self.data: + if "template_name" in self.data: return "messages/send-template.json" else: return "messages/send.json" @@ -81,7 +93,7 @@ class MandrillPayload(RequestsPayload): self.process_esp_extra() if self.is_batch(): # hide recipients from each other - self.data['message']['preserve_recipients'] = False + self.data["message"]["preserve_recipients"] = False return self.serialize_json(self.data) # @@ -96,7 +108,9 @@ class MandrillPayload(RequestsPayload): def set_from_email(self, email): if getattr(self.message, "use_template_from", False): - self.deprecation_warning('message.use_template_from', 'message.from_email = None') + self.deprecation_warning( + "message.use_template_from", "message.from_email = None" + ) else: self.data["message"]["from_email"] = email.addr_spec if email.display_name: @@ -112,7 +126,9 @@ class MandrillPayload(RequestsPayload): def set_subject(self, subject): if getattr(self.message, "use_template_subject", False): - self.deprecation_warning('message.use_template_subject', 'message.subject = None') + self.deprecation_warning( + "message.use_template_subject", "message.subject = None" + ) else: self.data["message"]["subject"] = subject @@ -129,7 +145,8 @@ class MandrillPayload(RequestsPayload): def set_html_body(self, body): if "html" in self.data["message"]: - # second html body could show up through multiple alternatives, or html body + alternative + # second html body could show up through multiple alternatives, + # or html body + alternative self.unsupported_feature("multiple html parts") self.data["message"]["html"] = body @@ -140,11 +157,13 @@ class MandrillPayload(RequestsPayload): else: field = "attachments" name = attachment.name or "" - self.data["message"].setdefault(field, []).append({ - "type": attachment.mimetype, - "name": name, - "content": attachment.b64content - }) + self.data["message"].setdefault(field, []).append( + { + "type": attachment.mimetype, + "name": name, + "content": attachment.b64content, + } + ) def set_envelope_sender(self, email): # Only the domain is used @@ -170,22 +189,28 @@ class MandrillPayload(RequestsPayload): self.data.setdefault("template_content", []) # Mandrill requires something here def set_merge_data(self, merge_data): - self.data['message']['merge_vars'] = [ - {'rcpt': rcpt, 'vars': [{'name': key, 'content': rcpt_data[key]} - for key in sorted(rcpt_data.keys())]} # sort for testing reproducibility + self.data["message"]["merge_vars"] = [ + { + "rcpt": rcpt, + "vars": [ + # sort for testing reproducibility: + {"name": key, "content": rcpt_data[key]} + for key in sorted(rcpt_data.keys()) + ], + } for rcpt, rcpt_data in merge_data.items() ] def set_merge_global_data(self, merge_global_data): - self.data['message']['global_merge_vars'] = [ - {'name': var, 'content': value} - for var, value in merge_global_data.items() + self.data["message"]["global_merge_vars"] = [ + {"name": var, "content": value} for var, value in merge_global_data.items() ] def set_merge_metadata(self, merge_metadata): - # recipient_metadata format is similar to, but not quite the same as, merge_vars: - self.data['message']['recipient_metadata'] = [ - {'rcpt': rcpt, 'values': rcpt_data} + # recipient_metadata format is similar to, but not quite the same as, + # merge_vars: + self.data["message"]["recipient_metadata"] = [ + {"rcpt": rcpt, "values": rcpt_data} for rcpt, rcpt_data in merge_metadata.items() ] @@ -198,33 +223,40 @@ class MandrillPayload(RequestsPayload): esp_extra = self.esp_extra # Convert pythonic template_content dict to Mandrill name/content list try: - template_content = esp_extra['template_content'] + template_content = esp_extra["template_content"] except KeyError: pass else: - if hasattr(template_content, 'items'): # if it's dict-like + # if it's dict-like: + if hasattr(template_content, "items"): if esp_extra is self.esp_extra: - esp_extra = self.esp_extra.copy() # don't modify caller's value - esp_extra['template_content'] = [ - {'name': var, 'content': value} - for var, value in template_content.items()] + # don't modify caller's value + esp_extra = self.esp_extra.copy() + esp_extra["template_content"] = [ + {"name": var, "content": value} + for var, value in template_content.items() + ] # Convert pythonic recipient_metadata dict to Mandrill rcpt/values list try: - recipient_metadata = esp_extra['message']['recipient_metadata'] + recipient_metadata = esp_extra["message"]["recipient_metadata"] except KeyError: pass else: - if hasattr(recipient_metadata, 'keys'): # if it's dict-like - if esp_extra['message'] is self.esp_extra['message']: - esp_extra['message'] = self.esp_extra['message'].copy() # don't modify caller's value - # For testing reproducibility, we sort the recipients - esp_extra['message']['recipient_metadata'] = [ - {'rcpt': rcpt, 'values': recipient_metadata[rcpt]} - for rcpt in sorted(recipient_metadata.keys())] - # Merge esp_extra with payload data: shallow merge within ['message'] and top-level keys - self.data.update({k: v for k, v in esp_extra.items() if k != 'message'}) + # if it's dict-like: + if hasattr(recipient_metadata, "keys"): + if esp_extra["message"] is self.esp_extra["message"]: + # don't modify caller's value: + esp_extra["message"] = self.esp_extra["message"].copy() + # For testing reproducibility, sort the recipients + esp_extra["message"]["recipient_metadata"] = [ + {"rcpt": rcpt, "values": recipient_metadata[rcpt]} + for rcpt in sorted(recipient_metadata.keys()) + ] + # Merge esp_extra with payload data: shallow merge within ['message'] + # and top-level keys + self.data.update({k: v for k, v in esp_extra.items() if k != "message"}) try: - self.data['message'].update(esp_extra['message']) + self.data["message"].update(esp_extra["message"]) except KeyError: pass @@ -245,69 +277,75 @@ class MandrillPayload(RequestsPayload): self.deprecation_warning(feature, replacement) esp_message_attrs = ( - ('async', last, None), - ('ip_pool', last, None), - ('from_name', last, None), # overrides display name parsed from from_email above - ('important', last, None), - ('auto_text', last, None), - ('auto_html', last, None), - ('inline_css', last, None), - ('url_strip_qs', last, None), - ('tracking_domain', last, None), - ('signing_domain', last, None), - ('return_path_domain', last, None), - ('merge_language', last, None), - ('preserve_recipients', last, None), - ('view_content_link', last, None), - ('subaccount', last, None), - ('google_analytics_domains', last, None), - ('google_analytics_campaign', last, None), - ('global_merge_vars', combine, None), - ('merge_vars', combine, None), - ('recipient_metadata', combine, None), - ('template_name', last, None), - ('template_content', combine, None), + ("async", last, None), + ("ip_pool", last, None), + ("from_name", last, None), # overrides display name parsed from from_email + ("important", last, None), + ("auto_text", last, None), + ("auto_html", last, None), + ("inline_css", last, None), + ("url_strip_qs", last, None), + ("tracking_domain", last, None), + ("signing_domain", last, None), + ("return_path_domain", last, None), + ("merge_language", last, None), + ("preserve_recipients", last, None), + ("view_content_link", last, None), + ("subaccount", last, None), + ("google_analytics_domains", last, None), + ("google_analytics_campaign", last, None), + ("global_merge_vars", combine, None), + ("merge_vars", combine, None), + ("recipient_metadata", combine, None), + ("template_name", last, None), + ("template_content", combine, None), ) def set_async(self, is_async): - self.deprecated_to_esp_extra('async') - self.esp_extra['async'] = is_async + self.deprecated_to_esp_extra("async") + self.esp_extra["async"] = is_async def set_ip_pool(self, ip_pool): - self.deprecated_to_esp_extra('ip_pool') - self.esp_extra['ip_pool'] = ip_pool + self.deprecated_to_esp_extra("ip_pool") + self.esp_extra["ip_pool"] = ip_pool def set_global_merge_vars(self, global_merge_vars): - self.deprecation_warning('message.global_merge_vars', 'message.merge_global_data') + self.deprecation_warning( + "message.global_merge_vars", "message.merge_global_data" + ) self.set_merge_global_data(global_merge_vars) def set_merge_vars(self, merge_vars): - self.deprecation_warning('message.merge_vars', 'message.merge_data') + self.deprecation_warning("message.merge_vars", "message.merge_data") self.set_merge_data(merge_vars) def set_return_path_domain(self, domain): - self.deprecation_warning('message.return_path_domain', 'message.envelope_sender') - self.esp_extra.setdefault('message', {})['return_path_domain'] = domain + self.deprecation_warning( + "message.return_path_domain", "message.envelope_sender" + ) + self.esp_extra.setdefault("message", {})["return_path_domain"] = domain def set_template_name(self, template_name): - self.deprecation_warning('message.template_name', 'message.template_id') + self.deprecation_warning("message.template_name", "message.template_id") self.set_template_id(template_name) def set_template_content(self, template_content): - self.deprecated_to_esp_extra('template_content') - self.esp_extra['template_content'] = template_content + self.deprecated_to_esp_extra("template_content") + self.esp_extra["template_content"] = template_content def set_recipient_metadata(self, recipient_metadata): - self.deprecated_to_esp_extra('recipient_metadata', in_message_dict=True) - self.esp_extra.setdefault('message', {})['recipient_metadata'] = recipient_metadata + self.deprecated_to_esp_extra("recipient_metadata", in_message_dict=True) + self.esp_extra.setdefault("message", {})[ + "recipient_metadata" + ] = recipient_metadata # Set up simple set_ functions for any missing esp_message_attrs attrs # (avoids dozens of simple `self.data["message"][] = value` functions) @classmethod def define_message_attr_setters(cls): - for (attr, _, _) in cls.esp_message_attrs: - setter_name = 'set_%s' % attr + for attr, _, _ in cls.esp_message_attrs: + setter_name = "set_%s" % attr try: getattr(cls, setter_name) except AttributeError: @@ -316,10 +354,12 @@ class MandrillPayload(RequestsPayload): @staticmethod def make_setter(attr, setter_name): - # sure wish we could use functools.partial to create instance methods (descriptors) + # sure wish we could use functools.partial + # to create instance methods (descriptors) def setter(self, value): self.deprecated_to_esp_extra(attr, in_message_dict=True) - self.esp_extra.setdefault('message', {})[attr] = value + self.esp_extra.setdefault("message", {})[attr] = value + setter.__name__ = setter_name return setter diff --git a/anymail/backends/postal.py b/anymail/backends/postal.py index ff3bcc1..addf44b 100644 --- a/anymail/backends/postal.py +++ b/anymail/backends/postal.py @@ -1,7 +1,7 @@ -from .base_requests import AnymailRequestsBackend, RequestsPayload from ..exceptions import AnymailRequestsAPIError from ..message import AnymailRecipientStatus from ..utils import get_anymail_setting +from .base_requests import AnymailRequestsBackend, RequestsPayload class EmailBackend(AnymailRequestsBackend): @@ -106,7 +106,7 @@ class PostalPayload(RequestsPayload): if attachment.inline: # see https://github.com/postalhq/postal/issues/731 # but it might be possible with the send/raw endpoint - self.unsupported_feature('inline attachments') + self.unsupported_feature("inline attachments") return att def set_attachments(self, attachments): diff --git a/anymail/backends/postmark.py b/anymail/backends/postmark.py index 277041f..b2e2a05 100644 --- a/anymail/backends/postmark.py +++ b/anymail/backends/postmark.py @@ -2,8 +2,11 @@ import re from ..exceptions import AnymailRequestsAPIError from ..message import AnymailRecipientStatus -from ..utils import get_anymail_setting, parse_address_list, CaseInsensitiveCasePreservingDict - +from ..utils import ( + CaseInsensitiveCasePreservingDict, + get_anymail_setting, + parse_address_list, +) from .base_requests import AnymailRequestsBackend, RequestsPayload @@ -17,9 +20,15 @@ class EmailBackend(AnymailRequestsBackend): def __init__(self, **kwargs): """Init options from Django settings""" esp_name = self.esp_name - self.server_token = get_anymail_setting('server_token', esp_name=esp_name, kwargs=kwargs, allow_bare=True) - api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs, - default="https://api.postmarkapp.com/") + self.server_token = get_anymail_setting( + "server_token", esp_name=esp_name, kwargs=kwargs, allow_bare=True + ) + api_url = get_anymail_setting( + "api_url", + esp_name=esp_name, + kwargs=kwargs, + default="https://api.postmarkapp.com/", + ) if not api_url.endswith("/"): api_url += "/" super().__init__(api_url, **kwargs) @@ -33,13 +42,17 @@ class EmailBackend(AnymailRequestsBackend): super().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. - # (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 = CaseInsensitiveCasePreservingDict({ - recip.addr_spec: unknown_status - for recip in payload.to_emails + payload.cc_and_bcc_emails}) + # 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 = 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): @@ -52,21 +65,30 @@ class EmailBackend(AnymailRequestsBackend): error_code = one_response["ErrorCode"] msg = one_response["Message"] except (KeyError, TypeError) as err: - raise AnymailRequestsAPIError("Invalid Postmark API response format", - email_message=message, payload=payload, response=response, - backend=self) from err + raise AnymailRequestsAPIError( + "Invalid Postmark API response format", + email_message=message, + payload=payload, + response=response, + backend=self, + ) from err if error_code == 0: # At least partial success, and (some) email was sent. try: message_id = one_response["MessageID"] except KeyError as err: - raise AnymailRequestsAPIError("Invalid Postmark API success response format", - email_message=message, payload=payload, - response=response, backend=self) from err + raise AnymailRequestsAPIError( + "Invalid Postmark API success response format", + email_message=message, + payload=payload, + response=response, + backend=self, + ) from err # Assume all To recipients are "sent" unless proven otherwise below. - # (Must use "To" from API response to get correct individual MessageIDs in batch send.) + # (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: @@ -74,60 +96,89 @@ class EmailBackend(AnymailRequestsBackend): else: for to in parse_address_list(to_header): recipient_status[to.addr_spec] = AnymailRecipientStatus( - message_id=message_id, status='sent') + 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.) + # 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') + 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. + # 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') + msg, r"inactive addresses:\s*(.*)\.\s*Inactive recipients" + ) for reject_addr_spec in reject_addr_specs: recipient_status[reject_addr_spec] = AnymailRecipientStatus( - message_id=None, status='rejected') + message_id=None, status="rejected" + ) elif error_code == 300: # Invalid email request - # Various parse-time validation errors, which may include invalid recipients. Email not sent. - # response["To"] is not populated for this error; must examine response["Message"]: - if re.match(r"^(Invalid|Error\s+parsing)\s+'(To|Cc|Bcc)'", msg, re.IGNORECASE): + # Various parse-time validation errors, which may include invalid + # recipients. Email not sent. response["To"] is not populated for this + # error; must examine response["Message"]: + if re.match( + r"^(Invalid|Error\s+parsing)\s+'(To|Cc|Bcc)'", msg, re.IGNORECASE + ): # Recipient-related errors: use AnymailRecipientsRefused logic - # "Invalid 'To' address: '{addr_spec}'." - # "Error parsing 'Cc': Illegal email domain '{domain}' in address '{addr_spec}'." - # "Error parsing 'Bcc': Illegal email address '{addr_spec}'. It must contain the '@' symbol." - invalid_addr_specs = self._addr_specs_from_error_msg(msg, r"address:?\s*'(.*)'") + # - "Invalid 'To' address: '{addr_spec}'." + # - "Error parsing 'Cc': Illegal email domain '{domain}' + # in address '{addr_spec}'." + # - "Error parsing 'Bcc': Illegal email address '{addr_spec}'. + # It must contain the '@' symbol." + invalid_addr_specs = self._addr_specs_from_error_msg( + msg, r"address:?\s*'(.*)'" + ) for invalid_addr_spec in invalid_addr_specs: recipient_status[invalid_addr_spec] = AnymailRecipientStatus( - message_id=None, status='invalid') + message_id=None, status="invalid" + ) else: # Non-recipient errors; handle as normal API error response - # "Invalid 'From' address: '{email_address}'." - # "Error parsing 'Reply-To': Illegal email domain '{domain}' in address '{addr_spec}'." - # "Invalid metadata content. ..." - raise AnymailRequestsAPIError(email_message=message, payload=payload, - response=response, backend=self) + # - "Invalid 'From' address: '{email_address}'." + # - "Error parsing 'Reply-To': Illegal email domain '{domain}' + # in address '{addr_spec}'." + # - "Invalid metadata content. ..." + raise AnymailRequestsAPIError( + email_message=message, + payload=payload, + response=response, + backend=self, + ) elif error_code == 406: # Inactive recipient - # All recipients were rejected as hard-bounce or spam-complaint. Email not sent. - # response["To"] is not populated for this error; must examine response["Message"]: - # "You tried to send to a recipient that has been marked as inactive.\n - # Found inactive addresses: {addr_spec, ...}.\n - # Inactive recipients are ones that have generated a hard bounce or a spam complaint. " + # All recipients were rejected as hard-bounce or spam-complaint. Email + # not sent. response["To"] is not populated for this error; must examine + # response["Message"]: + # "You tried to send to a recipient that has been marked as + # inactive.\n Found inactive addresses: {addr_spec, ...}.\n + # Inactive recipients are ones that have generated a hard bounce + # or a spam complaint. " reject_addr_specs = self._addr_specs_from_error_msg( - msg, r'inactive addresses:\s*(.*)\.\s*Inactive recipients') + msg, r"inactive addresses:\s*(.*)\.\s*Inactive recipients" + ) for reject_addr_spec in reject_addr_specs: recipient_status[reject_addr_spec] = AnymailRecipientStatus( - message_id=None, status='rejected') + message_id=None, status="rejected" + ) else: # Other error - raise AnymailRequestsAPIError(email_message=message, payload=payload, response=response, - backend=self) + raise AnymailRequestsAPIError( + email_message=message, + payload=payload, + response=response, + backend=self, + ) return dict(recipient_status) @@ -141,33 +192,37 @@ class EmailBackend(AnymailRequestsBackend): match = re.search(pattern, error_msg, re.MULTILINE) if match: emails = match.group(1) # "one@xample.com, two@example.com" - return [email.strip().lower() for email in emails.split(',')] + return [email.strip().lower() for email in emails.split(",")] else: return [] class PostmarkPayload(RequestsPayload): - def __init__(self, message, defaults, backend, *args, **kwargs): headers = { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - # 'X-Postmark-Server-Token': see get_request_params (and set_esp_extra) + "Content-Type": "application/json", + "Accept": "application/json", + # "X-Postmark-Server-Token": see get_request_params (and set_esp_extra) } - self.server_token = backend.server_token # added to headers later, so esp_extra can override + self.server_token = backend.server_token # esp_extra can override self.to_emails = [] - self.cc_and_bcc_emails = [] # need to track (separately) for parse_recipient_status + self.cc_and_bcc_emails = [] # needed for parse_recipient_status self.merge_data = None self.merge_metadata = None super().__init__(message, defaults, backend, headers=headers, *args, **kwargs) def get_api_endpoint(self): batch_send = self.is_batch() - if 'TemplateAlias' in self.data or 'TemplateId' in self.data or 'TemplateModel' in self.data: + if ( + "TemplateAlias" in self.data + or "TemplateId" in self.data + or "TemplateModel" in self.data + ): if batch_send: return "email/batchWithTemplates" else: - # This is the one Postmark API documented to have a trailing slash. (Typo?) + # This is the one Postmark API documented to have a trailing slash. + # (Typo?) return "email/withTemplate/" else: if batch_send: @@ -177,7 +232,7 @@ class PostmarkPayload(RequestsPayload): def get_request_params(self, api_url): params = super().get_request_params(api_url) - params['headers']['X-Postmark-Server-Token'] = self.server_token + params["headers"]["X-Postmark-Server-Token"] = self.server_token return params def serialize_data(self): @@ -189,11 +244,15 @@ class PostmarkPayload(RequestsPayload): elif api_endpoint == "email/batch": data = [self.data_for_recipient(to) for to in self.to_emails] elif api_endpoint == "email/withTemplate/": - assert self.merge_data is None and self.merge_metadata is None # else it's a batch send + assert ( + self.merge_data is None and self.merge_metadata is None + ) # else it's a batch send data = self.data else: - raise AssertionError("PostmarkPayload.serialize_data missing" - " case for api_endpoint %r" % api_endpoint) + raise AssertionError( + "PostmarkPayload.serialize_data missing" + " case for api_endpoint %r" % api_endpoint + ) return self.serialize_json(data) def data_for_recipient(self, to): @@ -222,7 +281,7 @@ class PostmarkPayload(RequestsPayload): # def init_payload(self): - self.data = {} # becomes json + self.data = {} # becomes json def set_from_email_list(self, emails): # Postmark accepts multiple From email addresses @@ -233,7 +292,7 @@ class PostmarkPayload(RequestsPayload): assert recipient_type in ["to", "cc", "bcc"] if emails: field = recipient_type.capitalize() - self.data[field] = ', '.join([email.address for email in emails]) + self.data[field] = ", ".join([email.address for email in emails]) if recipient_type == "to": self.to_emails = emails else: @@ -249,8 +308,7 @@ class PostmarkPayload(RequestsPayload): def set_extra_headers(self, headers): self.data["Headers"] = [ - {"Name": key, "Value": value} - for key, value in headers.items() + {"Name": key, "Value": value} for key, value in headers.items() ] def set_text_body(self, body): @@ -258,7 +316,8 @@ class PostmarkPayload(RequestsPayload): def set_html_body(self, body): if "HtmlBody" in self.data: - # second html body could show up through multiple alternatives, or html body + alternative + # second html body could show up through multiple alternatives, + # or html body + alternative self.unsupported_feature("multiple html parts") self.data["HtmlBody"] = body @@ -289,10 +348,10 @@ class PostmarkPayload(RequestsPayload): if len(tags) > 0: self.data["Tag"] = tags[0] if len(tags) > 1: - self.unsupported_feature('multiple tags (%r)' % tags) + self.unsupported_feature("multiple tags (%r)" % tags) def set_track_clicks(self, track_clicks): - self.data["TrackLinks"] = 'HtmlAndText' if track_clicks else 'None' + self.data["TrackLinks"] = "HtmlAndText" if track_clicks else "None" def set_track_opens(self, track_opens): self.data["TrackOpens"] = track_opens @@ -327,4 +386,4 @@ class PostmarkPayload(RequestsPayload): def set_esp_extra(self, extra): self.data.update(extra) # Special handling for 'server_token': - self.server_token = self.data.pop('server_token', self.server_token) + self.server_token = self.data.pop("server_token", self.server_token) diff --git a/anymail/backends/sendgrid.py b/anymail/backends/sendgrid.py index 1d9c260..13ae941 100644 --- a/anymail/backends/sendgrid.py +++ b/anymail/backends/sendgrid.py @@ -4,10 +4,10 @@ from email.utils import quote as rfc822_quote from requests.structures import CaseInsensitiveDict -from .base_requests import AnymailRequestsBackend, RequestsPayload from ..exceptions import AnymailConfigurationError, AnymailWarning from ..message import AnymailRecipientStatus from ..utils import BASIC_NUMERIC_TYPES, Mapping, get_anymail_setting, update_deep +from .base_requests import AnymailRequestsBackend, RequestsPayload class EmailBackend(AnymailRequestsBackend): @@ -22,29 +22,45 @@ class EmailBackend(AnymailRequestsBackend): esp_name = self.esp_name # Warn if v2-only username or password settings found - username = get_anymail_setting('username', esp_name=esp_name, kwargs=kwargs, default=None, allow_bare=True) - password = get_anymail_setting('password', esp_name=esp_name, kwargs=kwargs, default=None, allow_bare=True) + username = get_anymail_setting( + "username", esp_name=esp_name, kwargs=kwargs, default=None, allow_bare=True + ) + password = get_anymail_setting( + "password", esp_name=esp_name, kwargs=kwargs, default=None, allow_bare=True + ) if username or password: raise AnymailConfigurationError( - "SendGrid v3 API doesn't support username/password auth; Please change to API key.") + "SendGrid v3 API doesn't support username/password auth;" + " Please change to API key." + ) - self.api_key = get_anymail_setting('api_key', esp_name=esp_name, kwargs=kwargs, allow_bare=True) + self.api_key = get_anymail_setting( + "api_key", esp_name=esp_name, kwargs=kwargs, allow_bare=True + ) - self.generate_message_id = get_anymail_setting('generate_message_id', esp_name=esp_name, - kwargs=kwargs, default=True) - self.merge_field_format = get_anymail_setting('merge_field_format', esp_name=esp_name, - kwargs=kwargs, default=None) + self.generate_message_id = get_anymail_setting( + "generate_message_id", esp_name=esp_name, kwargs=kwargs, default=True + ) + self.merge_field_format = get_anymail_setting( + "merge_field_format", esp_name=esp_name, kwargs=kwargs, default=None + ) - # Undocumented setting to disable workaround for SendGrid display-name quoting bug (see below). - # If/when SendGrid fixes their API, recipient names will end up with extra double quotes - # until Anymail is updated to remove the workaround. In the meantime, you can disable it - # by adding `"SENDGRID_WORKAROUND_NAME_QUOTE_BUG": False` to your `ANYMAIL` settings. - self.workaround_name_quote_bug = get_anymail_setting('workaround_name_quote_bug', esp_name=esp_name, - kwargs=kwargs, default=True) + # Undocumented setting to disable workaround for SendGrid display-name quoting + # bug (see below). If/when SendGrid fixes their API, recipient names will end up + # with extra double quotes until Anymail is updated to remove the workaround. + # In the meantime, you can disable it by adding + # `"SENDGRID_WORKAROUND_NAME_QUOTE_BUG": False` to your `ANYMAIL` settings. + self.workaround_name_quote_bug = get_anymail_setting( + "workaround_name_quote_bug", esp_name=esp_name, kwargs=kwargs, default=True + ) # This is SendGrid's newer Web API v3 - api_url = get_anymail_setting('api_url', esp_name=esp_name, kwargs=kwargs, - default="https://api.sendgrid.com/v3/") + api_url = get_anymail_setting( + "api_url", + esp_name=esp_name, + kwargs=kwargs, + default="https://api.sendgrid.com/v3/", + ) if not api_url.endswith("/"): api_url += "/" super().__init__(api_url, **kwargs) @@ -53,17 +69,19 @@ class EmailBackend(AnymailRequestsBackend): return SendGridPayload(message, defaults, self) def parse_recipient_status(self, response, payload, message): - # If we get here, the send call was successful. - # (SendGrid uses a non-2xx response for any failures, caught in raise_for_status.) - # SendGrid v3 doesn't provide any information in the response for a successful send, - # so simulate a per-recipient status of "queued": - return {recip.addr_spec: AnymailRecipientStatus(message_id=payload.message_ids.get(recip.addr_spec), - status="queued") - for recip in payload.all_recipients} + # If we get here, the "send" call was successful. (SendGrid uses a non-2xx + # response for any failures, caught in raise_for_status.) SendGrid v3 doesn't + # provide any information in the response for a successful send, so simulate a + # per-recipient status of "queued": + return { + recip.addr_spec: AnymailRecipientStatus( + message_id=payload.message_ids.get(recip.addr_spec), status="queued" + ) + for recip in payload.all_recipients + } class SendGridPayload(RequestsPayload): - def __init__(self, message, defaults, backend, *args, **kwargs): self.all_recipients = [] # used for backend.parse_recipient_status self.generate_message_id = backend.generate_message_id @@ -75,11 +93,13 @@ class SendGridPayload(RequestsPayload): self.merge_global_data = {} self.merge_metadata = {} - http_headers = kwargs.pop('headers', {}) - http_headers['Authorization'] = 'Bearer %s' % backend.api_key - http_headers['Content-Type'] = 'application/json' - http_headers['Accept'] = 'application/json' - super().__init__(message, defaults, backend, headers=http_headers, *args, **kwargs) + http_headers = kwargs.pop("headers", {}) + http_headers["Authorization"] = "Bearer %s" % backend.api_key + http_headers["Content-Type"] = "application/json" + http_headers["Accept"] = "application/json" + super().__init__( + message, defaults, backend, headers=http_headers, *args, **kwargs + ) def get_api_endpoint(self): return "mail/send" @@ -105,11 +125,17 @@ class SendGridPayload(RequestsPayload): return self.serialize_json(self.data) def set_anymail_id(self): - """Ensure each personalization has a known anymail_id for later event tracking""" + """ + Ensure each personalization has a known anymail_id for later event tracking + """ for personalization in self.data["personalizations"]: message_id = str(uuid.uuid4()) personalization.setdefault("custom_args", {})["anymail_id"] = message_id - for recipient in personalization["to"] + personalization.get("cc", []) + personalization.get("bcc", []): + for recipient in ( + personalization["to"] + + personalization.get("cc", []) + + personalization.get("bcc", []) + ): self.message_ids[recipient["email"]] = message_id def expand_personalizations_for_batch(self): @@ -139,8 +165,10 @@ class SendGridPayload(RequestsPayload): self.convert_dynamic_template_data_to_legacy_substitutions() def convert_dynamic_template_data_to_legacy_substitutions(self): - """Change personalizations[...]['dynamic_template_data'] to ...['substitutions]""" - merge_field_format = self.merge_field_format or '{}' + """ + Change personalizations[...]['dynamic_template_data'] to ...['substitutions] + """ + merge_field_format = self.merge_field_format or "{}" all_merge_fields = set() for personalization in self.data["personalizations"]: @@ -149,10 +177,12 @@ class SendGridPayload(RequestsPayload): except KeyError: pass # no substitutions for this recipient else: - # Convert dynamic_template_data keys for substitutions, using merge_field_format + # Convert dynamic_template_data keys for substitutions, + # using merge_field_format personalization["substitutions"] = { merge_field_format.format(field): data - for field, data in dynamic_template_data.items()} + for field, data in dynamic_template_data.items() + } all_merge_fields.update(dynamic_template_data.keys()) if self.merge_field_format is None: @@ -160,15 +190,21 @@ class SendGridPayload(RequestsPayload): warnings.warn( "Your SendGrid merge fields don't seem to have delimiters, " "which can cause unexpected results with Anymail's merge_data. " - "Search SENDGRID_MERGE_FIELD_FORMAT in the Anymail docs for more info.", - AnymailWarning) + "Search SENDGRID_MERGE_FIELD_FORMAT in the Anymail docs " + "for more info.", + AnymailWarning, + ) - if self.merge_global_data and all(field.isalnum() for field in self.merge_global_data.keys()): + if self.merge_global_data and all( + field.isalnum() for field in self.merge_global_data.keys() + ): warnings.warn( "Your SendGrid global merge fields don't seem to have delimiters, " "which can cause unexpected results with Anymail's merge_data. " - "Search SENDGRID_MERGE_FIELD_FORMAT in the Anymail docs for more info.", - AnymailWarning) + "Search SENDGRID_MERGE_FIELD_FORMAT in the Anymail docs " + "for more info.", + AnymailWarning, + ) def build_merge_metadata(self): if self.merge_metadata: @@ -208,8 +244,9 @@ class SendGridPayload(RequestsPayload): workaround_name_quote_bug = self.workaround_name_quote_bug # Normally, exactly one "personalizations" entry for all recipients # (Exception: with merge_data; will be burst apart later.) - self.data["personalizations"][0][recipient_type] = \ - [self.email_object(email, workaround_name_quote_bug) for email in emails] + self.data["personalizations"][0][recipient_type] = [ + self.email_object(email, workaround_name_quote_bug) for email in emails + ] self.all_recipients += emails # used for backend.parse_recipient_status def set_subject(self, subject): @@ -226,10 +263,12 @@ class SendGridPayload(RequestsPayload): def set_extra_headers(self, headers): # SendGrid requires header values to be strings -- not integers. # We'll stringify ints and floats; anything else is the caller's responsibility. - self.data["headers"].update({ - k: str(v) if isinstance(v, BASIC_NUMERIC_TYPES) else v - for k, v in headers.items() - }) + self.data["headers"].update( + { + k: str(v) if isinstance(v, BASIC_NUMERIC_TYPES) else v + for k, v in headers.items() + } + ) def set_text_body(self, body): # Empty strings (the EmailMessage default) can cause unexpected SendGrid @@ -238,33 +277,41 @@ class SendGridPayload(RequestsPayload): # Treat an empty string as a request to omit the body # (which means use the template content if present.) if body != "": - self.data.setdefault("content", []).append({ - "type": "text/plain", - "value": body, - }) + self.data.setdefault("content", []).append( + { + "type": "text/plain", + "value": body, + } + ) def set_html_body(self, body): # SendGrid's API permits multiple html bodies # "If you choose to include the text/plain or text/html mime types, they must be # the first indices of the content array in the order text/plain, text/html." - if body != "": # see note in set_text_body about template rendering - self.data.setdefault("content", []).append({ - "type": "text/html", - "value": body, - }) + # Body must not be empty (see note in set_text_body about template rendering). + if body != "": + self.data.setdefault("content", []).append( + { + "type": "text/html", + "value": body, + } + ) def add_alternative(self, content, mimetype): - # SendGrid is one of the few ESPs that supports arbitrary alternative parts in their API - self.data.setdefault("content", []).append({ - "type": mimetype, - "value": content, - }) + # SendGrid is one of the few ESPs that supports arbitrary alternative parts + self.data.setdefault("content", []).append( + { + "type": mimetype, + "value": content, + } + ) def add_attachment(self, attachment): att = { "content": attachment.b64content, "type": attachment.mimetype, - "filename": attachment.name or '', # required -- submit empty string if unknown + # (filename is required -- submit empty string if unknown) + "filename": attachment.name or "", } if attachment.inline: att["disposition"] = "inline" @@ -276,9 +323,10 @@ class SendGridPayload(RequestsPayload): def transform_metadata(self, metadata): # SendGrid requires custom_args values to be strings -- not integers. - # (And issues the cryptic error {"field": null, "message": "Bad Request", "help": null} + # (And issues the cryptic error + # {"field": null, "message": "Bad Request", "help": null} # if they're not.) - # We'll stringify ints and floats; anything else is the caller's responsibility. + # Stringify ints and floats; anything else is the caller's responsibility. return { k: str(v) if isinstance(v, BASIC_NUMERIC_TYPES) else v for k, v in metadata.items() @@ -330,8 +378,12 @@ class SendGridPayload(RequestsPayload): self.merge_metadata = merge_metadata def set_esp_extra(self, extra): - self.merge_field_format = extra.pop("merge_field_format", self.merge_field_format) - self.use_dynamic_template = extra.pop("use_dynamic_template", self.use_dynamic_template) + self.merge_field_format = extra.pop( + "merge_field_format", self.merge_field_format + ) + self.use_dynamic_template = extra.pop( + "use_dynamic_template", self.use_dynamic_template + ) if isinstance(extra.get("personalizations", None), Mapping): # merge personalizations *dict* into other message personalizations assert len(self.data["personalizations"]) == 1 @@ -339,6 +391,7 @@ class SendGridPayload(RequestsPayload): if "x-smtpapi" in extra: raise AnymailConfigurationError( "You are attempting to use SendGrid v2 API-style x-smtpapi params " - "with the SendGrid v3 API. Please update your `esp_extra` to the new API." + "with the SendGrid v3 API. Please update your `esp_extra` " + "to the new API." ) update_deep(self.data, extra) diff --git a/anymail/backends/sendinblue.py b/anymail/backends/sendinblue.py index 1b69225..06f9c87 100644 --- a/anymail/backends/sendinblue.py +++ b/anymail/backends/sendinblue.py @@ -1,9 +1,9 @@ from requests.structures import CaseInsensitiveDict -from .base_requests import AnymailRequestsBackend, RequestsPayload from ..exceptions import AnymailRequestsAPIError from ..message import AnymailRecipientStatus -from ..utils import get_anymail_setting, BASIC_NUMERIC_TYPES +from ..utils import BASIC_NUMERIC_TYPES, get_anymail_setting +from .base_requests import AnymailRequestsBackend, RequestsPayload class EmailBackend(AnymailRequestsBackend): @@ -17,13 +17,13 @@ class EmailBackend(AnymailRequestsBackend): """Init options from Django settings""" esp_name = self.esp_name self.api_key = get_anymail_setting( - 'api_key', + "api_key", esp_name=esp_name, kwargs=kwargs, allow_bare=True, ) api_url = get_anymail_setting( - 'api_url', + "api_url", esp_name=esp_name, kwargs=kwargs, default="https://api.sendinblue.com/v3/", @@ -40,42 +40,45 @@ class EmailBackend(AnymailRequestsBackend): # https://developers.sendinblue.com/docs/responses message_id = None - if response.content != b'': + if response.content != b"": parsed_response = self.deserialize_json_response(response, payload, message) try: - message_id = parsed_response['messageId'] + message_id = parsed_response["messageId"] except (KeyError, TypeError) as err: - raise AnymailRequestsAPIError("Invalid SendinBlue API response format", - email_message=message, payload=payload, response=response, - backend=self) from err + raise AnymailRequestsAPIError( + "Invalid SendinBlue API response format", + email_message=message, + payload=payload, + response=response, + backend=self, + ) from err status = AnymailRecipientStatus(message_id=message_id, status="queued") return {recipient.addr_spec: status for recipient in payload.all_recipients} class SendinBluePayload(RequestsPayload): - def __init__(self, message, defaults, backend, *args, **kwargs): self.all_recipients = [] # used for backend.parse_recipient_status - http_headers = kwargs.pop('headers', {}) - http_headers['api-key'] = backend.api_key - http_headers['Content-Type'] = 'application/json' + http_headers = kwargs.pop("headers", {}) + http_headers["api-key"] = backend.api_key + http_headers["Content-Type"] = "application/json" - super().__init__(message, defaults, backend, headers=http_headers, *args, **kwargs) + super().__init__( + message, defaults, backend, headers=http_headers, *args, **kwargs + ) def get_api_endpoint(self): return "smtp/email" def init_payload(self): - self.data = { # becomes json - 'headers': CaseInsensitiveDict() - } + self.data = {"headers": CaseInsensitiveDict()} # becomes json def serialize_data(self): """Performs any necessary serialization on self.data, and returns the result.""" - if not self.data['headers']: - del self.data['headers'] # don't send empty headers + if not self.data["headers"]: + del self.data["headers"] # don't send empty headers return self.serialize_json(self.data) # @@ -86,9 +89,9 @@ class SendinBluePayload(RequestsPayload): def email_object(email): """Converts EmailAddress to SendinBlue API array""" email_object = dict() - email_object['email'] = email.addr_spec + email_object["email"] = email.addr_spec if email.display_name: - email_object['name'] = email.display_name + email_object["name"] = email.display_name return email_object def set_from_email(self, email): @@ -109,39 +112,41 @@ class SendinBluePayload(RequestsPayload): if len(emails) > 1: self.unsupported_feature("multiple reply_to addresses") if len(emails) > 0: - self.data['replyTo'] = self.email_object(emails[0]) + self.data["replyTo"] = self.email_object(emails[0]) def set_extra_headers(self, headers): - # SendinBlue requires header values to be strings -- not integers -- as of 11/2022. - # We'll stringify ints and floats; anything else is the caller's responsibility. - self.data["headers"].update({ - k: str(v) if isinstance(v, BASIC_NUMERIC_TYPES) else v - for k, v in headers.items() - }) + # SendinBlue requires header values to be strings (not integers) as of 11/2022. + # Stringify ints and floats; anything else is the caller's responsibility. + self.data["headers"].update( + { + k: str(v) if isinstance(v, BASIC_NUMERIC_TYPES) else v + for k, v in headers.items() + } + ) def set_tags(self, tags): if len(tags) > 0: - self.data['tags'] = tags + self.data["tags"] = tags def set_template_id(self, template_id): - self.data['templateId'] = template_id + self.data["templateId"] = template_id def set_text_body(self, body): if body: - self.data['textContent'] = body + self.data["textContent"] = body def set_html_body(self, body): if body: if "htmlContent" in self.data: self.unsupported_feature("multiple html parts") - self.data['htmlContent'] = body + self.data["htmlContent"] = body def add_attachment(self, attachment): """Converts attachments to SendinBlue API {name, base64} array""" att = { - 'name': attachment.name or '', - 'content': attachment.b64content, + "name": attachment.name or "", + "content": attachment.b64content, } if attachment.inline: @@ -157,15 +162,15 @@ class SendinBluePayload(RequestsPayload): self.unsupported_feature("merge_data") def set_merge_global_data(self, merge_global_data): - self.data['params'] = merge_global_data + self.data["params"] = merge_global_data def set_metadata(self, metadata): # SendinBlue expects a single string payload - self.data['headers']["X-Mailin-custom"] = self.serialize_json(metadata) + self.data["headers"]["X-Mailin-custom"] = self.serialize_json(metadata) def set_send_at(self, send_at): try: start_time_iso = send_at.isoformat(timespec="milliseconds") except (AttributeError, TypeError): start_time_iso = send_at # assume user already formatted - self.data['scheduledAt'] = start_time_iso + self.data["scheduledAt"] = start_time_iso diff --git a/anymail/backends/sparkpost.py b/anymail/backends/sparkpost.py index 0dc7f8c..33f8d8f 100644 --- a/anymail/backends/sparkpost.py +++ b/anymail/backends/sparkpost.py @@ -1,7 +1,7 @@ -from .base_requests import AnymailRequestsBackend, RequestsPayload from ..exceptions import AnymailRequestsAPIError from ..message import AnymailRecipientStatus from ..utils import get_anymail_setting, update_deep +from .base_requests import AnymailRequestsBackend, RequestsPayload class EmailBackend(AnymailRequestsBackend): @@ -13,12 +13,18 @@ class EmailBackend(AnymailRequestsBackend): def __init__(self, **kwargs): """Init options from Django settings""" - self.api_key = get_anymail_setting('api_key', esp_name=self.esp_name, - kwargs=kwargs, allow_bare=True) - self.subaccount = get_anymail_setting('subaccount', esp_name=self.esp_name, - kwargs=kwargs, default=None) - api_url = get_anymail_setting('api_url', esp_name=self.esp_name, kwargs=kwargs, - default="https://api.sparkpost.com/api/v1/") + self.api_key = get_anymail_setting( + "api_key", esp_name=self.esp_name, kwargs=kwargs, allow_bare=True + ) + self.subaccount = get_anymail_setting( + "subaccount", esp_name=self.esp_name, kwargs=kwargs, default=None + ) + api_url = get_anymail_setting( + "api_url", + esp_name=self.esp_name, + kwargs=kwargs, + default="https://api.sparkpost.com/api/v1/", + ) if not api_url.endswith("/"): api_url += "/" super().__init__(api_url, **kwargs) @@ -34,9 +40,13 @@ class EmailBackend(AnymailRequestsBackend): rejected = results["total_rejected_recipients"] transmission_id = results["id"] except (KeyError, TypeError) as err: - raise AnymailRequestsAPIError("Invalid SparkPost API response format", - email_message=message, payload=payload, - response=response, backend=self) from err + raise AnymailRequestsAPIError( + "Invalid SparkPost API response format", + email_message=message, + payload=payload, + response=response, + backend=self, + ) from err # SparkPost doesn't (yet*) tell us *which* recipients were accepted or rejected. # (* looks like undocumented 'rcpt_to_errors' might provide this info.) @@ -44,26 +54,32 @@ class EmailBackend(AnymailRequestsBackend): # else just report 'unknown' for all recipients. recipient_count = len(payload.recipients) if accepted == recipient_count and rejected == 0: - status = 'queued' + status = "queued" elif rejected == recipient_count and accepted == 0: - status = 'rejected' + status = "rejected" else: # mixed results, or wrong total - status = 'unknown' - recipient_status = AnymailRecipientStatus(message_id=transmission_id, status=status) - return {recipient.addr_spec: recipient_status for recipient in payload.recipients} + status = "unknown" + recipient_status = AnymailRecipientStatus( + message_id=transmission_id, status=status + ) + return { + recipient.addr_spec: recipient_status for recipient in payload.recipients + } class SparkPostPayload(RequestsPayload): def __init__(self, message, defaults, backend, *args, **kwargs): http_headers = { - 'Authorization': backend.api_key, - 'Content-Type': 'application/json', + "Authorization": backend.api_key, + "Content-Type": "application/json", } if backend.subaccount is not None: - http_headers['X-MSYS-SUBACCOUNT'] = backend.subaccount + http_headers["X-MSYS-SUBACCOUNT"] = backend.subaccount self.recipients = [] # all recipients, for backend parse_recipient_status self.cc_and_bcc = [] # for _finalize_recipients - super().__init__(message, defaults, backend, headers=http_headers, *args, **kwargs) + super().__init__( + message, defaults, backend, headers=http_headers, *args, **kwargs + ) def get_api_endpoint(self): return "transmissions/" @@ -74,15 +90,15 @@ class SparkPostPayload(RequestsPayload): def _finalize_recipients(self): # https://www.sparkpost.com/docs/faq/cc-bcc-with-rest-api/ - # self.data["recipients"] is currently a list of all to-recipients. We need to add - # all cc and bcc recipients. Exactly how depends on whether this is a batch send. + # self.data["recipients"] is currently a list of all to-recipients. Must add all + # cc and bcc recipients. Exactly how depends on whether this is a batch send. if self.is_batch(): # For batch sends, must duplicate the cc/bcc for *every* to-recipient # (using each to-recipient's metadata and substitutions). extra_recipients = [] for to_recipient in self.data["recipients"]: for email in self.cc_and_bcc: - extra = to_recipient.copy() # capture "metadata" and "substitutions", if any + extra = to_recipient.copy() # gets "metadata" and "substitutions" extra["address"] = { "email": email.addr_spec, "header_to": to_recipient["address"]["header_to"], @@ -94,17 +110,21 @@ class SparkPostPayload(RequestsPayload): # "To" header to show all the "To" recipients... full_to_header = ", ".join( to_recipient["address"]["header_to"] - for to_recipient in self.data["recipients"]) + for to_recipient in self.data["recipients"] + ) for recipient in self.data["recipients"]: recipient["address"]["header_to"] = full_to_header # ... and then simply add the cc/bcc to the end of the list. # (There is no per-recipient data, or it would be a batch send.) self.data["recipients"].extend( - {"address": { - "email": email.addr_spec, - "header_to": full_to_header, - }} - for email in self.cc_and_bcc) + { + "address": { + "email": email.addr_spec, + "header_to": full_to_header, + } + } + for email in self.cc_and_bcc + ) # # Payload construction @@ -127,11 +147,14 @@ class SparkPostPayload(RequestsPayload): # (We use "header_to" rather than "name" to simplify some logic # in _finalize_recipients; the results end up the same.) self.data["recipients"].extend( - {"address": { - "email": email.addr_spec, - "header_to": email.address, - }} - for email in emails) + { + "address": { + "email": email.addr_spec, + "header_to": email.address, + } + } + for email in emails + ) self.recipients += emails def set_cc(self, emails): @@ -155,7 +178,9 @@ class SparkPostPayload(RequestsPayload): def set_reply_to(self, emails): if emails: - self.data["content"]["reply_to"] = ", ".join(email.address for email in emails) + self.data["content"]["reply_to"] = ", ".join( + email.address for email in emails + ) def set_extra_headers(self, headers): if headers: @@ -166,7 +191,8 @@ class SparkPostPayload(RequestsPayload): def set_html_body(self, body): if "html" in self.data["content"]: - # second html body could show up through multiple alternatives, or html body + alternative + # second html body could show up through multiple alternatives, + # or html body + alternative self.unsupported_feature("multiple html parts") self.data["content"]["html"] = body @@ -179,19 +205,27 @@ class SparkPostPayload(RequestsPayload): super().add_alternative(content, mimetype) def set_attachments(self, atts): - attachments = [{ - "name": att.name or "", - "type": att.content_type, - "data": att.b64content, - } for att in atts if not att.inline] + attachments = [ + { + "name": att.name or "", + "type": att.content_type, + "data": att.b64content, + } + for att in atts + if not att.inline + ] if attachments: self.data["content"]["attachments"] = attachments - inline_images = [{ - "name": att.cid, - "type": att.mimetype, - "data": att.b64content, - } for att in atts if att.inline] + inline_images = [ + { + "name": att.cid, + "type": att.mimetype, + "data": att.b64content, + } + for att in atts + if att.inline + ] if inline_images: self.data["content"]["inline_images"] = inline_images diff --git a/anymail/backends/test.py b/anymail/backends/test.py index 4a77c95..3a9d535 100644 --- a/anymail/backends/test.py +++ b/anymail/backends/test.py @@ -1,22 +1,24 @@ from django.core import mail -from .base import AnymailBaseBackend, BasePayload from ..exceptions import AnymailAPIError from ..message import AnymailRecipientStatus +from .base import AnymailBaseBackend, BasePayload class EmailBackend(AnymailBaseBackend): """ Anymail backend that simulates sending messages, useful for testing. - Sent messages are collected in django.core.mail.outbox (as with Django's locmem backend). + Sent messages are collected in django.core.mail.outbox + (as with Django's locmem backend). In addition: - * Anymail send params parsed from the message will be attached to the outbox message - as a dict in the attr `anymail_test_params` - * If the caller supplies an `anymail_test_response` attr on the message, that will be - used instead of the default "sent" response. It can be either an AnymailRecipientStatus - or an instance of AnymailAPIError (or a subclass) to raise an exception. + * Anymail send params parsed from the message will be attached + to the outbox message as a dict in the attr `anymail_test_params` + * If the caller supplies an `anymail_test_response` attr on the message, + that will be used instead of the default "sent" response. It can be either + an AnymailRecipientStatus or an instance of AnymailAPIError (or a subclass) + to raise an exception. """ esp_name = "Test" @@ -24,9 +26,9 @@ class EmailBackend(AnymailBaseBackend): def __init__(self, *args, **kwargs): # Allow replacing the payload, for testing. # (Real backends would generally not implement this option.) - self._payload_class = kwargs.pop('payload_class', TestPayload) + self._payload_class = kwargs.pop("payload_class", TestPayload) super().__init__(*args, **kwargs) - if not hasattr(mail, 'outbox'): + if not hasattr(mail, "outbox"): mail.outbox = [] # see django.core.mail.backends.locmem def get_esp_message_id(self, message): @@ -49,19 +51,20 @@ class EmailBackend(AnymailBaseBackend): except AttributeError: # Default is to return 'sent' for each recipient status = AnymailRecipientStatus( - message_id=self.get_esp_message_id(message), - status='sent' + message_id=self.get_esp_message_id(message), status="sent" ) response = { - 'recipient_status': {email: status for email in payload.recipient_emails} + "recipient_status": { + email: status for email in payload.recipient_emails + } } return response def parse_recipient_status(self, response, payload, message): try: - return response['recipient_status'] + return response["recipient_status"] except KeyError as err: - raise AnymailAPIError('Unparsable test response') from err + raise AnymailAPIError("Unparsable test response") from err class TestPayload(BasePayload): @@ -76,79 +79,79 @@ class TestPayload(BasePayload): def get_params(self): # Test backend callers can check message.anymail_test_params['is_batch_send'] # to verify whether Anymail thought the message should use batch send logic. - self.params['is_batch_send'] = self.is_batch() + self.params["is_batch_send"] = self.is_batch() return self.params def set_from_email(self, email): - self.params['from'] = email + self.params["from"] = email def set_envelope_sender(self, email): - self.params['envelope_sender'] = email.addr_spec + self.params["envelope_sender"] = email.addr_spec def set_to(self, emails): - self.params['to'] = emails + self.params["to"] = emails self.recipient_emails += [email.addr_spec for email in emails] def set_cc(self, emails): - self.params['cc'] = emails + self.params["cc"] = emails self.recipient_emails += [email.addr_spec for email in emails] def set_bcc(self, emails): - self.params['bcc'] = emails + self.params["bcc"] = emails self.recipient_emails += [email.addr_spec for email in emails] def set_subject(self, subject): - self.params['subject'] = subject + self.params["subject"] = subject def set_reply_to(self, emails): - self.params['reply_to'] = emails + self.params["reply_to"] = emails def set_extra_headers(self, headers): - self.params['extra_headers'] = headers + self.params["extra_headers"] = headers def set_text_body(self, body): - self.params['text_body'] = body + self.params["text_body"] = body def set_html_body(self, body): - self.params['html_body'] = body + self.params["html_body"] = body def add_alternative(self, content, mimetype): # For testing purposes, we allow all "text/*" alternatives, # but not any other mimetypes. - if mimetype.startswith('text'): - self.params.setdefault('alternatives', []).append((content, mimetype)) + if mimetype.startswith("text"): + self.params.setdefault("alternatives", []).append((content, mimetype)) else: self.unsupported_feature("alternative part with type '%s'" % mimetype) def add_attachment(self, attachment): - self.params.setdefault('attachments', []).append(attachment) + self.params.setdefault("attachments", []).append(attachment) def set_metadata(self, metadata): - self.params['metadata'] = metadata + self.params["metadata"] = metadata def set_send_at(self, send_at): - self.params['send_at'] = send_at + self.params["send_at"] = send_at def set_tags(self, tags): - self.params['tags'] = tags + self.params["tags"] = tags def set_track_clicks(self, track_clicks): - self.params['track_clicks'] = track_clicks + self.params["track_clicks"] = track_clicks def set_track_opens(self, track_opens): - self.params['track_opens'] = track_opens + self.params["track_opens"] = track_opens def set_template_id(self, template_id): - self.params['template_id'] = template_id + self.params["template_id"] = template_id def set_merge_data(self, merge_data): - self.params['merge_data'] = merge_data + self.params["merge_data"] = merge_data def set_merge_metadata(self, merge_metadata): - self.params['merge_metadata'] = merge_metadata + self.params["merge_metadata"] = merge_metadata def set_merge_global_data(self, merge_global_data): - self.params['merge_global_data'] = merge_global_data + self.params["merge_global_data"] = merge_global_data def set_esp_extra(self, extra): # Merge extra into params diff --git a/anymail/checks.py b/anymail/checks.py index 55ad219..e9d0994 100644 --- a/anymail/checks.py +++ b/anymail/checks.py @@ -13,17 +13,23 @@ def check_deprecated_settings(app_configs, **kwargs): # anymail.E001: rename WEBHOOK_AUTHORIZATION to WEBHOOK_SECRET if "WEBHOOK_AUTHORIZATION" in anymail_settings: - errors.append(checks.Error( - "The ANYMAIL setting 'WEBHOOK_AUTHORIZATION' has been renamed 'WEBHOOK_SECRET' to improve security.", - hint="You must update your settings.py.", - id="anymail.E001", - )) + errors.append( + checks.Error( + "The ANYMAIL setting 'WEBHOOK_AUTHORIZATION' has been renamed" + " 'WEBHOOK_SECRET' to improve security.", + hint="You must update your settings.py.", + id="anymail.E001", + ) + ) if hasattr(settings, "ANYMAIL_WEBHOOK_AUTHORIZATION"): - errors.append(checks.Error( - "The ANYMAIL_WEBHOOK_AUTHORIZATION setting has been renamed ANYMAIL_WEBHOOK_SECRET to improve security.", - hint="You must update your settings.py.", - id="anymail.E001", - )) + errors.append( + checks.Error( + "The ANYMAIL_WEBHOOK_AUTHORIZATION setting has been renamed" + " ANYMAIL_WEBHOOK_SECRET to improve security.", + hint="You must update your settings.py.", + id="anymail.E001", + ) + ) return errors @@ -33,11 +39,13 @@ def check_insecure_settings(app_configs, **kwargs): # anymail.W002: DEBUG_API_REQUESTS can leak private information if get_anymail_setting("debug_api_requests", default=False) and not settings.DEBUG: - errors.append(checks.Warning( - "You have enabled the ANYMAIL setting DEBUG_API_REQUESTS, which can " - "leak API keys and other sensitive data into logs or the console.", - hint="You should not use DEBUG_API_REQUESTS in production deployment.", - id="anymail.W002", - )) + errors.append( + checks.Warning( + "You have enabled the ANYMAIL setting DEBUG_API_REQUESTS, which can " + "leak API keys and other sensitive data into logs or the console.", + hint="You should not use DEBUG_API_REQUESTS in production deployment.", + id="anymail.W002", + ) + ) return errors diff --git a/anymail/exceptions.py b/anymail/exceptions.py index 7e2b349..9cb5111 100644 --- a/anymail/exceptions.py +++ b/anymail/exceptions.py @@ -22,17 +22,18 @@ class AnymailError(Exception): response: requests.Response from the send call esp_name: what to call the ESP (read from backend if provided) """ - self.backend = kwargs.pop('backend', None) - self.email_message = kwargs.pop('email_message', None) - self.payload = kwargs.pop('payload', None) - self.status_code = kwargs.pop('status_code', None) - self.esp_name = kwargs.pop('esp_name', - self.backend.esp_name if self.backend else None) + self.backend = kwargs.pop("backend", None) + self.email_message = kwargs.pop("email_message", None) + self.payload = kwargs.pop("payload", None) + self.status_code = kwargs.pop("status_code", None) + self.esp_name = kwargs.pop( + "esp_name", self.backend.esp_name if self.backend else None + ) if isinstance(self, HTTPError): # must leave response in kwargs for HTTPError - self.response = kwargs.get('response', None) + self.response = kwargs.get("response", None) else: - self.response = kwargs.pop('response', None) + self.response = kwargs.pop("response", None) super().__init__(*args, **kwargs) def __str__(self): @@ -48,15 +49,20 @@ class AnymailError(Exception): if self.status_code is None: return None - # Decode response.reason to text -- borrowed from requests.Response.raise_for_status: + # Decode response.reason to text + # (borrowed from requests.Response.raise_for_status) reason = self.response.reason if isinstance(reason, bytes): try: - reason = reason.decode('utf-8') + reason = reason.decode("utf-8") except UnicodeDecodeError: - reason = reason.decode('iso-8859-1') + reason = reason.decode("iso-8859-1") - description = "%s API response %d (%s)" % (self.esp_name or "ESP", self.status_code, reason) + description = "%s API response %d (%s)" % ( + self.esp_name or "ESP", + self.status_code, + reason, + ) try: json_response = self.response.json() description += ":\n" + json.dumps(json_response, indent=2) @@ -71,7 +77,9 @@ class AnymailError(Exception): """Describe the original exception""" if self.__cause__ is None: return None - return ''.join(format_exception_only(type(self.__cause__), self.__cause__)).strip() + return "".join( + format_exception_only(type(self.__cause__), self.__cause__) + ).strip() class AnymailAPIError(AnymailError): @@ -122,15 +130,20 @@ class AnymailSerializationError(AnymailError, TypeError): in your merge_vars. """ + # inherits from TypeError for compatibility with JSON serialization error def __init__(self, message=None, orig_err=None, *args, **kwargs): if message is None: # self.esp_name not set until super init, so duplicate logic to get esp_name - backend = kwargs.get('backend', None) - esp_name = kwargs.get('esp_name', backend.esp_name if backend else "the ESP") - message = "Don't know how to send this data to %s. " \ - "Try converting it to a string or number first." % esp_name + backend = kwargs.get("backend", None) + esp_name = kwargs.get( + "esp_name", backend.esp_name if backend else "the ESP" + ) + message = ( + "Don't know how to send this data to %s. " + "Try converting it to a string or number first." % esp_name + ) if orig_err is not None: message += "\n%s" % str(orig_err) super().__init__(message, *args, **kwargs) @@ -150,6 +163,7 @@ class AnymailWebhookValidationFailure(AnymailError, SuspiciousOperation): class AnymailConfigurationError(ImproperlyConfigured): """Exception for Anymail configuration or installation issues""" + # This deliberately doesn't inherit from AnymailError, # because we don't want it to be swallowed by backend fail_silently @@ -158,14 +172,17 @@ class AnymailImproperlyInstalled(AnymailConfigurationError, ImportError): """Exception for Anymail missing package dependencies""" def __init__(self, missing_package, backend=""): - message = "The %s package is required to use this ESP, but isn't installed.\n" \ - "(Be sure to use `pip install django-anymail[%s]` " \ - "with your desired ESPs.)" % (missing_package, backend) + message = ( + "The %s package is required to use this ESP, but isn't installed.\n" + "(Be sure to use `pip install django-anymail[%s]` " + "with your desired ESPs.)" % (missing_package, backend) + ) super().__init__(message) # Warnings + class AnymailWarning(Warning): """Base warning for Anymail""" @@ -180,8 +197,10 @@ class AnymailDeprecationWarning(AnymailWarning, DeprecationWarning): # Helpers + class _LazyError: """An object that sits inert unless/until used, then raises an error""" + def __init__(self, error): self._error = error diff --git a/anymail/inbound.py b/anymail/inbound.py index 083aad1..21dbee0 100644 --- a/anymail/inbound.py +++ b/anymail/inbound.py @@ -23,14 +23,15 @@ class AnymailInboundMessage(Message): # Message to send; Message is better designed for representing arbitrary messages: # # * Message is easily parsed from raw mime (which is an inbound format provided - # by many ESPs), and can accurately represent any mime email that might be received + # by many ESPs), and can accurately represent any mime email received # * Message can represent repeated header fields (e.g., "Received") which # are common in inbound messages # * Django's EmailMessage defaults a bunch of properties in ways that aren't helpful # (e.g., from_email from settings) def __init__(self, *args, **kwargs): - # Note: this must accept zero arguments, for use with message_from_string (email.parser) + # Note: this must accept zero arguments, + # for use with message_from_string (email.parser) super().__init__(*args, **kwargs) # Additional attrs provided by some ESPs: @@ -47,48 +48,49 @@ class AnymailInboundMessage(Message): @property def from_email(self): - """EmailAddress """ + """EmailAddress""" # equivalent to Python 3.2+ message['From'].addresses[0] - from_email = self.get_address_header('From') + from_email = self.get_address_header("From") if len(from_email) == 1: return from_email[0] elif len(from_email) == 0: return None else: - return from_email # unusual, but technically-legal multiple-From; preserve list + # unusual, but technically-legal multiple-From; preserve list: + return from_email @property def to(self): """list of EmailAddress objects from To header""" # equivalent to Python 3.2+ message['To'].addresses - return self.get_address_header('To') + return self.get_address_header("To") @property def cc(self): """list of EmailAddress objects from Cc header""" # equivalent to Python 3.2+ message['Cc'].addresses - return self.get_address_header('Cc') + return self.get_address_header("Cc") @property def subject(self): """str value of Subject header, or None""" - return self['Subject'] + return self["Subject"] @property def date(self): """datetime.datetime from Date header, or None if missing/invalid""" # equivalent to Python 3.2+ message['Date'].datetime - return self.get_date_header('Date') + return self.get_date_header("Date") @property def text(self): """Contents of the (first) text/plain body part, or None""" - return self._get_body_content('text/plain') + return self._get_body_content("text/plain") @property def html(self): """Contents of the (first) text/html body part, or None""" - return self._get_body_content('text/html') + return self._get_body_content("text/html") @property def attachments(self): @@ -98,11 +100,17 @@ class AnymailInboundMessage(Message): @property def inline_attachments(self): """dict of Content-ID: attachment (as MIMEPart objects)""" - return {unquote(part['Content-ID']): part for part in self.walk() - if part.is_inline_attachment() and part['Content-ID'] is not None} + return { + unquote(part["Content-ID"]): part + for part in self.walk() + if part.is_inline_attachment() and part["Content-ID"] is not None + } def get_address_header(self, header): - """Return the value of header parsed into a (possibly-empty) list of EmailAddress objects""" + """ + Return the value of header parsed into a (possibly-empty) + list of EmailAddress objects + """ values = self.get_all(header) if values is not None: values = parse_address_list(values) @@ -116,10 +124,11 @@ class AnymailInboundMessage(Message): return value def _get_body_content(self, content_type): - # This doesn't handle as many corner cases as Python 3.6 email.message.EmailMessage.get_body, - # but should work correctly for nearly all real-world inbound messages. - # We're guaranteed to have `is_attachment` available, because all AnymailInboundMessage parts - # should themselves be AnymailInboundMessage. + # This doesn't handle as many corner cases as Python 3.6 + # email.message.EmailMessage.get_body, but should work correctly + # for nearly all real-world inbound messages. + # We're guaranteed to have `is_attachment` available, because all + # AnymailInboundMessage parts should themselves be AnymailInboundMessage. for part in self.walk(): if part.get_content_type() == content_type and not part.is_attachment(): return part.get_content_text() @@ -127,53 +136,59 @@ class AnymailInboundMessage(Message): # Hoisted from email.message.MIMEPart def is_attachment(self): - return self.get_content_disposition() == 'attachment' + return self.get_content_disposition() == "attachment" # New for Anymail def is_inline_attachment(self): - return self.get_content_disposition() == 'inline' + return self.get_content_disposition() == "inline" def get_content_bytes(self): """Return the raw payload bytes""" maintype = self.get_content_maintype() - if maintype == 'message': - # The attachment's payload is a single (parsed) email Message; flatten it to bytes. + if maintype == "message": + # The attachment's payload is a single (parsed) email Message; + # flatten it to bytes. # (Note that self.is_multipart() misleadingly returns True in this case.) payload = self.get_payload() assert len(payload) == 1 # should be exactly one message return payload[0].as_bytes() - elif maintype == 'multipart': + elif maintype == "multipart": # The attachment itself is multipart; the payload is a list of parts, # and it's not clear which one is the "content". - raise ValueError("get_content_bytes() is not valid on multipart messages " - "(perhaps you want as_bytes()?)") + raise ValueError( + "get_content_bytes() is not valid on multipart messages " + "(perhaps you want as_bytes()?)" + ) return self.get_payload(decode=True) def get_content_text(self, charset=None, errors=None): """Return the payload decoded to text""" maintype = self.get_content_maintype() - if maintype == 'message': - # The attachment's payload is a single (parsed) email Message; flatten it to text. + if maintype == "message": + # The attachment's payload is a single (parsed) email Message; + # flatten it to text. # (Note that self.is_multipart() misleadingly returns True in this case.) payload = self.get_payload() assert len(payload) == 1 # should be exactly one message return payload[0].as_string() - elif maintype == 'multipart': + elif maintype == "multipart": # The attachment itself is multipart; the payload is a list of parts, # and it's not clear which one is the "content". - raise ValueError("get_content_text() is not valid on multipart messages " - "(perhaps you want as_string()?)") + raise ValueError( + "get_content_text() is not valid on multipart messages " + "(perhaps you want as_string()?)" + ) else: payload = self.get_payload(decode=True) if payload is None: return payload - charset = charset or self.get_content_charset('US-ASCII') - errors = errors or 'replace' + charset = charset or self.get_content_charset("US-ASCII") + errors = errors or "replace" return payload.decode(charset, errors=errors) def as_uploaded_file(self): """Return the attachment converted to a Django UploadedFile""" - if self['Content-Disposition'] is None: + if self["Content-Disposition"] is None: return None # this part is not an attachment name = self.get_filename() content_type = self.get_content_type() @@ -192,7 +207,7 @@ class AnymailInboundMessage(Message): if isinstance(s, str): # Avoid Python 3.x issue https://bugs.python.org/issue18271 # (See test_inbound: test_parse_raw_mime_8bit_utf8) - return cls.parse_raw_mime_bytes(s.encode('utf-8')) + return cls.parse_raw_mime_bytes(s.encode("utf-8")) return Parser(cls, policy=default_policy).parsestr(s) @classmethod @@ -209,16 +224,28 @@ class AnymailInboundMessage(Message): return Parser(cls, policy=default_policy).parse(fp) @classmethod - def construct(cls, raw_headers=None, from_email=None, to=None, cc=None, subject=None, headers=None, - text=None, text_charset='utf-8', html=None, html_charset='utf-8', - attachments=None): + def construct( + cls, + raw_headers=None, + from_email=None, + to=None, + cc=None, + subject=None, + headers=None, + text=None, + text_charset="utf-8", + html=None, + html_charset="utf-8", + attachments=None, + ): """ Returns a new AnymailInboundMessage constructed from params. - This is designed to handle the sorts of email fields typically present - in ESP parsed inbound messages. (It's not a generalized MIME message constructor.) + This is designed to handle the sorts of email fields typically present in ESP + parsed inbound messages. (It's not a generalized MIME message constructor.) - :param raw_headers: {str|None} base (or complete) message headers as a single string + :param raw_headers: {str|None} + base (or complete) message headers as a single string :param from_email: {str|None} value for From header :param to: {str|None} value for To header :param cc: {str|None} value for Cc header @@ -232,23 +259,26 @@ class AnymailInboundMessage(Message): :return: {AnymailInboundMessage} """ if raw_headers is not None: - msg = Parser(cls, policy=default_policy).parsestr(raw_headers, headersonly=True) - msg.set_payload(None) # headersonly forces an empty string payload, which breaks things later + msg = Parser(cls, policy=default_policy).parsestr( + raw_headers, headersonly=True + ) + # headersonly forces an empty string payload, which breaks things later: + msg.set_payload(None) else: msg = cls() if from_email is not None: - del msg['From'] # override raw_headers value, if any - msg['From'] = from_email + del msg["From"] # override raw_headers value, if any + msg["From"] = from_email if to is not None: - del msg['To'] - msg['To'] = to + del msg["To"] + msg["To"] = to if cc is not None: - del msg['Cc'] - msg['Cc'] = cc + del msg["Cc"] + msg["Cc"] = cc if subject is not None: - del msg['Subject'] - msg['Subject'] = subject + del msg["Subject"] + msg["Subject"] = subject if headers is not None: try: header_items = headers.items() # mapping @@ -257,30 +287,31 @@ class AnymailInboundMessage(Message): for name, value in header_items: msg.add_header(name, value) - # For simplicity, we always build a MIME structure that could support plaintext/html - # alternative bodies, inline attachments for the body(ies), and message attachments. - # This may be overkill for simpler messages, but the structure is never incorrect. - del msg['MIME-Version'] # override raw_headers values, if any - del msg['Content-Type'] - msg['MIME-Version'] = '1.0' - msg['Content-Type'] = 'multipart/mixed' + # For simplicity, always build a MIME structure that could support + # plaintext/html alternative bodies, inline attachments for the body(ies), and + # message attachments. This may be overkill for simpler messages, but the + # structure is never incorrect. + del msg["MIME-Version"] # override raw_headers values, if any + del msg["Content-Type"] + msg["MIME-Version"] = "1.0" + msg["Content-Type"] = "multipart/mixed" related = cls() # container for alternative bodies and inline attachments - related['Content-Type'] = 'multipart/related' + related["Content-Type"] = "multipart/related" msg.attach(related) alternatives = cls() # container for text and html bodies - alternatives['Content-Type'] = 'multipart/alternative' + alternatives["Content-Type"] = "multipart/alternative" related.attach(alternatives) if text is not None: part = cls() - part['Content-Type'] = 'text/plain' + part["Content-Type"] = "text/plain" part.set_payload(text, charset=text_charset) alternatives.attach(part) if html is not None: part = cls() - part['Content-Type'] = 'text/html' + part["Content-Type"] = "text/html" part.set_payload(html, charset=html_charset) alternatives.attach(part) @@ -299,31 +330,41 @@ class AnymailInboundMessage(Message): # some sort of lazy attachment where the content is only pulled in if/when # requested (and then use file.chunks() to minimize memory usage) return cls.construct_attachment( - content_type=getattr(file, 'content_type', None), + content_type=getattr(file, "content_type", None), content=file.read(), - filename=getattr(file, 'name', None), + filename=getattr(file, "name", None), content_id=content_id, - charset=getattr(file, 'charset', None)) + charset=getattr(file, "charset", None), + ) @classmethod - def construct_attachment(cls, content_type, content, - charset=None, filename=None, content_id=None, base64=False): + def construct_attachment( + cls, + content_type, + content, + charset=None, + filename=None, + content_id=None, + base64=False, + ): part = cls() - part['Content-Type'] = content_type - part['Content-Disposition'] = 'inline' if content_id is not None else 'attachment' + part["Content-Type"] = content_type + part["Content-Disposition"] = ( + "inline" if content_id is not None else "attachment" + ) if filename is not None: - part.set_param('name', filename, header='Content-Type') - part.set_param('filename', filename, header='Content-Disposition') + part.set_param("name", filename, header="Content-Type") + part.set_param("filename", filename, header="Content-Disposition") if content_id is not None: - part['Content-ID'] = angle_wrap(content_id) + part["Content-ID"] = angle_wrap(content_id) if base64: content = b64decode(content) payload = content - if part.get_content_maintype() == 'message': + if part.get_content_maintype() == "message": # email.Message parses message/rfc822 parts as a "multipart" (list) payload # whose single item is the recursively-parsed message attachment if isinstance(content, bytes): diff --git a/anymail/message.py b/anymail/message.py index c118964..ec84cbd 100644 --- a/anymail/message.py +++ b/anymail/message.py @@ -19,27 +19,31 @@ class AnymailMessageMixin(EmailMessage): """ def __init__(self, *args, **kwargs): - self.esp_extra = kwargs.pop('esp_extra', UNSET) - self.envelope_sender = kwargs.pop('envelope_sender', UNSET) - self.metadata = kwargs.pop('metadata', UNSET) - self.send_at = kwargs.pop('send_at', UNSET) - self.tags = kwargs.pop('tags', UNSET) - self.track_clicks = kwargs.pop('track_clicks', UNSET) - self.track_opens = kwargs.pop('track_opens', UNSET) - self.template_id = kwargs.pop('template_id', UNSET) - self.merge_data = kwargs.pop('merge_data', UNSET) - self.merge_global_data = kwargs.pop('merge_global_data', UNSET) - self.merge_metadata = kwargs.pop('merge_metadata', UNSET) + self.esp_extra = kwargs.pop("esp_extra", UNSET) + self.envelope_sender = kwargs.pop("envelope_sender", UNSET) + self.metadata = kwargs.pop("metadata", UNSET) + self.send_at = kwargs.pop("send_at", UNSET) + self.tags = kwargs.pop("tags", UNSET) + self.track_clicks = kwargs.pop("track_clicks", UNSET) + self.track_opens = kwargs.pop("track_opens", UNSET) + self.template_id = kwargs.pop("template_id", UNSET) + self.merge_data = kwargs.pop("merge_data", UNSET) + self.merge_global_data = kwargs.pop("merge_global_data", UNSET) + self.merge_metadata = kwargs.pop("merge_metadata", UNSET) self.anymail_status = AnymailStatus() super().__init__(*args, **kwargs) def attach_inline_image_file(self, path, subtype=None, idstring="img", domain=None): - """Add inline image from file path to an EmailMessage, and return its content id""" + """ + Add inline image from file path to an EmailMessage, and return its content id + """ assert isinstance(self, EmailMessage) return attach_inline_image_file(self, path, subtype, idstring, domain) - def attach_inline_image(self, content, filename=None, subtype=None, idstring="img", domain=None): + def attach_inline_image( + self, content, filename=None, subtype=None, idstring="img", domain=None + ): """Add inline image and return its content id""" assert isinstance(self, EmailMessage) return attach_inline_image(self, content, filename, subtype, idstring, domain) @@ -57,27 +61,32 @@ def attach_inline_image_file(message, path, subtype=None, idstring="img", domain return attach_inline_image(message, content, filename, subtype, idstring, domain) -def attach_inline_image(message, content, filename=None, subtype=None, idstring="img", domain=None): +def attach_inline_image( + message, content, filename=None, subtype=None, idstring="img", domain=None +): """Add inline image to an EmailMessage, and return its content id""" if domain is None: # Avoid defaulting to hostname that might end in '.com', because some ESPs # use Content-ID as filename, and Gmail blocks filenames ending in '.com'. - domain = 'inline' # valid domain for a msgid; will never be a real TLD - content_id = make_msgid(idstring, domain) # Content ID per RFC 2045 section 7 (with <...>) + domain = "inline" # valid domain for a msgid; will never be a real TLD + + # Content ID per RFC 2045 section 7 (with <...>): + content_id = make_msgid(idstring, domain) + image = MIMEImage(content, subtype) - image.add_header('Content-Disposition', 'inline', filename=filename) - image.add_header('Content-ID', content_id) + image.add_header("Content-Disposition", "inline", filename=filename) + image.add_header("Content-ID", content_id) message.attach(image) return unquote(content_id) # Without <...>, for use as the tag src ANYMAIL_STATUSES = [ - 'sent', # the ESP has sent the message (though it may or may not get delivered) - 'queued', # the ESP will try to send the message later - 'invalid', # the recipient email was not valid - 'rejected', # the recipient is blacklisted - 'failed', # the attempt to send failed for some other reason - 'unknown', # anything else + "sent", # the ESP has sent the message (though it may or may not get delivered) + "queued", # the ESP will try to send the message later + "invalid", # the recipient email was not valid + "rejected", # the recipient is blacklisted + "failed", # the attempt to send failed for some other reason + "unknown", # anything else ] @@ -98,16 +107,20 @@ class AnymailRecipientStatus: def __repr__(self): return "AnymailRecipientStatus({message_id!r}, {status!r})".format( - message_id=self.message_id, status=self.status) + message_id=self.message_id, status=self.status + ) class AnymailStatus: """Information about an EmailMessage's send status for all recipients""" def __init__(self): - self.message_id = None # set of ESP message ids across all recipients, or bare id if only one, or None - self.status = None # set of ANYMAIL_STATUSES across all recipients, or None for not yet sent to ESP - self.recipients = {} # per-recipient: { email: AnymailRecipientStatus, ... } + #: set of ESP message ids across all recipients, or bare id if only one, or None + self.message_id = None + #: set of ANYMAIL_STATUSES across all recipients, or None if not yet sent to ESP + self.status = None + #: per-recipient: { email: AnymailRecipientStatus, ... } + self.recipients = {} self.esp_response = None def __repr__(self): @@ -118,17 +131,26 @@ class AnymailStatus: return "{%s}" % ", ".join(item_reprs) else: return repr(o) + details = ["status={status}".format(status=_repr(self.status))] if self.message_id: - details.append("message_id={message_id}".format(message_id=_repr(self.message_id))) + details.append( + "message_id={message_id}".format(message_id=_repr(self.message_id)) + ) if self.recipients: - details.append("{num_recipients} recipients".format(num_recipients=len(self.recipients))) + details.append( + "{num_recipients} recipients".format( + num_recipients=len(self.recipients) + ) + ) return "AnymailStatus<{details}>".format(details=", ".join(details)) def set_recipient_status(self, recipients): self.recipients.update(recipients) recipient_statuses = self.recipients.values() - self.message_id = set([recipient.message_id for recipient in recipient_statuses]) + self.message_id = set( + [recipient.message_id for recipient in recipient_statuses] + ) if len(self.message_id) == 1: self.message_id = self.message_id.pop() # de-set-ify if single message_id self.status = set([recipient.status for recipient in recipient_statuses]) diff --git a/anymail/signals.py b/anymail/signals.py index 41b1bed..e886d9a 100644 --- a/anymail/signals.py +++ b/anymail/signals.py @@ -1,31 +1,36 @@ from django.dispatch import Signal - -# Outbound message, before sending -# provides args: message, esp_name +#: Outbound message, before sending +#: provides args: message, esp_name pre_send = Signal() -# Outbound message, after sending -# provides args: message, status, esp_name +#: Outbound message, after sending +#: provides args: message, status, esp_name post_send = Signal() -# Delivery and tracking events for sent messages -# provides args: event, esp_name +#: Delivery and tracking events for sent messages +#: provides args: event, esp_name tracking = Signal() -# Event for receiving inbound messages -# provides args: event, esp_name +#: Event for receiving inbound messages +#: provides args: event, esp_name inbound = Signal() class AnymailEvent: """Base class for normalized Anymail webhook events""" - def __init__(self, event_type, timestamp=None, event_id=None, esp_event=None, **kwargs): - self.event_type = event_type # normalized to an EventType str - self.timestamp = timestamp # normalized to an aware datetime - self.event_id = event_id # opaque str - self.esp_event = esp_event # raw event fields (e.g., parsed JSON dict or POST data QueryDict) + def __init__( + self, event_type, timestamp=None, event_id=None, esp_event=None, **kwargs + ): + #: normalized to an EventType str + self.event_type = event_type + #: normalized to an aware datetime + self.timestamp = timestamp + #: opaque str + self.event_id = event_id + #: raw event fields (e.g., parsed JSON dict or POST data QueryDict) + self.esp_event = esp_event class AnymailTrackingEvent(AnymailEvent): @@ -33,15 +38,19 @@ class AnymailTrackingEvent(AnymailEvent): def __init__(self, **kwargs): super().__init__(**kwargs) - self.click_url = kwargs.pop('click_url', None) # str - self.description = kwargs.pop('description', None) # str, usually human-readable, not normalized - self.message_id = kwargs.pop('message_id', None) # str, format may vary - self.metadata = kwargs.pop('metadata', {}) # dict - self.mta_response = kwargs.pop('mta_response', None) # str, may include SMTP codes, not normalized - self.recipient = kwargs.pop('recipient', None) # str email address (just the email portion; no name) - self.reject_reason = kwargs.pop('reject_reason', None) # normalized to a RejectReason str - self.tags = kwargs.pop('tags', []) # list of str - self.user_agent = kwargs.pop('user_agent', None) # str + self.click_url = kwargs.pop("click_url", None) #: str + #: str, usually human-readable, not normalized + self.description = kwargs.pop("description", None) + self.message_id = kwargs.pop("message_id", None) #: str, format may vary + self.metadata = kwargs.pop("metadata", {}) #: dict + #: str, may include SMTP codes, not normalized + self.mta_response = kwargs.pop("mta_response", None) + #: str email address (just the email portion; no name) + self.recipient = kwargs.pop("recipient", None) + #: normalized to a RejectReason str + self.reject_reason = kwargs.pop("reject_reason", None) + self.tags = kwargs.pop("tags", []) #: list of str + self.user_agent = kwargs.pop("user_agent", None) #: str class AnymailInboundEvent(AnymailEvent): @@ -49,45 +58,92 @@ class AnymailInboundEvent(AnymailEvent): def __init__(self, **kwargs): super().__init__(**kwargs) - self.message = kwargs.pop('message', None) # anymail.inbound.AnymailInboundMessage + #: anymail.inbound.AnymailInboundMessage + self.message = kwargs.pop("message", None) class EventType: """Constants for normalized Anymail event types""" - # Delivery (and non-delivery) event types: + # Delivery (and non-delivery) event types # (these match message.ANYMAIL_STATUSES where appropriate) - QUEUED = 'queued' # the ESP has accepted the message and will try to send it (possibly later) - SENT = 'sent' # the ESP has sent the message (though it may or may not get delivered) - REJECTED = 'rejected' # the ESP refused to send the messsage (e.g., suppression list, policy, invalid email) - FAILED = 'failed' # the ESP was unable to send the message (e.g., template rendering error) - BOUNCED = 'bounced' # rejected or blocked by receiving MTA - DEFERRED = 'deferred' # delayed by receiving MTA; should be followed by a later BOUNCED or DELIVERED - DELIVERED = 'delivered' # accepted by receiving MTA - AUTORESPONDED = 'autoresponded' # a bot replied + #: the ESP has accepted the message and will try to send it (possibly later) + QUEUED = "queued" - # Tracking event types: - OPENED = 'opened' # open tracking - CLICKED = 'clicked' # click tracking - COMPLAINED = 'complained' # recipient reported as spam (e.g., through feedback loop) - UNSUBSCRIBED = 'unsubscribed' # recipient attempted to unsubscribe - SUBSCRIBED = 'subscribed' # signed up for mailing list through ESP-hosted form + #: the ESP has sent the message (though it may or may not get delivered) + SENT = "sent" - # Inbound event types: - INBOUND = 'inbound' # received message - INBOUND_FAILED = 'inbound_failed' + #: the ESP refused to send the message + #: (e.g., suppression list, policy, invalid email) + REJECTED = "rejected" - # Other: - UNKNOWN = 'unknown' # anything else + #: the ESP was unable to send the message (e.g., template rendering error) + FAILED = "failed" + + #: rejected or blocked by receiving MTA + BOUNCED = "bounced" + + #: delayed by receiving MTA; should be followed by a later BOUNCED or DELIVERED + DEFERRED = "deferred" + + #: accepted by receiving MTA + DELIVERED = "delivered" + + #: a bot replied + AUTORESPONDED = "autoresponded" + + # Tracking event types + + #: open tracking + OPENED = "opened" + + #: click tracking + CLICKED = "clicked" + + #: recipient reported as spam (e.g., through feedback loop) + COMPLAINED = "complained" + + #: recipient attempted to unsubscribe + UNSUBSCRIBED = "unsubscribed" + + #: signed up for mailing list through ESP-hosted form + SUBSCRIBED = "subscribed" + + # Inbound event types + + #: received message + INBOUND = "inbound" + + #: (ESP notification of) error receiving message + INBOUND_FAILED = "inbound_failed" + + # Other event types + + #: all other ESP events + UNKNOWN = "unknown" class RejectReason: """Constants for normalized Anymail reject/drop reasons""" - INVALID = 'invalid' # bad address format - BOUNCED = 'bounced' # (previous) bounce from recipient - TIMED_OUT = 'timed_out' # (previous) repeated failed delivery attempts - BLOCKED = 'blocked' # ESP policy suppression - SPAM = 'spam' # (previous) spam complaint from recipient - UNSUBSCRIBED = 'unsubscribed' # (previous) unsubscribe request from recipient - OTHER = 'other' + + #: bad address format + INVALID = "invalid" + + #: (previous) bounce from recipient + BOUNCED = "bounced" + + #: (previous) repeated failed delivery attempts + TIMED_OUT = "timed_out" + + #: ESP policy suppression + BLOCKED = "blocked" + + #: (previous) spam complaint from recipient + SPAM = "spam" + + #: (previous) unsubscribe request from recipient + UNSUBSCRIBED = "unsubscribed" + + #: all other ESP reject reasons + OTHER = "other" diff --git a/anymail/urls.py b/anymail/urls.py index 3f8885a..d4f19e1 100644 --- a/anymail/urls.py +++ b/anymail/urls.py @@ -1,6 +1,9 @@ from django.urls import re_path -from .webhooks.amazon_ses import AmazonSESInboundWebhookView, AmazonSESTrackingWebhookView +from .webhooks.amazon_ses import ( + AmazonSESInboundWebhookView, + AmazonSESTrackingWebhookView, +) from .webhooks.mailgun import MailgunInboundWebhookView, MailgunTrackingWebhookView from .webhooks.mailjet import MailjetInboundWebhookView, MailjetTrackingWebhookView from .webhooks.mandrill import MandrillCombinedWebhookView @@ -8,30 +11,97 @@ from .webhooks.postal import PostalInboundWebhookView, PostalTrackingWebhookView from .webhooks.postmark import PostmarkInboundWebhookView, PostmarkTrackingWebhookView from .webhooks.sendgrid import SendGridInboundWebhookView, SendGridTrackingWebhookView from .webhooks.sendinblue import SendinBlueTrackingWebhookView -from .webhooks.sparkpost import SparkPostInboundWebhookView, SparkPostTrackingWebhookView +from .webhooks.sparkpost import ( + SparkPostInboundWebhookView, + SparkPostTrackingWebhookView, +) - -app_name = 'anymail' +app_name = "anymail" urlpatterns = [ - re_path(r'^amazon_ses/inbound/$', AmazonSESInboundWebhookView.as_view(), name='amazon_ses_inbound_webhook'), - re_path(r'^mailgun/inbound(_mime)?/$', MailgunInboundWebhookView.as_view(), name='mailgun_inbound_webhook'), - re_path(r'^mailjet/inbound/$', MailjetInboundWebhookView.as_view(), name='mailjet_inbound_webhook'), - re_path(r'^postal/inbound/$', PostalInboundWebhookView.as_view(), name='postal_inbound_webhook'), - re_path(r'^postmark/inbound/$', PostmarkInboundWebhookView.as_view(), name='postmark_inbound_webhook'), - re_path(r'^sendgrid/inbound/$', SendGridInboundWebhookView.as_view(), name='sendgrid_inbound_webhook'), - re_path(r'^sparkpost/inbound/$', SparkPostInboundWebhookView.as_view(), name='sparkpost_inbound_webhook'), - - re_path(r'^amazon_ses/tracking/$', AmazonSESTrackingWebhookView.as_view(), name='amazon_ses_tracking_webhook'), - re_path(r'^mailgun/tracking/$', MailgunTrackingWebhookView.as_view(), name='mailgun_tracking_webhook'), - re_path(r'^mailjet/tracking/$', MailjetTrackingWebhookView.as_view(), name='mailjet_tracking_webhook'), - re_path(r'^postal/tracking/$', PostalTrackingWebhookView.as_view(), name='postal_tracking_webhook'), - re_path(r'^postmark/tracking/$', PostmarkTrackingWebhookView.as_view(), name='postmark_tracking_webhook'), - re_path(r'^sendgrid/tracking/$', SendGridTrackingWebhookView.as_view(), name='sendgrid_tracking_webhook'), - re_path(r'^sendinblue/tracking/$', SendinBlueTrackingWebhookView.as_view(), name='sendinblue_tracking_webhook'), - re_path(r'^sparkpost/tracking/$', SparkPostTrackingWebhookView.as_view(), name='sparkpost_tracking_webhook'), - - # Anymail uses a combined Mandrill webhook endpoint, to simplify Mandrill's key-validation scheme: - re_path(r'^mandrill/$', MandrillCombinedWebhookView.as_view(), name='mandrill_webhook'), + re_path( + r"^amazon_ses/inbound/$", + AmazonSESInboundWebhookView.as_view(), + name="amazon_ses_inbound_webhook", + ), + re_path( + r"^mailgun/inbound(_mime)?/$", + MailgunInboundWebhookView.as_view(), + name="mailgun_inbound_webhook", + ), + re_path( + r"^mailjet/inbound/$", + MailjetInboundWebhookView.as_view(), + name="mailjet_inbound_webhook", + ), + re_path( + r"^postal/inbound/$", + PostalInboundWebhookView.as_view(), + name="postal_inbound_webhook", + ), + re_path( + r"^postmark/inbound/$", + PostmarkInboundWebhookView.as_view(), + name="postmark_inbound_webhook", + ), + re_path( + r"^sendgrid/inbound/$", + SendGridInboundWebhookView.as_view(), + name="sendgrid_inbound_webhook", + ), + re_path( + r"^sparkpost/inbound/$", + SparkPostInboundWebhookView.as_view(), + name="sparkpost_inbound_webhook", + ), + re_path( + r"^amazon_ses/tracking/$", + AmazonSESTrackingWebhookView.as_view(), + name="amazon_ses_tracking_webhook", + ), + re_path( + r"^mailgun/tracking/$", + MailgunTrackingWebhookView.as_view(), + name="mailgun_tracking_webhook", + ), + re_path( + r"^mailjet/tracking/$", + MailjetTrackingWebhookView.as_view(), + name="mailjet_tracking_webhook", + ), + re_path( + r"^postal/tracking/$", + PostalTrackingWebhookView.as_view(), + name="postal_tracking_webhook", + ), + re_path( + r"^postmark/tracking/$", + PostmarkTrackingWebhookView.as_view(), + name="postmark_tracking_webhook", + ), + re_path( + r"^sendgrid/tracking/$", + SendGridTrackingWebhookView.as_view(), + name="sendgrid_tracking_webhook", + ), + re_path( + r"^sendinblue/tracking/$", + SendinBlueTrackingWebhookView.as_view(), + name="sendinblue_tracking_webhook", + ), + re_path( + r"^sparkpost/tracking/$", + SparkPostTrackingWebhookView.as_view(), + name="sparkpost_tracking_webhook", + ), + # Anymail uses a combined Mandrill webhook endpoint, + # to simplify Mandrill's key-validation scheme: + re_path( + r"^mandrill/$", MandrillCombinedWebhookView.as_view(), name="mandrill_webhook" + ), # This url is maintained for backwards compatibility with earlier Anymail releases: - re_path(r'^mandrill/tracking/$', MandrillCombinedWebhookView.as_view(), name='mandrill_tracking_webhook'), + re_path( + r"^mandrill/tracking/$", + MandrillCombinedWebhookView.as_view(), + name="mandrill_tracking_webhook", + ), ] diff --git a/anymail/utils.py b/anymail/utils.py index ebe50e5..464152b 100644 --- a/anymail/utils.py +++ b/anymail/utils.py @@ -17,7 +17,7 @@ from .exceptions import AnymailConfigurationError, AnymailInvalidAddress BASIC_NUMERIC_TYPES = (int, float) -UNSET = type('UNSET', (object,), {}) # Used as non-None default value +UNSET = type("UNSET", (object,), {}) # Used as non-None default value def combine(*args): @@ -93,7 +93,7 @@ def getfirst(dct, keys, default=UNSET): except KeyError: pass if default is UNSET: - raise KeyError("None of %s found in dict" % ', '.join(keys)) + raise KeyError("None of %s found in dict" % ", ".join(keys)) else: return default @@ -105,7 +105,11 @@ def update_deep(dct, other): and other can be any Mapping """ for key, value in other.items(): - if key in dct and isinstance(dct[key], MutableMapping) and isinstance(value, Mapping): + if ( + key in dct + and isinstance(dct[key], MutableMapping) + and isinstance(value, Mapping) + ): update_deep(dct[key], value) else: dct[key] = value @@ -138,18 +142,25 @@ def parse_address_list(address_list, field=None): # from the list -- which may split comma-seperated strings into multiple addresses. # (See django.core.mail.message: EmailMessage.message to/cc/bcc/reply_to handling; # also logic for ADDRESS_HEADERS in forbid_multi_line_headers.) - address_list_strings = [force_str(address) for address in address_list] # resolve lazy strings + + # resolve lazy strings: + address_list_strings = [force_str(address) for address in address_list] name_email_pairs = getaddresses(address_list_strings) if name_email_pairs == [] and address_list_strings == [""]: - name_email_pairs = [('', '')] # getaddresses ignores a single empty string - parsed = [EmailAddress(display_name=name, addr_spec=email) - for (name, email) in name_email_pairs] + name_email_pairs = [("", "")] # getaddresses ignores a single empty string + parsed = [ + EmailAddress(display_name=name, addr_spec=email) + for (name, email) in name_email_pairs + ] # Sanity-check, and raise useful errors for address in parsed: - if address.username == '' or address.domain == '': - # Django SMTP allows username-only emails, but they're not meaningful with an ESP - errmsg = "Invalid email address '{problem}' parsed from '{source}'{where}.".format( + if address.username == "" or address.domain == "": + # Django SMTP allows username-only emails, + # but they're not meaningful with an ESP + errmsg = ( + "Invalid email address '{problem}'" " parsed from '{source}'{where}." + ).format( problem=address.addr_spec, source=", ".join(address_list_strings), where=" in `%s`" % field if field else "", @@ -165,7 +176,8 @@ def parse_single_address(address, field=None): """Parses a single EmailAddress from str address, or raises AnymailInvalidAddress :param str address: the fully-formatted email str to parse - :param str|None field: optional description of the source of this address, for error message + :param str|None field: + optional description of the source of this address, for error message :return :class:`EmailAddress`: if address contains a single email :raises :exc:`AnymailInvalidAddress`: if address contains no or multiple emails """ @@ -173,8 +185,11 @@ def parse_single_address(address, field=None): count = len(parsed) if count > 1: raise AnymailInvalidAddress( - "Only one email address is allowed; found {count} in '{address}'{where}.".format( - count=count, address=address, where=" in `%s`" % field if field else "")) + "Only one email address is allowed;" + " found {count} in '{address}'{where}.".format( + count=count, address=address, where=" in `%s`" % field if field else "" + ) + ) else: return parsed[0] @@ -205,7 +220,7 @@ class EmailAddress: (also available as `str(EmailAddress)`) """ - def __init__(self, display_name='', addr_spec=None): + def __init__(self, display_name="", addr_spec=None): self._address = None # lazy formatted address if addr_spec is None: try: @@ -215,10 +230,10 @@ class EmailAddress: # ESPs should clean or reject addresses containing newlines, but some # extra protection can't hurt (and it seems to be a common oversight) - if '\n' in display_name or '\r' in display_name: - raise ValueError('EmailAddress display_name cannot contain newlines') - if '\n' in addr_spec or '\r' in addr_spec: - raise ValueError('EmailAddress addr_spec cannot contain newlines') + if "\n" in display_name or "\r" in display_name: + raise ValueError("EmailAddress display_name cannot contain newlines") + if "\n" in addr_spec or "\r" in addr_spec: + raise ValueError("EmailAddress addr_spec cannot contain newlines") self.display_name = display_name self.addr_spec = addr_spec @@ -227,11 +242,12 @@ class EmailAddress: # do we need to unquote username? except ValueError: self.username = addr_spec - self.domain = '' + self.domain = "" def __repr__(self): return "EmailAddress({display_name!r}, {addr_spec!r})".format( - display_name=self.display_name, addr_spec=self.addr_spec) + display_name=self.display_name, addr_spec=self.addr_spec + ) @property def address(self): @@ -278,7 +294,7 @@ class Attachment: # Note that an attachment can be either a tuple of (filename, content, mimetype) # or a MIMEBase object. (Also, both filename and mimetype may be missing.) self._attachment = attachment - self.encoding = encoding # should we be checking attachment["Content-Encoding"] ??? + self.encoding = encoding # or check attachment["Content-Encoding"] ??? self.inline = False self.content_id = None self.cid = "" @@ -289,12 +305,15 @@ class Attachment: if self.content is None: self.content = attachment.as_bytes() self.mimetype = attachment.get_content_type() - self.content_type = attachment["Content-Type"] # includes charset if provided + # Content-Type includes charset if provided + self.content_type = attachment["Content-Type"] content_disposition = attachment.get_content_disposition() - if content_disposition == 'inline' or (not content_disposition and 'Content-ID' in attachment): + if content_disposition == "inline" or ( + not content_disposition and "Content-ID" in attachment + ): self.inline = True - self.content_id = attachment["Content-ID"] # probably including the <...> + self.content_id = attachment["Content-ID"] # probably including <...> if self.content_id is not None: self.cid = unquote(self.content_id) # without the <, > else: @@ -322,7 +341,9 @@ class Attachment: details.append("name={name!r}".format(name=self.name)) if self.inline: details.insert(0, "inline") - details.append("content_id={content_id!r}".format(content_id=self.content_id)) + details.append( + "content_id={content_id!r}".format(content_id=self.content_id) + ) return "Attachment<{details}>".format(details=", ".join(details)) @property @@ -334,7 +355,9 @@ class Attachment: return b64encode(content).decode("ascii") -def get_anymail_setting(name, default=UNSET, esp_name=None, kwargs=None, allow_bare=False): +def get_anymail_setting( + name, default=UNSET, esp_name=None, kwargs=None, allow_bare=False +): """Returns an Anymail option from kwargs or Django settings. Returns first of: @@ -352,7 +375,7 @@ def get_anymail_setting(name, default=UNSET, esp_name=None, kwargs=None, allow_b try: value = kwargs.pop(name) - if name in ['username', 'password']: + if name in ["username", "password"]: # Work around a problem in django.core.mail.send_mail, which calls # get_connection(... username=None, password=None) by default. # We need to ignore those None defaults (else settings like @@ -382,7 +405,10 @@ def get_anymail_setting(name, default=UNSET, esp_name=None, kwargs=None, allow_b except AttributeError: pass if default is UNSET: - message = "You must set %s or ANYMAIL = {'%s': ...}" % (anymail_setting, setting) + message = "You must set %s or ANYMAIL = {'%s': ...}" % ( + anymail_setting, + setting, + ) if allow_bare: message += " or %s" % setting message += " in your Django settings" @@ -413,7 +439,9 @@ def collect_all_methods(cls, method_name): def querydict_getfirst(qdict, field, default=UNSET): - """Like :func:`django.http.QueryDict.get`, but returns *first* value of multi-valued field. + """ + Like :func:`django.http.QueryDict.get`, + but returns *first* value of multi-valued field. >>> from django.http import QueryDict >>> q = QueryDict('a=1&a=2&a=3') @@ -429,8 +457,9 @@ def querydict_getfirst(qdict, field, default=UNSET): >>> q.getfirst('a') '1' """ - # (Why not instead define a QueryDict subclass with this method? Because there's no simple way - # to efficiently initialize a QueryDict subclass with the contents of an existing instance.) + # (Why not instead define a QueryDict subclass with this method? Because there's + # no simple way to efficiently initialize a QueryDict subclass with the contents + # of an existing instance.) values = qdict.getlist(field) if len(values) > 0: return values[0] @@ -453,10 +482,10 @@ def angle_wrap(s): # This is the inverse behavior of email.utils.unquote # (which you might think email.utils.quote would do, but it doesn't) if len(s) > 0: - if s[0] != '<': - s = '<' + s - if s[-1] != '>': - s = s + '>' + if s[0] != "<": + s = "<" + s + if s[-1] != ">": + s = s + ">" return s @@ -468,7 +497,9 @@ def is_lazy(obj): def force_non_lazy(obj): - """If obj is a Django lazy object, return it coerced to text; otherwise return it unchanged. + """ + If obj is a Django lazy object, return it coerced to text; + otherwise return it unchanged. (Similar to django.utils.encoding.force_text, but doesn't alter non-text objects.) """ @@ -500,9 +531,9 @@ def get_request_basic_auth(request): If request includes basic auth, result is string 'username:password'. """ try: - authtype, authdata = request.META['HTTP_AUTHORIZATION'].split() + authtype, authdata = request.META["HTTP_AUTHORIZATION"].split() if authtype.lower() == "basic": - return base64.b64decode(authdata).decode('utf-8') + return base64.b64decode(authdata).decode("utf-8") except (IndexError, KeyError, TypeError, ValueError): pass return None @@ -519,8 +550,15 @@ def get_request_uri(request): if basic_auth is not None: # must reassemble url with auth parts = urlsplit(url) - url = urlunsplit((parts.scheme, basic_auth + '@' + parts.netloc, - parts.path, parts.query, parts.fragment)) + url = urlunsplit( + ( + parts.scheme, + basic_auth + "@" + parts.netloc, + parts.path, + parts.query, + parts.fragment, + ) + ) return url @@ -558,6 +596,7 @@ class CaseInsensitiveCasePreservingDict(CaseInsensitiveDict): >>> cid.keys() ["accEPT"] """ + def __setitem__(self, key, value): _k = key.lower() try: diff --git a/anymail/webhooks/amazon_ses.py b/anymail/webhooks/amazon_ses.py index 6bd4300..699c51f 100644 --- a/anymail/webhooks/amazon_ses.py +++ b/anymail/webhooks/amazon_ses.py @@ -5,24 +5,40 @@ from base64 import b64decode from django.http import HttpResponse from django.utils.dateparse import parse_datetime -from .base import AnymailBaseWebhookView from ..exceptions import ( - AnymailAPIError, AnymailConfigurationError, AnymailImproperlyInstalled, AnymailWebhookValidationFailure, - _LazyError) + AnymailAPIError, + AnymailConfigurationError, + AnymailImproperlyInstalled, + AnymailWebhookValidationFailure, + _LazyError, +) from ..inbound import AnymailInboundMessage -from ..signals import AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason, inbound, tracking +from ..signals import ( + AnymailInboundEvent, + AnymailTrackingEvent, + EventType, + RejectReason, + inbound, + tracking, +) from ..utils import get_anymail_setting, getfirst +from .base import AnymailBaseWebhookView try: import boto3 from botocore.exceptions import ClientError + from ..backends.amazon_ses import _get_anymail_boto3_params except ImportError: # This module gets imported by anymail.urls, so don't complain about boto3 missing # unless one of the Amazon SES webhook views is actually used and needs it - boto3 = _LazyError(AnymailImproperlyInstalled(missing_package='boto3', backend='amazon_ses')) + boto3 = _LazyError( + AnymailImproperlyInstalled(missing_package="boto3", backend="amazon_ses") + ) ClientError = object - _get_anymail_boto3_params = _LazyError(AnymailImproperlyInstalled(missing_package='boto3', backend='amazon_ses')) + _get_anymail_boto3_params = _LazyError( + AnymailImproperlyInstalled(missing_package="boto3", backend="amazon_ses") + ) class AmazonSESBaseWebhookView(AnymailBaseWebhookView): @@ -31,23 +47,32 @@ class AmazonSESBaseWebhookView(AnymailBaseWebhookView): esp_name = "Amazon SES" def __init__(self, **kwargs): - # whether to automatically respond to SNS SubscriptionConfirmation requests; default True - # (Future: could also take a TopicArn or list to auto-confirm) + # whether to automatically respond to SNS SubscriptionConfirmation requests; + # default True. (Future: could also take a TopicArn or list to auto-confirm) self.auto_confirm_enabled = get_anymail_setting( - "auto_confirm_sns_subscriptions", esp_name=self.esp_name, kwargs=kwargs, default=True) - # boto3 params for connecting to S3 (inbound downloads) and SNS (auto-confirm subscriptions): - self.session_params, self.client_params = _get_anymail_boto3_params(kwargs=kwargs) + "auto_confirm_sns_subscriptions", + esp_name=self.esp_name, + kwargs=kwargs, + default=True, + ) + # boto3 params for connecting to S3 (inbound downloads) + # and SNS (auto-confirm subscriptions): + self.session_params, self.client_params = _get_anymail_boto3_params( + kwargs=kwargs + ) super().__init__(**kwargs) @staticmethod def _parse_sns_message(request): # cache so we don't have to parse the json multiple times - if not hasattr(request, '_sns_message'): + if not hasattr(request, "_sns_message"): try: - body = request.body.decode(request.encoding or 'utf-8') + body = request.body.decode(request.encoding or "utf-8") request._sns_message = json.loads(body) except (TypeError, ValueError, UnicodeDecodeError) as err: - raise AnymailAPIError("Malformed SNS message body %r" % request.body) from err + raise AnymailAPIError( + "Malformed SNS message body %r" % request.body + ) from err return request._sns_message def validate_request(self, request): @@ -57,18 +82,24 @@ class AmazonSESBaseWebhookView(AnymailBaseWebhookView): body_type = sns_message.get("Type", "<>") if header_type != body_type: raise AnymailWebhookValidationFailure( - 'SNS header "x-amz-sns-message-type: %s" doesn\'t match body "Type": "%s"' - % (header_type, body_type)) + 'SNS header "x-amz-sns-message-type: %s"' + ' doesn\'t match body "Type": "%s"' % (header_type, body_type) + ) - if header_type not in ["Notification", "SubscriptionConfirmation", "UnsubscribeConfirmation"]: + if header_type not in [ + "Notification", + "SubscriptionConfirmation", + "UnsubscribeConfirmation", + ]: raise AnymailAPIError("Unknown SNS message type '%s'" % header_type) header_id = request.META.get("HTTP_X_AMZ_SNS_MESSAGE_ID", "<>") body_id = sns_message.get("MessageId", "<>") if header_id != body_id: raise AnymailWebhookValidationFailure( - 'SNS header "x-amz-sns-message-id: %s" doesn\'t match body "MessageId": "%s"' - % (header_id, body_id)) + 'SNS header "x-amz-sns-message-id: %s"' + ' doesn\'t match body "MessageId": "%s"' % (header_id, body_id) + ) # Future: Verify SNS message signature # https://docs.aws.amazon.com/sns/latest/dg/SendMessageToHttp.verify.signature.html @@ -76,7 +107,8 @@ class AmazonSESBaseWebhookView(AnymailBaseWebhookView): def post(self, request, *args, **kwargs): # request has *not* yet been validated at this point if self.basic_auth and not request.META.get("HTTP_AUTHORIZATION"): - # Amazon SNS requires a proper 401 response before it will attempt to send basic auth + # Amazon SNS requires a proper 401 response + # before it will attempt to send basic auth response = HttpResponse(status=401) response["WWW-Authenticate"] = 'Basic realm="Anymail WEBHOOK_SECRET"' return response @@ -92,10 +124,16 @@ class AmazonSESBaseWebhookView(AnymailBaseWebhookView): try: ses_event = json.loads(message_string) except (TypeError, ValueError) as err: - if message_string == "Successfully validated SNS topic for Amazon SES event publishing.": - pass # this Notification is generated after SubscriptionConfirmation + if ( + "Successfully validated SNS topic for Amazon SES event publishing." + == message_string + ): + # this Notification is generated after SubscriptionConfirmation + pass else: - raise AnymailAPIError("Unparsable SNS Message %r" % message_string) from err + raise AnymailAPIError( + "Unparsable SNS Message %r" % message_string + ) from err else: events = self.esp_to_anymail_events(ses_event, sns_message) elif sns_type == "SubscriptionConfirmation": @@ -107,43 +145,63 @@ class AmazonSESBaseWebhookView(AnymailBaseWebhookView): raise NotImplementedError() def auto_confirm_sns_subscription(self, sns_message): - """Automatically accept a subscription to Amazon SNS topics, if the request is expected. + """ + Automatically accept a subscription to Amazon SNS topics, + if the request is expected. - If an SNS SubscriptionConfirmation arrives with HTTP basic auth proving it is meant for us, - automatically load the SubscribeURL to confirm the subscription. + If an SNS SubscriptionConfirmation arrives with HTTP basic auth proving it is + meant for us, automatically load the SubscribeURL to confirm the subscription. """ if not self.auto_confirm_enabled: return if not self.basic_auth: - # Note: basic_auth (shared secret) confirms the notification was meant for us. - # If WEBHOOK_SECRET isn't set, Anymail logs a warning but allows the request. - # (Also, verifying the SNS message signature would be insufficient here: - # if someone else tried to point their own SNS topic at our webhook url, - # SNS would send a SubscriptionConfirmation with a valid Amazon signature.) + # basic_auth (shared secret) confirms the notification was meant for us. + # If WEBHOOK_SECRET isn't set, Anymail logs a warning but allows the + # request. (Also, verifying the SNS message signature would be insufficient + # here: if someone else tried to point their own SNS topic at our webhook + # url, SNS would send a SubscriptionConfirmation with a valid Amazon + # signature.) raise AnymailWebhookValidationFailure( - "Anymail received an unexpected SubscriptionConfirmation request for Amazon SNS topic " - "'{topic_arn!s}'. (Anymail can automatically confirm SNS subscriptions if you set a " - "WEBHOOK_SECRET and use that in your SNS notification url. Or you can manually confirm " - "this subscription in the SNS dashboard with token '{token!s}'.)" - "".format(topic_arn=sns_message.get('TopicArn'), token=sns_message.get('Token'))) + "Anymail received an unexpected SubscriptionConfirmation request for " + "Amazon SNS topic '{topic_arn!s}'. (Anymail can automatically confirm " + "SNS subscriptions if you set a WEBHOOK_SECRET and use that in your " + "SNS notification url. Or you can manually confirm this subscription " + "in the SNS dashboard with token '{token!s}'.)".format( + topic_arn=sns_message.get("TopicArn"), + token=sns_message.get("Token"), + ) + ) - # WEBHOOK_SECRET *is* set, so the request's basic auth has been verified by now (in run_validators). - # We're good to confirm... + # WEBHOOK_SECRET *is* set, so the request's basic auth has been verified by now + # (in run_validators). We're good to confirm... topic_arn = sns_message["TopicArn"] token = sns_message["Token"] - # Must confirm in TopicArn's own region (which may be different from the default) + # Must confirm in TopicArn's own region + # (which may be different from the default) try: - (_arn_tag, _partition, _service, region, _account, _resource) = topic_arn.split(":", maxsplit=6) + ( + _arn_tag, + _partition, + _service, + region, + _account, + _resource, + ) = topic_arn.split(":", maxsplit=6) except (TypeError, ValueError): - raise ValueError("Invalid ARN format '{topic_arn!s}'".format(topic_arn=topic_arn)) + raise ValueError( + "Invalid ARN format '{topic_arn!s}'".format(topic_arn=topic_arn) + ) client_params = self.client_params.copy() client_params["region_name"] = region - sns_client = boto3.session.Session(**self.session_params).client('sns', **client_params) + sns_client = boto3.session.Session(**self.session_params).client( + "sns", **client_params + ) sns_client.confirm_subscription( - TopicArn=topic_arn, Token=token, AuthenticateOnUnsubscribe='true') + TopicArn=topic_arn, Token=token, AuthenticateOnUnsubscribe="true" + ) class AmazonSESTrackingWebhookView(AmazonSESBaseWebhookView): @@ -153,16 +211,19 @@ class AmazonSESTrackingWebhookView(AmazonSESBaseWebhookView): def esp_to_anymail_events(self, ses_event, sns_message): # Amazon SES has two notification formats, which are almost exactly the same: - # - https://docs.aws.amazon.com/ses/latest/DeveloperGuide/event-publishing-retrieving-sns-contents.html - # - https://docs.aws.amazon.com/ses/latest/DeveloperGuide/notification-contents.html + # https://docs.aws.amazon.com/ses/latest/DeveloperGuide/event-publishing-retrieving-sns-contents.html + # https://docs.aws.amazon.com/ses/latest/DeveloperGuide/notification-contents.html # This code should handle either. - ses_event_type = getfirst(ses_event, ["eventType", "notificationType"], "<>") + ses_event_type = getfirst( + ses_event, ["eventType", "notificationType"], "<>" + ) if ses_event_type == "Received": # This is an inbound event raise AnymailConfigurationError( "You seem to have set an Amazon SES *inbound* receipt rule to publish " "to an SNS Topic that posts to Anymail's *tracking* webhook URL. " - "(SNS TopicArn %s)" % sns_message.get("TopicArn")) + "(SNS TopicArn %s)" % sns_message.get("TopicArn") + ) event_id = sns_message.get("MessageId") # unique to the SNS notification try: @@ -171,7 +232,8 @@ class AmazonSESTrackingWebhookView(AmazonSESBaseWebhookView): timestamp = None mail_object = ses_event.get("mail", {}) - message_id = mail_object.get("messageId") # same as MessageId in SendRawEmail response + # same as MessageId in SendRawEmail response: + message_id = mail_object.get("messageId") all_recipients = mail_object.get("destination", []) # Recover tags and metadata from custom headers @@ -187,7 +249,8 @@ class AmazonSESTrackingWebhookView(AmazonSESBaseWebhookView): except (ValueError, TypeError, KeyError): pass - common_props = dict( # AnymailTrackingEvent props for all recipients + # AnymailTrackingEvent props for all recipients: + common_props = dict( esp_event=ses_event, event_id=event_id, message_id=message_id, @@ -195,12 +258,13 @@ class AmazonSESTrackingWebhookView(AmazonSESBaseWebhookView): tags=tags, timestamp=timestamp, ) - per_recipient_props = [ # generate individual events for each of these - dict(recipient=email_address) - for email_address in all_recipients + # generate individual events for each of these: + per_recipient_props = [ + dict(recipient=email_address) for email_address in all_recipients ] - event_object = ses_event.get(ses_event_type.lower(), {}) # e.g., ses_event["bounce"] + # event-type-specific data (e.g., ses_event["bounce"]): + event_object = ses_event.get(ses_event_type.lower(), {}) if ses_event_type == "Bounce": common_props.update( @@ -208,10 +272,13 @@ class AmazonSESTrackingWebhookView(AmazonSESBaseWebhookView): description="{bounceType}: {bounceSubType}".format(**event_object), reject_reason=RejectReason.BOUNCED, ) - per_recipient_props = [dict( - recipient=recipient["emailAddress"], - mta_response=recipient.get("diagnosticCode"), - ) for recipient in event_object["bouncedRecipients"]] + per_recipient_props = [ + dict( + recipient=recipient["emailAddress"], + mta_response=recipient.get("diagnosticCode"), + ) + for recipient in event_object["bouncedRecipients"] + ] elif ses_event_type == "Complaint": common_props.update( event_type=EventType.COMPLAINED, @@ -219,17 +286,18 @@ class AmazonSESTrackingWebhookView(AmazonSESBaseWebhookView): reject_reason=RejectReason.SPAM, user_agent=event_object.get("userAgent"), ) - per_recipient_props = [dict( - recipient=recipient["emailAddress"], - ) for recipient in event_object["complainedRecipients"]] + per_recipient_props = [ + dict(recipient=recipient["emailAddress"]) + for recipient in event_object["complainedRecipients"] + ] elif ses_event_type == "Delivery": common_props.update( event_type=EventType.DELIVERED, mta_response=event_object.get("smtpResponse"), ) - per_recipient_props = [dict( - recipient=recipient, - ) for recipient in event_object["recipients"]] + per_recipient_props = [ + dict(recipient=recipient) for recipient in event_object["recipients"] + ] elif ses_event_type == "Send": common_props.update( event_type=EventType.SENT, @@ -256,7 +324,8 @@ class AmazonSESTrackingWebhookView(AmazonSESBaseWebhookView): click_url=event_object.get("link"), ) elif ses_event_type == "Rendering Failure": - event_object = ses_event["failure"] # rather than ses_event["rendering failure"] + # (this type doesn't follow usual event_object naming) + event_object = ses_event["failure"] common_props.update( event_type=EventType.FAILED, description=event_object["errorMessage"], @@ -285,8 +354,9 @@ class AmazonSESInboundWebhookView(AmazonSESBaseWebhookView): # This is not an inbound event raise AnymailConfigurationError( "You seem to have set an Amazon SES *sending* event or notification " - "to publish to an SNS Topic that posts to Anymail's *inbound* webhook URL. " - "(SNS TopicArn %s)" % sns_message.get("TopicArn")) + "to publish to an SNS Topic that posts to Anymail's *inbound* webhook " + "URL. (SNS TopicArn %s)" % sns_message.get("TopicArn") + ) receipt_object = ses_event.get("receipt", {}) action_object = receipt_object.get("action", {}) @@ -301,11 +371,13 @@ class AmazonSESInboundWebhookView(AmazonSESBaseWebhookView): else: message = AnymailInboundMessage.parse_raw_mime(content) elif action_type == "S3": - # download message from s3 into memory, then parse - # (SNS has 15s limit for an http response; hope download doesn't take that long) + # download message from s3 into memory, then parse. (SNS has 15s limit + # for an http response; hope download doesn't take that long) bucket_name = action_object["bucketName"] object_key = action_object["objectKey"] - s3 = boto3.session.Session(**self.session_params).client("s3", **self.client_params) + s3 = boto3.session.Session(**self.session_params).client( + "s3", **self.client_params + ) content = io.BytesIO() try: s3.download_fileobj(bucket_name, object_key, content) @@ -314,46 +386,62 @@ class AmazonSESInboundWebhookView(AmazonSESBaseWebhookView): except ClientError as err: # improve the botocore error message raise AnymailBotoClientAPIError( - "Anymail AmazonSESInboundWebhookView couldn't download S3 object '{bucket_name}:{object_key}'" + "Anymail AmazonSESInboundWebhookView couldn't download" + " S3 object '{bucket_name}:{object_key}'" "".format(bucket_name=bucket_name, object_key=object_key), - client_error=err) from err + client_error=err, + ) from err finally: content.close() else: raise AnymailConfigurationError( - "Anymail's Amazon SES inbound webhook works only with 'SNS' or 'S3' receipt rule actions, " - "not SNS notifications for {action_type!s} actions. (SNS TopicArn {topic_arn!s})" - "".format(action_type=action_type, topic_arn=sns_message.get("TopicArn"))) + "Anymail's Amazon SES inbound webhook works only with 'SNS' or 'S3'" + " receipt rule actions, not SNS notifications for {action_type!s}" + " actions. (SNS TopicArn {topic_arn!s})" + "".format( + action_type=action_type, topic_arn=sns_message.get("TopicArn") + ) + ) - message.envelope_sender = mail_object.get("source") # "the envelope MAIL FROM address" + # "the envelope MAIL FROM address": + message.envelope_sender = mail_object.get("source") try: # "recipients that were matched by the active receipt rule" message.envelope_recipient = receipt_object["recipients"][0] except (KeyError, TypeError, IndexError): pass spam_status = receipt_object.get("spamVerdict", {}).get("status", "").upper() - message.spam_detected = {"PASS": False, "FAIL": True}.get(spam_status) # else None if unsure + # spam_detected = False if no spam, True if spam, or None if unsure: + message.spam_detected = {"PASS": False, "FAIL": True}.get(spam_status) - event_id = mail_object.get("messageId") # "unique ID assigned to the email by Amazon SES" + # "unique ID assigned to the email by Amazon SES": + event_id = mail_object.get("messageId") try: - timestamp = parse_datetime(mail_object["timestamp"]) # "time at which the email was received" + # "time at which the email was received": + timestamp = parse_datetime(mail_object["timestamp"]) except (KeyError, ValueError): timestamp = None - return [AnymailInboundEvent( - event_type=EventType.INBOUND, - event_id=event_id, - message=message, - timestamp=timestamp, - esp_event=ses_event, - )] + return [ + AnymailInboundEvent( + event_type=EventType.INBOUND, + event_id=event_id, + message=message, + timestamp=timestamp, + esp_event=ses_event, + ) + ] class AnymailBotoClientAPIError(AnymailAPIError, ClientError): """An AnymailAPIError that is also a Boto ClientError""" + def __init__(self, *args, client_error): assert isinstance(client_error, ClientError) # init self as boto ClientError (which doesn't cooperatively subclass): - super().__init__(error_response=client_error.response, operation_name=client_error.operation_name) + super().__init__( + error_response=client_error.response, + operation_name=client_error.operation_name, + ) # emulate AnymailError init: self.args = args diff --git a/anymail/webhooks/base.py b/anymail/webhooks/base.py index c565ec8..5a2aefe 100644 --- a/anymail/webhooks/base.py +++ b/anymail/webhooks/base.py @@ -7,7 +7,7 @@ from django.views.decorators.csrf import csrf_exempt from django.views.generic import View from ..exceptions import AnymailInsecureWebhookWarning, AnymailWebhookValidationFailure -from ..utils import get_anymail_setting, collect_all_methods, get_request_basic_auth +from ..utils import collect_all_methods, get_anymail_setting, get_request_basic_auth # Mixin note: Django's View.__init__ doesn't cooperate with chaining, @@ -25,7 +25,7 @@ class AnymailCoreWebhookView(View): def __init__(self, **kwargs): super().__init__(**kwargs) - self.validators = collect_all_methods(self.__class__, 'validate_request') + self.validators = collect_all_methods(self.__class__, "validate_request") # Subclass implementation: @@ -99,8 +99,10 @@ class AnymailCoreWebhookView(View): esp_name = "Postmark" esp_name = "SendGrid" # (use ESP's preferred capitalization) """ - raise NotImplementedError("%s.%s must declare esp_name class attr" % - (self.__class__.__module__, self.__class__.__name__)) + raise NotImplementedError( + "%s.%s must declare esp_name class attr" + % (self.__class__.__module__, self.__class__.__name__) + ) class AnymailBasicAuthMixin(AnymailCoreWebhookView): @@ -113,11 +115,16 @@ class AnymailBasicAuthMixin(AnymailCoreWebhookView): warn_if_no_basic_auth = True # List of allowable HTTP basic-auth 'user:pass' strings. - basic_auth = None # (Declaring class attr allows override by kwargs in View.as_view.) + # (Declaring class attr allows override by kwargs in View.as_view.): + basic_auth = None def __init__(self, **kwargs): - self.basic_auth = get_anymail_setting('webhook_secret', default=[], - kwargs=kwargs) # no esp_name -- auth is shared between ESPs + self.basic_auth = get_anymail_setting( + "webhook_secret", + default=[], + # no esp_name -- auth is shared between ESPs + kwargs=kwargs, + ) # Allow a single string: if isinstance(self.basic_auth, str): @@ -127,25 +134,31 @@ class AnymailBasicAuthMixin(AnymailCoreWebhookView): "Your Anymail webhooks are insecure and open to anyone on the web. " "You should set WEBHOOK_SECRET in your ANYMAIL settings. " "See 'Securing webhooks' in the Anymail docs.", - AnymailInsecureWebhookWarning) + AnymailInsecureWebhookWarning, + ) super().__init__(**kwargs) def validate_request(self, request): """If configured for webhook basic auth, validate request has correct auth.""" if self.basic_auth: request_auth = get_request_basic_auth(request) - # Use constant_time_compare to avoid timing attack on basic auth. (It's OK that any() - # can terminate early: we're not trying to protect how many auth strings are allowed, - # just the contents of each individual auth string.) - auth_ok = any(constant_time_compare(request_auth, allowed_auth) - for allowed_auth in self.basic_auth) + # Use constant_time_compare to avoid timing attack on basic auth. (It's OK + # that any() can terminate early: we're not trying to protect how many auth + # strings are allowed, just the contents of each individual auth string.) + auth_ok = any( + constant_time_compare(request_auth, allowed_auth) + for allowed_auth in self.basic_auth + ) if not auth_ok: raise AnymailWebhookValidationFailure( - "Missing or invalid basic auth in Anymail %s webhook" % self.esp_name) + "Missing or invalid basic auth in Anymail %s webhook" + % self.esp_name + ) class AnymailBaseWebhookView(AnymailBasicAuthMixin, AnymailCoreWebhookView): """ Abstract base class for most webhook views, enforcing HTTP basic auth security """ + pass diff --git a/anymail/webhooks/mailgun.py b/anymail/webhooks/mailgun.py index 6c9c1eb..4d54e35 100644 --- a/anymail/webhooks/mailgun.py +++ b/anymail/webhooks/mailgun.py @@ -1,15 +1,32 @@ +import hashlib +import hmac import json from datetime import datetime, timezone -import hashlib -import hmac from django.utils.crypto import constant_time_compare -from .base import AnymailBaseWebhookView -from ..exceptions import AnymailConfigurationError, AnymailWebhookValidationFailure, AnymailInvalidAddress +from ..exceptions import ( + AnymailConfigurationError, + AnymailInvalidAddress, + AnymailWebhookValidationFailure, +) from ..inbound import AnymailInboundMessage -from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason -from ..utils import get_anymail_setting, combine, querydict_getfirst, parse_single_address, UNSET +from ..signals import ( + AnymailInboundEvent, + AnymailTrackingEvent, + EventType, + RejectReason, + inbound, + tracking, +) +from ..utils import ( + UNSET, + combine, + get_anymail_setting, + parse_single_address, + querydict_getfirst, +) +from .base import AnymailBaseWebhookView class MailgunBaseWebhookView(AnymailBaseWebhookView): @@ -18,18 +35,30 @@ class MailgunBaseWebhookView(AnymailBaseWebhookView): esp_name = "Mailgun" warn_if_no_basic_auth = False # because we validate against signature - webhook_signing_key = None # (Declaring class attr allows override by kwargs in View.as_view.) + # (Declaring class attr allows override by kwargs in View.as_view.) + webhook_signing_key = None - # The `api_key` attribute name is still allowed for compatibility with earlier Anymail releases. + # The `api_key` attribute name is still allowed for compatibility + # with earlier Anymail releases. api_key = None # (Declaring class attr allows override by kwargs in View.as_view.) def __init__(self, **kwargs): # webhook_signing_key: falls back to api_key if webhook_signing_key not provided - api_key = get_anymail_setting('api_key', esp_name=self.esp_name, - kwargs=kwargs, allow_bare=True, default=None) - webhook_signing_key = get_anymail_setting('webhook_signing_key', esp_name=self.esp_name, - kwargs=kwargs, default=UNSET if api_key is None else api_key) - self.webhook_signing_key = webhook_signing_key.encode('ascii') # hmac.new requires bytes key + api_key = get_anymail_setting( + "api_key", + esp_name=self.esp_name, + kwargs=kwargs, + allow_bare=True, + default=None, + ) + webhook_signing_key = get_anymail_setting( + "webhook_signing_key", + esp_name=self.esp_name, + kwargs=kwargs, + default=UNSET if api_key is None else api_key, + ) + # hmac.new requires bytes key: + self.webhook_signing_key = webhook_signing_key.encode("ascii") super().__init__(**kwargs) def validate_request(self, request): @@ -37,30 +66,38 @@ class MailgunBaseWebhookView(AnymailBaseWebhookView): if request.content_type == "application/json": # New-style webhook: json payload with separate signature block try: - event = json.loads(request.body.decode('utf-8')) - signature_block = event['signature'] - token = signature_block['token'] - timestamp = signature_block['timestamp'] - signature = signature_block['signature'] + event = json.loads(request.body.decode("utf-8")) + signature_block = event["signature"] + token = signature_block["token"] + timestamp = signature_block["timestamp"] + signature = signature_block["signature"] except (KeyError, ValueError, UnicodeDecodeError) as err: raise AnymailWebhookValidationFailure( - "Mailgun webhook called with invalid payload format") from err + "Mailgun webhook called with invalid payload format" + ) from err else: # Legacy webhook: signature fields are interspersed with other POST data try: - # Must use the *last* value of these fields if there are conflicting merged user-variables. - # (Fortunately, Django QueryDict is specced to return the last value.) - token = request.POST['token'] - timestamp = request.POST['timestamp'] - signature = request.POST['signature'] + # Must use the *last* value of these fields if there are conflicting + # merged user-variables. (Fortunately, Django QueryDict is specced to + # return the last value.) + token = request.POST["token"] + timestamp = request.POST["timestamp"] + signature = request.POST["signature"] except KeyError as err: raise AnymailWebhookValidationFailure( - "Mailgun webhook called without required security fields") from err + "Mailgun webhook called without required security fields" + ) from err - expected_signature = hmac.new(key=self.webhook_signing_key, msg='{}{}'.format(timestamp, token).encode('ascii'), - digestmod=hashlib.sha256).hexdigest() + expected_signature = hmac.new( + key=self.webhook_signing_key, + msg="{}{}".format(timestamp, token).encode("ascii"), + digestmod=hashlib.sha256, + ).hexdigest() if not constant_time_compare(signature, expected_signature): - raise AnymailWebhookValidationFailure("Mailgun webhook called with incorrect signature") + raise AnymailWebhookValidationFailure( + "Mailgun webhook called with incorrect signature" + ) class MailgunTrackingWebhookView(MailgunBaseWebhookView): @@ -70,75 +107,82 @@ class MailgunTrackingWebhookView(MailgunBaseWebhookView): def parse_events(self, request): if request.content_type == "application/json": - esp_event = json.loads(request.body.decode('utf-8')) + esp_event = json.loads(request.body.decode("utf-8")) return [self.esp_to_anymail_event(esp_event)] else: return [self.mailgun_legacy_to_anymail_event(request.POST)] event_types = { # Map Mailgun event: Anymail normalized type - 'accepted': EventType.QUEUED, # not delivered to webhooks (8/2018) - 'rejected': EventType.REJECTED, - 'delivered': EventType.DELIVERED, - 'failed': EventType.BOUNCED, - 'opened': EventType.OPENED, - 'clicked': EventType.CLICKED, - 'unsubscribed': EventType.UNSUBSCRIBED, - 'complained': EventType.COMPLAINED, + "accepted": EventType.QUEUED, # not delivered to webhooks (8/2018) + "rejected": EventType.REJECTED, + "delivered": EventType.DELIVERED, + "failed": EventType.BOUNCED, + "opened": EventType.OPENED, + "clicked": EventType.CLICKED, + "unsubscribed": EventType.UNSUBSCRIBED, + "complained": EventType.COMPLAINED, } reject_reasons = { - # Map Mailgun event_data.reason: Anymail normalized RejectReason - # (these appear in webhook doc examples, but aren't actually documented anywhere) + # Map Mailgun event_data.reason: Anymail normalized RejectReason (these appear + # in webhook doc examples, but aren't actually documented anywhere) "bounce": RejectReason.BOUNCED, "suppress-bounce": RejectReason.BOUNCED, - "generic": RejectReason.OTHER, # ??? appears to be used for any temporary failure? + # ??? "generic" appears to be used for any temporary failure? + "generic": RejectReason.OTHER, } severities = { # Remap some event types based on "severity" payload field - (EventType.BOUNCED, 'temporary'): EventType.DEFERRED + (EventType.BOUNCED, "temporary"): EventType.DEFERRED } def esp_to_anymail_event(self, esp_event): - event_data = esp_event.get('event-data', {}) + event_data = esp_event.get("event-data", {}) - event_type = self.event_types.get(event_data['event'], EventType.UNKNOWN) + event_type = self.event_types.get(event_data["event"], EventType.UNKNOWN) - event_type = self.severities.get((EventType.BOUNCED, event_data.get('severity')), event_type) + event_type = self.severities.get( + (EventType.BOUNCED, event_data.get("severity")), event_type + ) # Use signature.token for event_id, rather than event_data.id, # because the latter is only "guaranteed to be unique within a day". - event_id = esp_event.get('signature', {}).get('token') + event_id = esp_event.get("signature", {}).get("token") - recipient = event_data.get('recipient') + recipient = event_data.get("recipient") try: - timestamp = datetime.fromtimestamp(float(event_data['timestamp']), tz=timezone.utc) + timestamp = datetime.fromtimestamp( + float(event_data["timestamp"]), tz=timezone.utc + ) except KeyError: timestamp = None try: - message_id = event_data['message']['headers']['message-id'] + message_id = event_data["message"]["headers"]["message-id"] except KeyError: message_id = None - if message_id and not message_id.startswith('<'): + if message_id and not message_id.startswith("<"): message_id = "<{}>".format(message_id) - metadata = event_data.get('user-variables', {}) - tags = event_data.get('tags', []) + metadata = event_data.get("user-variables", {}) + tags = event_data.get("tags", []) try: - delivery_status = event_data['delivery-status'] + delivery_status = event_data["delivery-status"] except KeyError: description = None mta_response = None else: - description = delivery_status.get('description') - mta_response = delivery_status.get('message') + description = delivery_status.get("description") + mta_response = delivery_status.get("message") - if 'reason' in event_data: - reject_reason = self.reject_reasons.get(event_data['reason'], RejectReason.OTHER) + if "reason" in event_data: + reject_reason = self.reject_reasons.get( + event_data["reason"], RejectReason.OTHER + ) else: reject_reason = None @@ -149,7 +193,8 @@ class MailgunTrackingWebhookView(MailgunBaseWebhookView): if not recipient: try: to_email = parse_single_address( - event_data["message"]["headers"]["to"]) + event_data["message"]["headers"]["to"] + ) except (AnymailInvalidAddress, KeyError): pass else: @@ -166,8 +211,8 @@ class MailgunTrackingWebhookView(MailgunBaseWebhookView): mta_response=mta_response, tags=tags, metadata=metadata, - click_url=event_data.get('url'), - user_agent=event_data.get('client-info', {}).get('user-agent'), + click_url=event_data.get("url"), + user_agent=event_data.get("client-info", {}).get("user-agent"), esp_event=esp_event, ) @@ -176,13 +221,13 @@ class MailgunTrackingWebhookView(MailgunBaseWebhookView): legacy_event_types = { # Map Mailgun event: Anymail normalized type - 'delivered': EventType.DELIVERED, - 'dropped': EventType.REJECTED, - 'bounced': EventType.BOUNCED, - 'complained': EventType.COMPLAINED, - 'unsubscribed': EventType.UNSUBSCRIBED, - 'opened': EventType.OPENED, - 'clicked': EventType.CLICKED, + "delivered": EventType.DELIVERED, + "dropped": EventType.REJECTED, + "bounced": EventType.BOUNCED, + "complained": EventType.COMPLAINED, + "unsubscribed": EventType.UNSUBSCRIBED, + "opened": EventType.OPENED, + "clicked": EventType.CLICKED, # Mailgun does not send events corresponding to QUEUED or DEFERRED } @@ -190,7 +235,8 @@ class MailgunTrackingWebhookView(MailgunBaseWebhookView): # Map Mailgun (SMTP) error codes to Anymail normalized reject_reason. # By default, we will treat anything 400-599 as REJECT_BOUNCED # so only exceptions are listed here. - 499: RejectReason.TIMED_OUT, # unable to connect to MX (also covers invalid recipients) + 499: RejectReason.TIMED_OUT, # unable to connect to MX + # (499 also covers invalid recipients) # These 6xx codes appear to be Mailgun extensions to SMTP # (and don't seem to be documented anywhere): 605: RejectReason.BOUNCED, # previous bounce @@ -205,123 +251,163 @@ class MailgunTrackingWebhookView(MailgunBaseWebhookView): # to avoid potential conflicting user-data. esp_event.getfirst = querydict_getfirst.__get__(esp_event) - if 'event' not in esp_event and 'sender' in esp_event: + if "event" not in esp_event and "sender" in esp_event: # Inbound events don't (currently) have an event field raise AnymailConfigurationError( "You seem to have set Mailgun's *inbound* route " - "to Anymail's Mailgun *tracking* webhook URL.") + "to Anymail's Mailgun *tracking* webhook URL." + ) - event_type = self.legacy_event_types.get(esp_event.getfirst('event'), EventType.UNKNOWN) - timestamp = datetime.fromtimestamp( - int(esp_event['timestamp']), tz=timezone.utc) # use *last* value of timestamp + event_type = self.legacy_event_types.get( + esp_event.getfirst("event"), EventType.UNKNOWN + ) + # use *last* value of timestamp: + timestamp = datetime.fromtimestamp(int(esp_event["timestamp"]), tz=timezone.utc) # Message-Id is not documented for every event, but seems to always be included. - # (It's sometimes spelled as 'message-id', lowercase, and missing the .) - message_id = esp_event.getfirst('Message-Id', None) or esp_event.getfirst('message-id', None) - if message_id and not message_id.startswith('<'): + # (It's sometimes spelled as 'message-id', lowercase, and missing the + # .) + message_id = esp_event.getfirst("Message-Id", None) or esp_event.getfirst( + "message-id", None + ) + if message_id and not message_id.startswith("<"): message_id = "<{}>".format(message_id) - description = esp_event.getfirst('description', None) - mta_response = esp_event.getfirst('error', None) or esp_event.getfirst('notification', None) + description = esp_event.getfirst("description", None) + mta_response = esp_event.getfirst("error", None) or esp_event.getfirst( + "notification", None + ) reject_reason = None try: - mta_status = int(esp_event.getfirst('code')) + mta_status = int(esp_event.getfirst("code")) except (KeyError, TypeError): pass except ValueError: - # RFC-3463 extended SMTP status code (class.subject.detail, where class is "2", "4" or "5") + # RFC-3463 extended SMTP status code + # (class.subject.detail, where class is "2", "4" or "5") try: - status_class = esp_event.getfirst('code').split('.')[0] + status_class = esp_event.getfirst("code").split(".")[0] except (TypeError, IndexError): # illegal SMTP status code format pass else: - reject_reason = RejectReason.BOUNCED if status_class in ("4", "5") else RejectReason.OTHER + reject_reason = ( + RejectReason.BOUNCED + if status_class in ("4", "5") + else RejectReason.OTHER + ) else: reject_reason = self.legacy_reject_reasons.get( mta_status, - RejectReason.BOUNCED if 400 <= mta_status < 600 - else RejectReason.OTHER) + RejectReason.BOUNCED if 400 <= mta_status < 600 else RejectReason.OTHER, + ) metadata = self._extract_legacy_metadata(esp_event) - # tags are supposed to be in 'tag' fields, but are sometimes in undocumented X-Mailgun-Tag - tags = esp_event.getlist('tag', None) or esp_event.getlist('X-Mailgun-Tag', []) + # tags are supposed to be in 'tag' fields, + # but are sometimes in undocumented X-Mailgun-Tag + tags = esp_event.getlist("tag", None) or esp_event.getlist("X-Mailgun-Tag", []) return AnymailTrackingEvent( event_type=event_type, timestamp=timestamp, message_id=message_id, - event_id=esp_event.get('token', None), # use *last* value of token - recipient=esp_event.getfirst('recipient', None), + event_id=esp_event.get("token", None), # use *last* value of token + recipient=esp_event.getfirst("recipient", None), reject_reason=reject_reason, description=description, mta_response=mta_response, tags=tags, metadata=metadata, - click_url=esp_event.getfirst('url', None), - user_agent=esp_event.getfirst('user-agent', None), + click_url=esp_event.getfirst("url", None), + user_agent=esp_event.getfirst("user-agent", None), esp_event=esp_event, ) def _extract_legacy_metadata(self, esp_event): - # Mailgun merges user-variables into the POST fields. If you know which user variable - # you want to retrieve--and it doesn't conflict with a Mailgun event field--that's fine. - # But if you want to extract all user-variables (like we do), it's more complicated... - event_type = esp_event.getfirst('event') + # Mailgun merges user-variables into the POST fields. If you know which user + # variable you want to retrieve--and it doesn't conflict with a Mailgun event + # field--that's fine. But if you want to extract all user-variables (like we + # do), it's more complicated... + event_type = esp_event.getfirst("event") metadata = {} - if 'message-headers' in esp_event: - # For events where original message headers are available, it's most reliable - # to recover user-variables from the X-Mailgun-Variables header(s). - headers = json.loads(esp_event['message-headers']) - variables = [value for [field, value] in headers if field == 'X-Mailgun-Variables'] + if "message-headers" in esp_event: + # For events where original message headers are available, it's most + # reliable to recover user-variables from the X-Mailgun-Variables header(s). + headers = json.loads(esp_event["message-headers"]) + variables = [ + value for [field, value] in headers if field == "X-Mailgun-Variables" + ] if len(variables) >= 1: - # Each X-Mailgun-Variables value is JSON. Parse and merge them all into single dict: + # Each X-Mailgun-Variables value is JSON. Parse and merge them all into + # single dict: metadata = combine(*[json.loads(value) for value in variables]) elif event_type in self._known_legacy_event_fields: - # For other events, we must extract from the POST fields, ignoring known Mailgun - # event parameters, and treating all other values as user-variables. + # For other events, we must extract from the POST fields, ignoring known + # Mailgun event parameters, and treating all other values as user-variables. known_fields = self._known_legacy_event_fields[event_type] for field, values in esp_event.lists(): if field not in known_fields: - # Unknown fields are assumed to be user-variables. (There should really only be - # a single value, but just in case take the last one to match QueryDict semantics.) + # Unknown fields are assumed to be user-variables. (There should + # really only be a single value, but just in case take the last one + # to match QueryDict semantics.) metadata[field] = values[-1] - elif field == 'tag': - # There's no way to distinguish a user-variable named 'tag' from an actual tag, - # so don't treat this/these value(s) as metadata. + elif field == "tag": + # There's no way to distinguish a user-variable named 'tag' from + # an actual tag, so don't treat this/these value(s) as metadata. pass elif len(values) == 1: - # This is an expected event parameter, and since there's only a single value - # it must be the event param, not metadata. + # This is an expected event parameter, and since there's only a + # single value it must be the event param, not metadata. pass else: - # This is an expected event parameter, but there are (at least) two values. - # One is the event param, and the other is a user-variable metadata value. - # Which is which depends on the field: - if field in {'signature', 'timestamp', 'token'}: - metadata[field] = values[0] # values = [user-variable, event-param] + # This is an expected event parameter, but there are (at least) two + # values. One is the event param, and the other is a user-variable + # metadata value. Which is which depends on the field: + if field in {"signature", "timestamp", "token"}: + # values = [user-variable, event-param] + metadata[field] = values[0] else: - metadata[field] = values[-1] # values = [event-param, user-variable] + # values = [event-param, user-variable] + metadata[field] = values[-1] return metadata _common_legacy_event_fields = { - # These fields are documented to appear in all Mailgun opened, clicked and unsubscribed events: - 'event', 'recipient', 'domain', 'ip', 'country', 'region', 'city', 'user-agent', 'device-type', - 'client-type', 'client-name', 'client-os', 'campaign-id', 'campaign-name', 'tag', 'mailing-list', - 'timestamp', 'token', 'signature', + # These fields are documented to appear in all Mailgun + # opened, clicked and unsubscribed events: + "event", + "recipient", + "domain", + "ip", + "country", + "region", + "city", + "user-agent", + "device-type", + "client-type", + "client-name", + "client-os", + "campaign-id", + "campaign-name", + "tag", + "mailing-list", + "timestamp", + "token", + "signature", # Undocumented, but observed in actual events: - 'body-plain', 'h', 'message-id', + "body-plain", + "h", + "message-id", } _known_legacy_event_fields = { # For all Mailgun event types that *don't* include message-headers, # map Mailgun (not normalized) event type to set of expected event fields. # Used for metadata extraction. - 'clicked': _common_legacy_event_fields | {'url'}, - 'opened': _common_legacy_event_fields, - 'unsubscribed': _common_legacy_event_fields, + "clicked": _common_legacy_event_fields | {"url"}, + "opened": _common_legacy_event_fields, + "unsubscribed": _common_legacy_event_fields, } @@ -332,57 +418,63 @@ class MailgunInboundWebhookView(MailgunBaseWebhookView): def parse_events(self, request): if request.content_type == "application/json": - esp_event = json.loads(request.body.decode('utf-8')) - event_type = esp_event.get('event-data', {}).get('event', '') + esp_event = json.loads(request.body.decode("utf-8")) + event_type = esp_event.get("event-data", {}).get("event", "") raise AnymailConfigurationError( "You seem to have set Mailgun's *%s tracking* webhook " "to Anymail's Mailgun *inbound* webhook URL. " - "(Or Mailgun has changed inbound events to use json.)" - % event_type) + "(Or Mailgun has changed inbound events to use json.)" % event_type + ) return [self.esp_to_anymail_event(request)] def esp_to_anymail_event(self, request): - # Inbound uses the entire Django request as esp_event, because we need POST and FILES. - # Note that request.POST is case-sensitive (unlike email.message.Message headers). + # Inbound uses the entire Django request as esp_event, because + # we need POST and FILES. Note that request.POST is case-sensitive + # (unlike email.message.Message headers). esp_event = request - if request.POST.get('event', 'inbound') != 'inbound': + if request.POST.get("event", "inbound") != "inbound": # (Legacy) tracking event raise AnymailConfigurationError( "You seem to have set Mailgun's *%s tracking* webhook " - "to Anymail's Mailgun *inbound* webhook URL." % request.POST['event']) + "to Anymail's Mailgun *inbound* webhook URL." % request.POST["event"] + ) - if 'attachments' in request.POST: + if "attachments" in request.POST: # Inbound route used store() rather than forward(). # ("attachments" seems to be the only POST param that differs between # store and forward; Anymail could support store by handling the JSON # attachments param in message_from_mailgun_parsed.) raise AnymailConfigurationError( - "You seem to have configured Mailgun's receiving route using the store()" - " action. Anymail's inbound webhook requires the forward() action.") + "You seem to have configured Mailgun's receiving route using" + " the store() action. Anymail's inbound webhook requires" + " the forward() action." + ) - if 'body-mime' in request.POST: + if "body-mime" in request.POST: # Raw-MIME - message = AnymailInboundMessage.parse_raw_mime(request.POST['body-mime']) + message = AnymailInboundMessage.parse_raw_mime(request.POST["body-mime"]) else: # Fully-parsed message = self.message_from_mailgun_parsed(request) - message.envelope_sender = request.POST.get('sender', None) - message.envelope_recipient = request.POST.get('recipient', None) - message.stripped_text = request.POST.get('stripped-text', None) - message.stripped_html = request.POST.get('stripped-html', None) + message.envelope_sender = request.POST.get("sender", None) + message.envelope_recipient = request.POST.get("recipient", None) + message.stripped_text = request.POST.get("stripped-text", None) + message.stripped_html = request.POST.get("stripped-html", None) - message.spam_detected = message.get('X-Mailgun-Sflag', 'No').lower() == 'yes' + message.spam_detected = message.get("X-Mailgun-Sflag", "No").lower() == "yes" try: - message.spam_score = float(message['X-Mailgun-Sscore']) + message.spam_score = float(message["X-Mailgun-Sscore"]) except (TypeError, ValueError): pass return AnymailInboundEvent( event_type=EventType.INBOUND, - timestamp=datetime.fromtimestamp(int(request.POST['timestamp']), tz=timezone.utc), - event_id=request.POST.get('token', None), + timestamp=datetime.fromtimestamp( + int(request.POST["timestamp"]), tz=timezone.utc + ), + event_id=request.POST.get("token", None), esp_event=esp_event, message=message, ) @@ -391,35 +483,44 @@ class MailgunInboundWebhookView(MailgunBaseWebhookView): """Construct a Message from Mailgun's "fully-parsed" fields""" # Mailgun transcodes all fields to UTF-8 for "fully parsed" messages try: - attachment_count = int(request.POST['attachment-count']) + attachment_count = int(request.POST["attachment-count"]) except (KeyError, TypeError): attachments = None else: # Load attachments from posted files: attachment-1, attachment-2, etc. - # content-id-map is {content-id: attachment-id}, identifying which files are inline attachments. - # Invert it to {attachment-id: content-id}, while handling potentially duplicate content-ids. + # content-id-map is {content-id: attachment-id}, identifying which files + # are inline attachments. Invert it to {attachment-id: content-id}, while + # handling potentially duplicate content-ids. field_to_content_id = json.loads( - request.POST.get('content-id-map', '{}'), - object_pairs_hook=lambda pairs: {att_id: cid for (cid, att_id) in pairs}) + request.POST.get("content-id-map", "{}"), + object_pairs_hook=lambda pairs: { + att_id: cid for (cid, att_id) in pairs + }, + ) attachments = [] - for n in range(1, attachment_count+1): + for n in range(1, attachment_count + 1): attachment_id = "attachment-%d" % n try: file = request.FILES[attachment_id] except KeyError: # Django's multipart/form-data handling drops FILES with certain - # filenames (for security) or with empty filenames (Django ticket 15879). + # filenames (for security) or with empty filenames (Django ticket + # 15879). # (To avoid this problem, use Mailgun's "raw MIME" inbound option.) pass else: content_id = field_to_content_id.get(attachment_id) - attachment = AnymailInboundMessage.construct_attachment_from_uploaded_file( - file, content_id=content_id) + attachment = ( + AnymailInboundMessage.construct_attachment_from_uploaded_file( + file, content_id=content_id + ) + ) attachments.append(attachment) return AnymailInboundMessage.construct( - headers=json.loads(request.POST['message-headers']), # includes From, To, Cc, Subject, etc. - text=request.POST.get('body-plain', None), - html=request.POST.get('body-html', None), + # message-headers includes From, To, Cc, Subject, etc. + headers=json.loads(request.POST["message-headers"]), + text=request.POST.get("body-plain", None), + html=request.POST.get("body-html", None), attachments=attachments, ) diff --git a/anymail/webhooks/mailjet.py b/anymail/webhooks/mailjet.py index 678a68b..47613d9 100644 --- a/anymail/webhooks/mailjet.py +++ b/anymail/webhooks/mailjet.py @@ -1,10 +1,16 @@ import json from datetime import datetime, timezone - -from .base import AnymailBaseWebhookView from ..inbound import AnymailInboundMessage -from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason +from ..signals import ( + AnymailInboundEvent, + AnymailTrackingEvent, + EventType, + RejectReason, + inbound, + tracking, +) +from .base import AnymailBaseWebhookView class MailjetTrackingWebhookView(AnymailBaseWebhookView): @@ -14,7 +20,7 @@ class MailjetTrackingWebhookView(AnymailBaseWebhookView): signal = tracking def parse_events(self, request): - esp_events = json.loads(request.body.decode('utf-8')) + esp_events = json.loads(request.body.decode("utf-8")) # Mailjet webhook docs say the payload is "a JSON array of event objects," # but that's not true if "group events" isn't enabled in webhook config... try: @@ -28,65 +34,71 @@ class MailjetTrackingWebhookView(AnymailBaseWebhookView): # https://dev.mailjet.com/guides/#events event_types = { # Map Mailjet event: Anymail normalized type - 'sent': EventType.DELIVERED, # accepted by receiving MTA - 'open': EventType.OPENED, - 'click': EventType.CLICKED, - 'bounce': EventType.BOUNCED, - 'blocked': EventType.REJECTED, - 'spam': EventType.COMPLAINED, - 'unsub': EventType.UNSUBSCRIBED, + "sent": EventType.DELIVERED, # accepted by receiving MTA + "open": EventType.OPENED, + "click": EventType.CLICKED, + "bounce": EventType.BOUNCED, + "blocked": EventType.REJECTED, + "spam": EventType.COMPLAINED, + "unsub": EventType.UNSUBSCRIBED, } reject_reasons = { # Map Mailjet error strings to Anymail normalized reject_reason # error_related_to: recipient - 'user unknown': RejectReason.BOUNCED, - 'mailbox inactive': RejectReason.BOUNCED, - 'quota exceeded': RejectReason.BOUNCED, - 'blacklisted': RejectReason.BLOCKED, # might also be previous unsubscribe - 'spam reporter': RejectReason.SPAM, + "user unknown": RejectReason.BOUNCED, + "mailbox inactive": RejectReason.BOUNCED, + "quota exceeded": RejectReason.BOUNCED, + "blacklisted": RejectReason.BLOCKED, # might also be previous unsubscribe + "spam reporter": RejectReason.SPAM, # error_related_to: domain - 'invalid domain': RejectReason.BOUNCED, - 'no mail host': RejectReason.BOUNCED, - 'relay/access denied': RejectReason.BOUNCED, - 'greylisted': RejectReason.OTHER, # see special handling below - 'typofix': RejectReason.INVALID, - # error_related_to: spam (all Mailjet policy/filtering; see above for spam complaints) - 'sender blocked': RejectReason.BLOCKED, - 'content blocked': RejectReason.BLOCKED, - 'policy issue': RejectReason.BLOCKED, + "invalid domain": RejectReason.BOUNCED, + "no mail host": RejectReason.BOUNCED, + "relay/access denied": RejectReason.BOUNCED, + "greylisted": RejectReason.OTHER, # see special handling below + "typofix": RejectReason.INVALID, + # error_related_to: spam + # (all Mailjet policy/filtering; see above for spam complaints) + "sender blocked": RejectReason.BLOCKED, + "content blocked": RejectReason.BLOCKED, + "policy issue": RejectReason.BLOCKED, # error_related_to: mailjet - 'preblocked': RejectReason.BLOCKED, - 'duplicate in campaign': RejectReason.OTHER, + "preblocked": RejectReason.BLOCKED, + "duplicate in campaign": RejectReason.OTHER, } def esp_to_anymail_event(self, esp_event): - event_type = self.event_types.get(esp_event['event'], EventType.UNKNOWN) - if esp_event.get('error', None) == 'greylisted' and not esp_event.get('hard_bounce', False): - # "This is a temporary error due to possible unrecognised senders. Delivery will be re-attempted." + event_type = self.event_types.get(esp_event["event"], EventType.UNKNOWN) + if esp_event.get("error", None) == "greylisted" and not esp_event.get( + "hard_bounce", False + ): + # "This is a temporary error due to possible unrecognised senders. + # Delivery will be re-attempted." event_type = EventType.DEFERRED try: - timestamp = datetime.fromtimestamp(esp_event['time'], tz=timezone.utc) + timestamp = datetime.fromtimestamp(esp_event["time"], tz=timezone.utc) except (KeyError, ValueError): timestamp = None try: # convert bigint MessageID to str to match backend AnymailRecipientStatus - message_id = str(esp_event['MessageID']) + message_id = str(esp_event["MessageID"]) except (KeyError, TypeError): message_id = None - if 'error' in esp_event: - reject_reason = self.reject_reasons.get(esp_event['error'], RejectReason.OTHER) + if "error" in esp_event: + reject_reason = self.reject_reasons.get( + esp_event["error"], RejectReason.OTHER + ) else: reject_reason = None - tag = esp_event.get('customcampaign', None) + tag = esp_event.get("customcampaign", None) tags = [tag] if tag else [] try: - metadata = json.loads(esp_event['Payload']) + metadata = json.loads(esp_event["Payload"]) except (KeyError, ValueError): metadata = {} @@ -95,13 +107,13 @@ class MailjetTrackingWebhookView(AnymailBaseWebhookView): timestamp=timestamp, message_id=message_id, event_id=None, - recipient=esp_event.get('email', None), + recipient=esp_event.get("email", None), reject_reason=reject_reason, - mta_response=esp_event.get('smtp_reply', None), + mta_response=esp_event.get("smtp_reply", None), tags=tags, metadata=metadata, - click_url=esp_event.get('url', None), - user_agent=esp_event.get('agent', None), + click_url=esp_event.get("url", None), + user_agent=esp_event.get("agent", None), esp_event=esp_event, ) @@ -113,21 +125,23 @@ class MailjetInboundWebhookView(AnymailBaseWebhookView): signal = inbound def parse_events(self, request): - esp_event = json.loads(request.body.decode('utf-8')) + esp_event = json.loads(request.body.decode("utf-8")) return [self.esp_to_anymail_event(esp_event)] def esp_to_anymail_event(self, esp_event): - # You could _almost_ reconstruct the raw mime message from Mailjet's Headers and Parts fields, - # but it's not clear which multipart boundary to use on each individual Part. Although each Part's - # Content-Type header still has the multipart boundary, not knowing the parent part means typical - # nested multipart structures can't be reliably recovered from the data Mailjet provides. - # We'll just use our standarized multipart inbound constructor. + # You could _almost_ reconstruct the raw mime message from Mailjet's Headers + # and Parts fields, but it's not clear which multipart boundary to use on each + # individual Part. Although each Part's Content-Type header still has the + # multipart boundary, not knowing the parent part means typical nested multipart + # structures can't be reliably recovered from the data Mailjet provides. + # Just use our standardized multipart inbound constructor. headers = self._flatten_mailjet_headers(esp_event.get("Headers", {})) attachments = [ self._construct_mailjet_attachment(part, esp_event) for part in esp_event.get("Parts", []) - if "Attachment" in part.get("ContentRef", "") # Attachment or InlineAttachment + # if ContentRef is Attachment or InlineAttachment: + if "Attachment" in part.get("ContentRef", "") ] message = AnymailInboundMessage.construct( headers=headers, @@ -139,49 +153,62 @@ class MailjetInboundWebhookView(AnymailBaseWebhookView): message.envelope_sender = esp_event.get("Sender", None) message.envelope_recipient = esp_event.get("Recipient", None) - message.spam_detected = None # Mailjet doesn't provide a boolean; you'll have to interpret spam_score + # Mailjet doesn't provide a spam boolean; you'll have to interpret spam_score + message.spam_detected = None try: - message.spam_score = float(esp_event['SpamAssassinScore']) + message.spam_score = float(esp_event["SpamAssassinScore"]) except (KeyError, TypeError, ValueError): pass return AnymailInboundEvent( event_type=EventType.INBOUND, - timestamp=None, # Mailjet doesn't provide inbound event timestamp (esp_event['Date'] is time sent) - event_id=None, # Mailjet doesn't provide an idempotent inbound event id + # Mailjet doesn't provide inbound event timestamp + # (esp_event["Date"] is time sent): + timestamp=None, + # Mailjet doesn't provide an idempotent inbound event id: + event_id=None, esp_event=esp_event, message=message, ) @staticmethod def _flatten_mailjet_headers(headers): - """Convert Mailjet's dict-of-strings-and/or-lists header format to our list-of-name-value-pairs + """ + Convert Mailjet's dict-of-strings-and/or-lists header format + to our list-of-name-value-pairs {'name1': 'value', 'name2': ['value1', 'value2']} --> [('name1', 'value'), ('name2', 'value1'), ('name2', 'value2')] """ result = [] for name, values in headers.items(): - if isinstance(values, list): # Mailjet groups repeated headers together as a list of values + if isinstance(values, list): + # Mailjet groups repeated headers together as a list of values for value in values: result.append((name, value)) else: - result.append((name, values)) # single-valued (non-list) header + # single-valued (non-list) header + result.append((name, values)) return result def _construct_mailjet_attachment(self, part, esp_event): - # Mailjet includes unparsed attachment headers in each part; it's easiest to temporarily - # attach them to a MIMEPart for parsing. (We could just turn this into the attachment, - # but we want to use the payload handling from AnymailInboundMessage.construct_attachment later.) - part_headers = AnymailInboundMessage() # temporary container for parsed attachment headers + # Mailjet includes unparsed attachment headers in each part; it's easiest to + # temporarily attach them to a MIMEPart for parsing. (We could just turn this + # into the attachment, but we want to use the payload handling from + # AnymailInboundMessage.construct_attachment later.) + + # temporary container for parsed attachment headers: + part_headers = AnymailInboundMessage() for name, value in self._flatten_mailjet_headers(part.get("Headers", {})): part_headers.add_header(name, value) - content_base64 = esp_event[part["ContentRef"]] # Mailjet *always* base64-encodes attachments + # Mailjet *always* base64-encodes attachments + content_base64 = esp_event[part["ContentRef"]] return AnymailInboundMessage.construct_attachment( content_type=part_headers.get_content_type(), - content=content_base64, base64=True, + content=content_base64, + base64=True, filename=part_headers.get_filename(None), content_id=part_headers.get("Content-ID", "") or None, ) diff --git a/anymail/webhooks/mandrill.py b/anymail/webhooks/mandrill.py index 2c78f07..c01252b 100644 --- a/anymail/webhooks/mandrill.py +++ b/anymail/webhooks/mandrill.py @@ -1,16 +1,22 @@ -import json -from datetime import datetime, timezone - import hashlib import hmac +import json from base64 import b64encode +from datetime import datetime, timezone + from django.utils.crypto import constant_time_compare -from .base import AnymailBaseWebhookView, AnymailCoreWebhookView from ..exceptions import AnymailWebhookValidationFailure from ..inbound import AnymailInboundMessage -from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType -from ..utils import get_anymail_setting, getfirst, get_request_uri +from ..signals import ( + AnymailInboundEvent, + AnymailTrackingEvent, + EventType, + inbound, + tracking, +) +from ..utils import get_anymail_setting, get_request_uri, getfirst +from .base import AnymailBaseWebhookView, AnymailCoreWebhookView class MandrillSignatureMixin(AnymailCoreWebhookView): @@ -22,38 +28,60 @@ class MandrillSignatureMixin(AnymailCoreWebhookView): def __init__(self, **kwargs): esp_name = self.esp_name - # webhook_key is required for POST, but not for HEAD when Mandrill validates webhook url. - # Defer "missing setting" error until we actually try to use it in the POST... - webhook_key = get_anymail_setting('webhook_key', esp_name=esp_name, default=None, - kwargs=kwargs, allow_bare=True) + # webhook_key is required for POST, but not for HEAD when Mandrill validates + # webhook url. Defer "missing setting" error until we actually try to use it in + # the POST... + webhook_key = get_anymail_setting( + "webhook_key", + esp_name=esp_name, + default=None, + kwargs=kwargs, + allow_bare=True, + ) if webhook_key is not None: - self.webhook_key = webhook_key.encode('ascii') # hmac.new requires bytes key - self.webhook_url = get_anymail_setting('webhook_url', esp_name=esp_name, default=None, - kwargs=kwargs, allow_bare=True) + # hmac.new requires bytes key + self.webhook_key = webhook_key.encode("ascii") + self.webhook_url = get_anymail_setting( + "webhook_url", + esp_name=esp_name, + default=None, + kwargs=kwargs, + allow_bare=True, + ) super().__init__(**kwargs) def validate_request(self, request): if self.webhook_key is None: - # issue deferred "missing setting" error (re-call get-setting without a default) - get_anymail_setting('webhook_key', esp_name=self.esp_name, allow_bare=True) + # issue deferred "missing setting" error + # (re-call get-setting without a default) + get_anymail_setting("webhook_key", esp_name=self.esp_name, allow_bare=True) try: signature = request.META["HTTP_X_MANDRILL_SIGNATURE"] except KeyError: - raise AnymailWebhookValidationFailure("X-Mandrill-Signature header missing from webhook POST") from None + raise AnymailWebhookValidationFailure( + "X-Mandrill-Signature header missing from webhook POST" + ) from None - # Mandrill signs the exact URL (including basic auth, if used) plus the sorted POST params: + # Mandrill signs the exact URL (including basic auth, if used) + # plus the sorted POST params: url = self.webhook_url or get_request_uri(request) params = request.POST.dict() signed_data = url for key in sorted(params.keys()): signed_data += key + params[key] - expected_signature = b64encode(hmac.new(key=self.webhook_key, msg=signed_data.encode('utf-8'), - digestmod=hashlib.sha1).digest()) + expected_signature = b64encode( + hmac.new( + key=self.webhook_key, + msg=signed_data.encode("utf-8"), + digestmod=hashlib.sha1, + ).digest() + ) if not constant_time_compare(signature, expected_signature): raise AnymailWebhookValidationFailure( - "Mandrill webhook called with incorrect signature (for url %r)" % url) + "Mandrill webhook called with incorrect signature (for url %r)" % url + ) class MandrillCombinedWebhookView(MandrillSignatureMixin, AnymailBaseWebhookView): @@ -65,19 +93,19 @@ class MandrillCombinedWebhookView(MandrillSignatureMixin, AnymailBaseWebhookView signal = None # set in esp_to_anymail_event def parse_events(self, request): - esp_events = json.loads(request.POST['mandrill_events']) + esp_events = json.loads(request.POST["mandrill_events"]) return [self.esp_to_anymail_event(esp_event) for esp_event in esp_events] def esp_to_anymail_event(self, esp_event): """Route events to the inbound or tracking handler""" - esp_type = getfirst(esp_event, ['event', 'type'], 'unknown') + esp_type = getfirst(esp_event, ["event", "type"], "unknown") - if esp_type == 'inbound': - assert self.signal is not tracking # Mandrill should never mix event types in the same batch + if esp_type == "inbound": + assert self.signal is not tracking # batch must not mix event types self.signal = inbound return self.mandrill_inbound_to_anymail_event(esp_event) else: - assert self.signal is not inbound # Mandrill should never mix event types in the same batch + assert self.signal is not inbound # batch must not mix event types self.signal = tracking return self.mandrill_tracking_to_anymail_event(esp_event) @@ -87,72 +115,74 @@ class MandrillCombinedWebhookView(MandrillSignatureMixin, AnymailBaseWebhookView event_types = { # Message events: - 'send': EventType.SENT, - 'deferral': EventType.DEFERRED, - 'hard_bounce': EventType.BOUNCED, - 'soft_bounce': EventType.BOUNCED, - 'open': EventType.OPENED, - 'click': EventType.CLICKED, - 'spam': EventType.COMPLAINED, - 'unsub': EventType.UNSUBSCRIBED, - 'reject': EventType.REJECTED, + "send": EventType.SENT, + "deferral": EventType.DEFERRED, + "hard_bounce": EventType.BOUNCED, + "soft_bounce": EventType.BOUNCED, + "open": EventType.OPENED, + "click": EventType.CLICKED, + "spam": EventType.COMPLAINED, + "unsub": EventType.UNSUBSCRIBED, + "reject": EventType.REJECTED, # Sync events (we don't really normalize these well): - 'whitelist': EventType.UNKNOWN, - 'blacklist': EventType.UNKNOWN, + "whitelist": EventType.UNKNOWN, + "blacklist": EventType.UNKNOWN, # Inbound events: - 'inbound': EventType.INBOUND, + "inbound": EventType.INBOUND, } def mandrill_tracking_to_anymail_event(self, esp_event): - esp_type = getfirst(esp_event, ['event', 'type'], None) + esp_type = getfirst(esp_event, ["event", "type"], None) event_type = self.event_types.get(esp_type, EventType.UNKNOWN) try: - timestamp = datetime.fromtimestamp(esp_event['ts'], tz=timezone.utc) + timestamp = datetime.fromtimestamp(esp_event["ts"], tz=timezone.utc) except (KeyError, ValueError): timestamp = None try: - recipient = esp_event['msg']['email'] + recipient = esp_event["msg"]["email"] except KeyError: try: - recipient = esp_event['reject']['email'] # sync events + recipient = esp_event["reject"]["email"] # sync events except KeyError: recipient = None try: - mta_response = esp_event['msg']['diag'] + mta_response = esp_event["msg"]["diag"] except KeyError: mta_response = None try: - description = getfirst(esp_event['reject'], ['detail', 'reason']) + description = getfirst(esp_event["reject"], ["detail", "reason"]) except KeyError: description = None try: - metadata = esp_event['msg']['metadata'] + metadata = esp_event["msg"]["metadata"] except KeyError: metadata = {} try: - tags = esp_event['msg']['tags'] + tags = esp_event["msg"]["tags"] except KeyError: tags = [] return AnymailTrackingEvent( - click_url=esp_event.get('url', None), + click_url=esp_event.get("url", None), description=description, esp_event=esp_event, event_type=event_type, - message_id=esp_event.get('_id', None), + message_id=esp_event.get("_id", None), metadata=metadata, mta_response=mta_response, recipient=recipient, - reject_reason=None, # probably map esp_event['msg']['bounce_description'], but insufficient docs + # reject_reason should probably map esp_event['msg']['bounce_description'], + # but Mandrill docs are insufficient to determine how + reject_reason=None, tags=tags, timestamp=timestamp, - user_agent=esp_event.get('user_agent', None), + user_agent=esp_event.get("user_agent", None), ) # @@ -160,27 +190,33 @@ class MandrillCombinedWebhookView(MandrillSignatureMixin, AnymailBaseWebhookView # def mandrill_inbound_to_anymail_event(self, esp_event): - # It's easier (and more accurate) to just work from the original raw mime message - message = AnymailInboundMessage.parse_raw_mime(esp_event['msg']['raw_msg']) - message.envelope_sender = None # (Mandrill's 'sender' field only applies to outbound messages) - message.envelope_recipient = esp_event['msg'].get('email', None) + # It's easier (and more accurate) to just work + # from the original raw mime message + message = AnymailInboundMessage.parse_raw_mime(esp_event["msg"]["raw_msg"]) - message.spam_detected = None # no simple boolean field; would need to parse the spam_report - message.spam_score = esp_event['msg'].get('spam_report', {}).get('score', None) + # (Mandrill's "sender" field only applies to outbound messages) + message.envelope_sender = None + message.envelope_recipient = esp_event["msg"].get("email", None) + + # no simple boolean spam; would need to parse the spam_report + message.spam_detected = None + message.spam_score = esp_event["msg"].get("spam_report", {}).get("score", None) try: - timestamp = datetime.fromtimestamp(esp_event['ts'], tz=timezone.utc) + timestamp = datetime.fromtimestamp(esp_event["ts"], tz=timezone.utc) except (KeyError, ValueError): timestamp = None return AnymailInboundEvent( event_type=EventType.INBOUND, timestamp=timestamp, - event_id=None, # Mandrill doesn't provide an idempotent inbound message event id + # Mandrill doesn't provide an idempotent inbound message event id + event_id=None, esp_event=esp_event, message=message, ) -# Backwards-compatibility: earlier Anymail versions had only MandrillTrackingWebhookView: +# Backwards-compatibility: +# earlier Anymail versions had only MandrillTrackingWebhookView: MandrillTrackingWebhookView = MandrillCombinedWebhookView diff --git a/anymail/webhooks/postal.py b/anymail/webhooks/postal.py index b6d774d..deaa8bd 100644 --- a/anymail/webhooks/postal.py +++ b/anymail/webhooks/postal.py @@ -3,35 +3,36 @@ import json from base64 import b64decode from datetime import datetime, timezone - -from .base import AnymailBaseWebhookView from ..exceptions import ( + AnymailConfigurationError, + AnymailImproperlyInstalled, AnymailInvalidAddress, AnymailWebhookValidationFailure, - AnymailImproperlyInstalled, _LazyError, - AnymailConfigurationError, ) from ..inbound import AnymailInboundMessage from ..signals import ( - inbound, - tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason, + inbound, + tracking, ) -from ..utils import parse_single_address, get_anymail_setting +from ..utils import get_anymail_setting, parse_single_address +from .base import AnymailBaseWebhookView try: - from cryptography.hazmat.primitives import serialization, hashes - from cryptography.hazmat.backends import default_backend - from cryptography.hazmat.primitives.asymmetric import padding from cryptography.exceptions import InvalidSignature + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import padding except ImportError: - # This module gets imported by anymail.urls, so don't complain about cryptography missing - # unless one of the Postal webhook views is actually used and needs it - error = _LazyError(AnymailImproperlyInstalled(missing_package='cryptography', backend='postal')) + # This module gets imported by anymail.urls, so don't complain about cryptography + # missing unless one of the Postal webhook views is actually used and needs it + error = _LazyError( + AnymailImproperlyInstalled(missing_package="cryptography", backend="postal") + ) serialization = error hashes = error default_backend = error @@ -50,7 +51,9 @@ class PostalBaseWebhookView(AnymailBaseWebhookView): webhook_key = None def __init__(self, **kwargs): - self.webhook_key = get_anymail_setting('webhook_key', esp_name=self.esp_name, kwargs=kwargs, allow_bare=True) + self.webhook_key = get_anymail_setting( + "webhook_key", esp_name=self.esp_name, kwargs=kwargs, allow_bare=True + ) super().__init__(**kwargs) @@ -58,23 +61,27 @@ class PostalBaseWebhookView(AnymailBaseWebhookView): try: signature = request.META["HTTP_X_POSTAL_SIGNATURE"] except KeyError: - raise AnymailWebhookValidationFailure("X-Postal-Signature header missing from webhook") + raise AnymailWebhookValidationFailure( + "X-Postal-Signature header missing from webhook" + ) public_key = serialization.load_pem_public_key( - ('-----BEGIN PUBLIC KEY-----\n' + self.webhook_key + '\n-----END PUBLIC KEY-----').encode(), - backend=default_backend() + ( + "-----BEGIN PUBLIC KEY-----\n" + + self.webhook_key + + "\n-----END PUBLIC KEY-----" + ).encode(), + backend=default_backend(), ) try: public_key.verify( - b64decode(signature), - request.body, - padding.PKCS1v15(), - hashes.SHA1() + b64decode(signature), request.body, padding.PKCS1v15(), hashes.SHA1() ) except (InvalidSignature, binascii.Error): raise AnymailWebhookValidationFailure( - "Postal webhook called with incorrect signature") + "Postal webhook called with incorrect signature" + ) class PostalTrackingWebhookView(PostalBaseWebhookView): @@ -85,10 +92,11 @@ class PostalTrackingWebhookView(PostalBaseWebhookView): def parse_events(self, request): esp_event = json.loads(request.body.decode("utf-8")) - if 'rcpt_to' in esp_event: + if "rcpt_to" in esp_event: raise AnymailConfigurationError( "You seem to have set Postal's *inbound* webhook " - "to Anymail's Postal *tracking* webhook URL.") + "to Anymail's Postal *tracking* webhook URL." + ) raw_timestamp = esp_event.get("timestamp") timestamp = ( @@ -133,8 +141,8 @@ class PostalTrackingWebhookView(PostalBaseWebhookView): if message.get("direction") == "incoming": # Let's ignore tracking events about an inbound emails. # This happens when an inbound email could not be forwarded. - # The email didn't originate from Anymail, so the user can't do much about it. - # It is part of normal Postal operation, not a configuration error. + # The email didn't originate from Anymail, so the user can't do much about + # it. It is part of normal Postal operation, not a configuration error. return [] # only for MessageLinkClicked @@ -144,7 +152,7 @@ class PostalTrackingWebhookView(PostalBaseWebhookView): event = AnymailTrackingEvent( event_type=event_type, timestamp=timestamp, - event_id=esp_event.get('uuid'), + event_id=esp_event.get("uuid"), esp_event=esp_event, click_url=click_url, description=description, @@ -152,7 +160,9 @@ class PostalTrackingWebhookView(PostalBaseWebhookView): metadata=None, mta_response=mta_response, recipient=recipient, - reject_reason=RejectReason.BOUNCED if event_type == EventType.BOUNCED else None, + reject_reason=( + RejectReason.BOUNCED if event_type == EventType.BOUNCED else None + ), tags=[tag], user_agent=user_agent, ) @@ -168,18 +178,19 @@ class PostalInboundWebhookView(PostalBaseWebhookView): def parse_events(self, request): esp_event = json.loads(request.body.decode("utf-8")) - if 'status' in esp_event: + if "status" in esp_event: raise AnymailConfigurationError( "You seem to have set Postal's *tracking* webhook " - "to Anymail's Postal *inbound* webhook URL.") + "to Anymail's Postal *inbound* webhook URL." + ) raw_mime = esp_event["message"] if esp_event.get("base64") is True: raw_mime = b64decode(esp_event["message"]).decode("utf-8") message = AnymailInboundMessage.parse_raw_mime(raw_mime) - message.envelope_sender = esp_event.get('mail_from', None) - message.envelope_recipient = esp_event.get('rcpt_to', None) + message.envelope_sender = esp_event.get("mail_from", None) + message.envelope_recipient = esp_event.get("rcpt_to", None) event = AnymailInboundEvent( event_type=EventType.INBOUND, diff --git a/anymail/webhooks/postmark.py b/anymail/webhooks/postmark.py index ba45841..4b13aed 100644 --- a/anymail/webhooks/postmark.py +++ b/anymail/webhooks/postmark.py @@ -2,11 +2,18 @@ import json from django.utils.dateparse import parse_datetime -from .base import AnymailBaseWebhookView from ..exceptions import AnymailConfigurationError from ..inbound import AnymailInboundMessage -from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason -from ..utils import getfirst, EmailAddress +from ..signals import ( + AnymailInboundEvent, + AnymailTrackingEvent, + EventType, + RejectReason, + inbound, + tracking, +) +from ..utils import EmailAddress, getfirst +from .base import AnymailBaseWebhookView class PostmarkBaseWebhookView(AnymailBaseWebhookView): @@ -15,7 +22,7 @@ class PostmarkBaseWebhookView(AnymailBaseWebhookView): esp_name = "Postmark" def parse_events(self, request): - esp_event = json.loads(request.body.decode('utf-8')) + esp_event = json.loads(request.body.decode("utf-8")) return [self.esp_to_anymail_event(esp_event)] def esp_to_anymail_event(self, esp_event): @@ -29,40 +36,44 @@ class PostmarkTrackingWebhookView(PostmarkBaseWebhookView): event_record_types = { # Map Postmark event RecordType --> Anymail normalized event type - 'Bounce': EventType.BOUNCED, # but check Type field for further info (below) - 'Click': EventType.CLICKED, - 'Delivery': EventType.DELIVERED, - 'Open': EventType.OPENED, - 'SpamComplaint': EventType.COMPLAINED, - 'SubscriptionChange': EventType.UNSUBSCRIBED, - 'Inbound': EventType.INBOUND, # future, probably + "Bounce": EventType.BOUNCED, # but check Type field for further info (below) + "Click": EventType.CLICKED, + "Delivery": EventType.DELIVERED, + "Open": EventType.OPENED, + "SpamComplaint": EventType.COMPLAINED, + "SubscriptionChange": EventType.UNSUBSCRIBED, + "Inbound": EventType.INBOUND, # future, probably } event_types = { - # Map Postmark bounce/spam event Type --> Anymail normalized (event type, reject reason) - 'HardBounce': (EventType.BOUNCED, RejectReason.BOUNCED), - 'Transient': (EventType.DEFERRED, None), - 'Unsubscribe': (EventType.UNSUBSCRIBED, RejectReason.UNSUBSCRIBED), - 'Subscribe': (EventType.SUBSCRIBED, None), - 'AutoResponder': (EventType.AUTORESPONDED, None), - 'AddressChange': (EventType.AUTORESPONDED, None), - 'DnsError': (EventType.DEFERRED, None), # "temporary DNS error" - 'SpamNotification': (EventType.COMPLAINED, RejectReason.SPAM), - 'OpenRelayTest': (EventType.DEFERRED, None), # Receiving MTA is testing Postmark - 'Unknown': (EventType.UNKNOWN, None), - 'SoftBounce': (EventType.BOUNCED, RejectReason.BOUNCED), # might also receive HardBounce later - 'VirusNotification': (EventType.BOUNCED, RejectReason.OTHER), - 'ChallengeVerification': (EventType.AUTORESPONDED, None), - 'BadEmailAddress': (EventType.REJECTED, RejectReason.INVALID), - 'SpamComplaint': (EventType.COMPLAINED, RejectReason.SPAM), - 'ManuallyDeactivated': (EventType.REJECTED, RejectReason.BLOCKED), - 'Unconfirmed': (EventType.REJECTED, None), - 'Blocked': (EventType.REJECTED, RejectReason.BLOCKED), - 'SMTPApiError': (EventType.FAILED, None), # could occur if user also using Postmark SMTP directly - 'InboundError': (EventType.INBOUND_FAILED, None), - 'DMARCPolicy': (EventType.REJECTED, RejectReason.BLOCKED), - 'TemplateRenderingFailed': (EventType.FAILED, None), - 'ManualSuppression': (EventType.UNSUBSCRIBED, RejectReason.UNSUBSCRIBED), + # Map Postmark bounce/spam event Type + # --> Anymail normalized (event type, reject reason) + "HardBounce": (EventType.BOUNCED, RejectReason.BOUNCED), + "Transient": (EventType.DEFERRED, None), + "Unsubscribe": (EventType.UNSUBSCRIBED, RejectReason.UNSUBSCRIBED), + "Subscribe": (EventType.SUBSCRIBED, None), + "AutoResponder": (EventType.AUTORESPONDED, None), + "AddressChange": (EventType.AUTORESPONDED, None), + "DnsError": (EventType.DEFERRED, None), # "temporary DNS error" + "SpamNotification": (EventType.COMPLAINED, RejectReason.SPAM), + # Receiving MTA is testing Postmark: + "OpenRelayTest": (EventType.DEFERRED, None), + "Unknown": (EventType.UNKNOWN, None), + # might also receive HardBounce later: + "SoftBounce": (EventType.BOUNCED, RejectReason.BOUNCED), + "VirusNotification": (EventType.BOUNCED, RejectReason.OTHER), + "ChallengeVerification": (EventType.AUTORESPONDED, None), + "BadEmailAddress": (EventType.REJECTED, RejectReason.INVALID), + "SpamComplaint": (EventType.COMPLAINED, RejectReason.SPAM), + "ManuallyDeactivated": (EventType.REJECTED, RejectReason.BLOCKED), + "Unconfirmed": (EventType.REJECTED, None), + "Blocked": (EventType.REJECTED, RejectReason.BLOCKED), + # could occur if user also using Postmark SMTP directly: + "SMTPApiError": (EventType.FAILED, None), + "InboundError": (EventType.INBOUND_FAILED, None), + "DMARCPolicy": (EventType.REJECTED, RejectReason.BLOCKED), + "TemplateRenderingFailed": (EventType.FAILED, None), + "ManualSuppression": (EventType.UNSUBSCRIBED, RejectReason.UNSUBSCRIBED), } def esp_to_anymail_event(self, esp_event): @@ -70,7 +81,7 @@ class PostmarkTrackingWebhookView(PostmarkBaseWebhookView): try: esp_record_type = esp_event["RecordType"] except KeyError: - if 'FromFull' in esp_event: + if "FromFull" in esp_event: # This is an inbound event event_type = EventType.INBOUND else: @@ -81,59 +92,65 @@ class PostmarkTrackingWebhookView(PostmarkBaseWebhookView): if event_type == EventType.INBOUND: raise AnymailConfigurationError( "You seem to have set Postmark's *inbound* webhook " - "to Anymail's Postmark *tracking* webhook URL.") + "to Anymail's Postmark *tracking* webhook URL." + ) if event_type in (EventType.BOUNCED, EventType.COMPLAINED): # additional info is in the Type field try: - event_type, reject_reason = self.event_types[esp_event['Type']] + event_type, reject_reason = self.event_types[esp_event["Type"]] except KeyError: pass if event_type == EventType.UNSUBSCRIBED: - if esp_event['SuppressSending']: + if esp_event["SuppressSending"]: # Postmark doesn't provide a way to distinguish between # explicit unsubscribes and bounces try: - event_type, reject_reason = self.event_types[esp_event['SuppressionReason']] + event_type, reject_reason = self.event_types[ + esp_event["SuppressionReason"] + ] except KeyError: pass else: - event_type, reject_reason = self.event_types['Subscribe'] + event_type, reject_reason = self.event_types["Subscribe"] - recipient = getfirst(esp_event, ['Email', 'Recipient'], None) # Email for bounce; Recipient for open + # Email for bounce; Recipient for open: + recipient = getfirst(esp_event, ["Email", "Recipient"], None) try: - timestr = getfirst(esp_event, ['DeliveredAt', 'BouncedAt', 'ReceivedAt', 'ChangedAt']) + timestr = getfirst( + esp_event, ["DeliveredAt", "BouncedAt", "ReceivedAt", "ChangedAt"] + ) except KeyError: timestamp = None else: timestamp = parse_datetime(timestr) try: - event_id = str(esp_event['ID']) # only in bounce events + event_id = str(esp_event["ID"]) # only in bounce events except KeyError: event_id = None - metadata = esp_event.get('Metadata', {}) + metadata = esp_event.get("Metadata", {}) try: - tags = [esp_event['Tag']] + tags = [esp_event["Tag"]] except KeyError: tags = [] return AnymailTrackingEvent( - description=esp_event.get('Description', None), + description=esp_event.get("Description", None), esp_event=esp_event, event_id=event_id, event_type=event_type, - message_id=esp_event.get('MessageID', None), + message_id=esp_event.get("MessageID", None), metadata=metadata, - mta_response=esp_event.get('Details', None), + mta_response=esp_event.get("Details", None), recipient=recipient, reject_reason=reject_reason, tags=tags, timestamp=timestamp, - user_agent=esp_event.get('UserAgent', None), - click_url=esp_event.get('OriginalLink', None), + user_agent=esp_event.get("UserAgent", None), + click_url=esp_event.get("OriginalLink", None), ) @@ -146,12 +163,14 @@ class PostmarkInboundWebhookView(PostmarkBaseWebhookView): if esp_event.get("RecordType", "Inbound") != "Inbound": raise AnymailConfigurationError( "You seem to have set Postmark's *%s* webhook " - "to Anymail's Postmark *inbound* webhook URL." % esp_event["RecordType"]) + "to Anymail's Postmark *inbound* webhook URL." % esp_event["RecordType"] + ) attachments = [ AnymailInboundMessage.construct_attachment( content_type=attachment["ContentType"], - content=attachment["Content"], base64=True, + content=attachment["Content"], + base64=True, filename=attachment.get("Name", "") or None, content_id=attachment.get("ContentID", "") or None, ) @@ -160,11 +179,15 @@ class PostmarkInboundWebhookView(PostmarkBaseWebhookView): message = AnymailInboundMessage.construct( from_email=self._address(esp_event.get("FromFull")), - to=', '.join([self._address(to) for to in esp_event.get("ToFull", [])]), - cc=', '.join([self._address(cc) for cc in esp_event.get("CcFull", [])]), - # bcc? Postmark specs this for inbound events, but it's unclear how it could occur + to=", ".join([self._address(to) for to in esp_event.get("ToFull", [])]), + cc=", ".join([self._address(cc) for cc in esp_event.get("CcFull", [])]), + # bcc? Postmark specs this for inbound events, + # but it's unclear how it could occur subject=esp_event.get("Subject", ""), - headers=[(header["Name"], header["Value"]) for header in esp_event.get("Headers", [])], + headers=[ + (header["Name"], header["Value"]) + for header in esp_event.get("Headers", []) + ], text=esp_event.get("TextBody", ""), html=esp_event.get("HtmlBody", ""), attachments=attachments, @@ -176,36 +199,48 @@ class PostmarkInboundWebhookView(PostmarkBaseWebhookView): if "ReplyTo" in esp_event and "Reply-To" not in message: message["Reply-To"] = esp_event["ReplyTo"] - # Postmark doesn't have a separate envelope-sender field, but it can be extracted - # from the Received-SPF header that Postmark will have added: - if len(message.get_all("Received-SPF", [])) == 1: # (more than one? someone's up to something weird) + # Postmark doesn't have a separate envelope-sender field, but it can + # be extracted from the Received-SPF header that Postmark will have added. + # (More than one Received-SPF? someone's up to something weird?) + if len(message.get_all("Received-SPF", [])) == 1: received_spf = message["Received-SPF"].lower() - if received_spf.startswith("pass") or received_spf.startswith("neutral"): # not fail/softfail - message.envelope_sender = message.get_param("envelope-from", None, header="Received-SPF") + if received_spf.startswith( # not fail/softfail + "pass" + ) or received_spf.startswith("neutral"): + message.envelope_sender = message.get_param( + "envelope-from", None, header="Received-SPF" + ) message.envelope_recipient = esp_event.get("OriginalRecipient", None) message.stripped_text = esp_event.get("StrippedTextReply", None) - message.spam_detected = message.get('X-Spam-Status', 'No').lower() == 'yes' + message.spam_detected = message.get("X-Spam-Status", "No").lower() == "yes" try: - message.spam_score = float(message['X-Spam-Score']) + message.spam_score = float(message["X-Spam-Score"]) except (TypeError, ValueError): pass return AnymailInboundEvent( event_type=EventType.INBOUND, - timestamp=None, # Postmark doesn't provide inbound event timestamp - event_id=esp_event.get("MessageID", None), # Postmark uuid, different from Message-ID mime header + # Postmark doesn't provide inbound event timestamp: + timestamp=None, + # Postmark uuid, different from Message-ID mime header: + event_id=esp_event.get("MessageID", None), esp_event=esp_event, message=message, ) @staticmethod def _address(full): - """Return an formatted email address from a Postmark inbound {From,To,Cc}Full dict""" + """ + Return a formatted email address + from a Postmark inbound {From,To,Cc}Full dict + """ if full is None: return "" - return str(EmailAddress( - display_name=full.get('Name', ""), - addr_spec=full.get("Email", ""), - )) + return str( + EmailAddress( + display_name=full.get("Name", ""), + addr_spec=full.get("Email", ""), + ) + ) diff --git a/anymail/webhooks/sendgrid.py b/anymail/webhooks/sendgrid.py index 999c230..8f08547 100644 --- a/anymail/webhooks/sendgrid.py +++ b/anymail/webhooks/sendgrid.py @@ -3,10 +3,16 @@ from datetime import datetime, timezone from email.parser import BytesParser from email.policy import default as default_policy - -from .base import AnymailBaseWebhookView from ..inbound import AnymailInboundMessage -from ..signals import AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason, inbound, tracking +from ..signals import ( + AnymailInboundEvent, + AnymailTrackingEvent, + EventType, + RejectReason, + inbound, + tracking, +) +from .base import AnymailBaseWebhookView class SendGridTrackingWebhookView(AnymailBaseWebhookView): @@ -16,47 +22,50 @@ class SendGridTrackingWebhookView(AnymailBaseWebhookView): signal = tracking def parse_events(self, request): - esp_events = json.loads(request.body.decode('utf-8')) + esp_events = json.loads(request.body.decode("utf-8")) return [self.esp_to_anymail_event(esp_event) for esp_event in esp_events] event_types = { # Map SendGrid event: Anymail normalized type - 'bounce': EventType.BOUNCED, - 'deferred': EventType.DEFERRED, - 'delivered': EventType.DELIVERED, - 'dropped': EventType.REJECTED, - 'processed': EventType.QUEUED, - 'click': EventType.CLICKED, - 'open': EventType.OPENED, - 'spamreport': EventType.COMPLAINED, - 'unsubscribe': EventType.UNSUBSCRIBED, - 'group_unsubscribe': EventType.UNSUBSCRIBED, - 'group_resubscribe': EventType.SUBSCRIBED, + "bounce": EventType.BOUNCED, + "deferred": EventType.DEFERRED, + "delivered": EventType.DELIVERED, + "dropped": EventType.REJECTED, + "processed": EventType.QUEUED, + "click": EventType.CLICKED, + "open": EventType.OPENED, + "spamreport": EventType.COMPLAINED, + "unsubscribe": EventType.UNSUBSCRIBED, + "group_unsubscribe": EventType.UNSUBSCRIBED, + "group_resubscribe": EventType.SUBSCRIBED, } reject_reasons = { - # Map SendGrid reason/type strings (lowercased) to Anymail normalized reject_reason - 'invalid': RejectReason.INVALID, - 'unsubscribed address': RejectReason.UNSUBSCRIBED, - 'bounce': RejectReason.BOUNCED, - 'blocked': RejectReason.BLOCKED, - 'expired': RejectReason.TIMED_OUT, + # Map SendGrid reason/type strings (lowercased) + # to Anymail normalized reject_reason + "invalid": RejectReason.INVALID, + "unsubscribed address": RejectReason.UNSUBSCRIBED, + "bounce": RejectReason.BOUNCED, + "blocked": RejectReason.BLOCKED, + "expired": RejectReason.TIMED_OUT, } def esp_to_anymail_event(self, esp_event): - event_type = self.event_types.get(esp_event['event'], EventType.UNKNOWN) + event_type = self.event_types.get(esp_event["event"], EventType.UNKNOWN) try: - timestamp = datetime.fromtimestamp(esp_event['timestamp'], tz=timezone.utc) + timestamp = datetime.fromtimestamp(esp_event["timestamp"], tz=timezone.utc) except (KeyError, ValueError): timestamp = None - if esp_event['event'] == 'dropped': - mta_response = None # dropped at ESP before even getting to MTA - reason = esp_event.get('type', esp_event.get('reason', '')) # cause could be in 'type' or 'reason' + if esp_event["event"] == "dropped": + # message dropped at ESP before even getting to MTA: + mta_response = None + # cause could be in "type" or "reason": + reason = esp_event.get("type", esp_event.get("reason", "")) reject_reason = self.reject_reasons.get(reason.lower(), RejectReason.OTHER) else: - # MTA response is in 'response' for delivered; 'reason' for bounce - mta_response = esp_event.get('response', esp_event.get('reason', None)) + # MTA response is in "response" for delivered; "reason" for bounce + mta_response = esp_event.get("response", esp_event.get("reason", None)) reject_reason = None # SendGrid merges metadata ('unique_args') with the event. @@ -73,49 +82,50 @@ class SendGridTrackingWebhookView(AnymailBaseWebhookView): return AnymailTrackingEvent( event_type=event_type, timestamp=timestamp, - message_id=esp_event.get('anymail_id', esp_event.get('smtp-id')), # backwards compatibility - event_id=esp_event.get('sg_event_id', None), - recipient=esp_event.get('email', None), + # (smtp-id for backwards compatibility) + message_id=esp_event.get("anymail_id", esp_event.get("smtp-id")), + event_id=esp_event.get("sg_event_id", None), + recipient=esp_event.get("email", None), reject_reason=reject_reason, mta_response=mta_response, - tags=esp_event.get('category', []), + tags=esp_event.get("category", []), metadata=metadata, - click_url=esp_event.get('url', None), - user_agent=esp_event.get('useragent', None), + click_url=esp_event.get("url", None), + user_agent=esp_event.get("useragent", None), esp_event=esp_event, ) # Known keys in SendGrid events (used to recover metadata above) sendgrid_event_keys = { - 'anymail_id', - 'asm_group_id', - 'attempt', # MTA deferred count - 'category', - 'cert_err', - 'email', - 'event', - 'ip', - 'marketing_campaign_id', - 'marketing_campaign_name', - 'newsletter', # ??? - 'nlvx_campaign_id', - 'nlvx_campaign_split_id', - 'nlvx_user_id', - 'pool', - 'post_type', - 'reason', # MTA bounce/drop reason; SendGrid suppression reason - 'response', # MTA deferred/delivered message - 'send_at', - 'sg_event_id', - 'sg_message_id', - 'smtp-id', - 'status', # SMTP status code - 'timestamp', - 'tls', - 'type', # suppression reject reason ("bounce", "blocked", "expired") - 'url', # click tracking - 'url_offset', # click tracking - 'useragent', # click/open tracking + "anymail_id", + "asm_group_id", + "attempt", # MTA deferred count + "category", + "cert_err", + "email", + "event", + "ip", + "marketing_campaign_id", + "marketing_campaign_name", + "newsletter", # ??? + "nlvx_campaign_id", + "nlvx_campaign_split_id", + "nlvx_user_id", + "pool", + "post_type", + "reason", # MTA bounce/drop reason; SendGrid suppression reason + "response", # MTA deferred/delivered message + "send_at", + "sg_event_id", + "sg_message_id", + "smtp-id", + "status", # SMTP status code + "timestamp", + "tls", + "type", # suppression reject reason ("bounce", "blocked", "expired") + "url", # click tracking + "url_offset", # click tracking + "useragent", # click/open tracking } @@ -129,39 +139,46 @@ class SendGridInboundWebhookView(AnymailBaseWebhookView): return [self.esp_to_anymail_event(request)] def esp_to_anymail_event(self, request): - # Inbound uses the entire Django request as esp_event, because we need POST and FILES. - # Note that request.POST is case-sensitive (unlike email.message.Message headers). + # Inbound uses the entire Django request as esp_event, because we need + # POST and FILES. Note that request.POST is case-sensitive (unlike + # email.message.Message headers). esp_event = request # Must access body before any POST fields, or it won't be available if we need # it later (see text_charset and html_charset handling below). _ensure_body_is_available_later = request.body # noqa: F841 - if 'headers' in request.POST: + if "headers" in request.POST: # Default (not "Send Raw") inbound fields message = self.message_from_sendgrid_parsed(esp_event) - elif 'email' in request.POST: + elif "email" in request.POST: # "Send Raw" full MIME - message = AnymailInboundMessage.parse_raw_mime(request.POST['email']) + message = AnymailInboundMessage.parse_raw_mime(request.POST["email"]) else: - raise KeyError("Invalid SendGrid inbound event data (missing both 'headers' and 'email' fields)") + raise KeyError( + "Invalid SendGrid inbound event data" + " (missing both 'headers' and 'email' fields)" + ) try: - envelope = json.loads(request.POST['envelope']) + envelope = json.loads(request.POST["envelope"]) except (KeyError, TypeError, ValueError): pass else: - message.envelope_sender = envelope['from'] - message.envelope_recipient = envelope['to'][0] + message.envelope_sender = envelope["from"] + message.envelope_recipient = envelope["to"][0] - message.spam_detected = None # no simple boolean field; would need to parse the spam_report + # no simple boolean spam; would need to parse the spam_report + message.spam_detected = None try: - message.spam_score = float(request.POST['spam_score']) + message.spam_score = float(request.POST["spam_score"]) except (KeyError, TypeError, ValueError): pass return AnymailInboundEvent( event_type=EventType.INBOUND, - timestamp=None, # SendGrid doesn't provide an inbound event timestamp - event_id=None, # SendGrid doesn't provide an idempotent inbound message event id + # SendGrid doesn't provide an inbound event timestamp: + timestamp=None, + # SendGrid doesn't provide an idempotent inbound message event id: + event_id=None, esp_event=esp_event, message=message, ) @@ -170,12 +187,12 @@ class SendGridInboundWebhookView(AnymailBaseWebhookView): """Construct a Message from SendGrid's "default" (non-raw) fields""" try: - charsets = json.loads(request.POST['charsets']) + charsets = json.loads(request.POST["charsets"]) except (KeyError, ValueError): charsets = {} try: - attachment_info = json.loads(request.POST['attachment-info']) + attachment_info = json.loads(request.POST["attachment-info"]) except (KeyError, ValueError): attachments = None else: @@ -186,44 +203,60 @@ class SendGridInboundWebhookView(AnymailBaseWebhookView): file = request.FILES[attachment_id] except KeyError: # Django's multipart/form-data handling drops FILES with certain - # filenames (for security) or with empty filenames (Django ticket 15879). - # (To avoid this problem, enable SendGrid's "raw, full MIME" inbound option.) + # filenames (for security) or with empty filenames (Django ticket + # 15879). (To avoid this problem, enable SendGrid's "raw, full MIME" + # inbound option.) pass else: - # (This deliberately ignores attachment_info[attachment_id]["filename"], + # (This deliberately ignores + # attachment_info[attachment_id]["filename"], # which has not passed through Django's filename sanitization.) content_id = attachment_info[attachment_id].get("content-id") - attachment = AnymailInboundMessage.construct_attachment_from_uploaded_file( - file, content_id=content_id) + attachment = ( + AnymailInboundMessage.construct_attachment_from_uploaded_file( + file, content_id=content_id + ) + ) attachments.append(attachment) default_charset = request.POST.encoding.lower() # (probably utf-8) - text = request.POST.get('text') - text_charset = charsets.get('text', default_charset).lower() - html = request.POST.get('html') - html_charset = charsets.get('html', default_charset).lower() - if (text and text_charset != default_charset) or (html and html_charset != default_charset): + text = request.POST.get("text") + text_charset = charsets.get("text", default_charset).lower() + html = request.POST.get("html") + html_charset = charsets.get("html", default_charset).lower() + if (text and text_charset != default_charset) or ( + html and html_charset != default_charset + ): # Django has parsed text and/or html fields using the wrong charset. # We need to re-parse the raw form data and decode each field separately, # using the indicated charsets. The email package parses multipart/form-data # retaining bytes content. (In theory, we could instead just change # request.encoding and access the POST fields again, per Django docs, # but that seems to be have bugs around the cached request._files.) - raw_data = b"".join([ - b"Content-Type: ", request.META['CONTENT_TYPE'].encode('ascii'), - b"\r\n\r\n", - request.body - ]) - parsed_parts = BytesParser(policy=default_policy).parsebytes(raw_data).get_payload() + raw_data = b"".join( + [ + b"Content-Type: ", + request.META["CONTENT_TYPE"].encode("ascii"), + b"\r\n\r\n", + request.body, + ] + ) + parsed_parts = ( + BytesParser(policy=default_policy).parsebytes(raw_data).get_payload() + ) for part in parsed_parts: - name = part.get_param('name', header='content-disposition') - if name == 'text': + name = part.get_param("name", header="content-disposition") + if name == "text": text = part.get_payload(decode=True).decode(text_charset) - elif name == 'html': + elif name == "html": html = part.get_payload(decode=True).decode(html_charset) # (subject, from, to, etc. are parsed from raw headers field, # so no need to worry about their separate POST field charsets) return AnymailInboundMessage.construct( - raw_headers=request.POST.get('headers', ""), # includes From, To, Cc, Subject, etc. - text=text, html=html, attachments=attachments) + # POST["headers"] includes From, To, Cc, Subject, etc. + raw_headers=request.POST.get("headers", ""), + text=text, + html=html, + attachments=attachments, + ) diff --git a/anymail/webhooks/sendinblue.py b/anymail/webhooks/sendinblue.py index 9e78f35..47c36a2 100644 --- a/anymail/webhooks/sendinblue.py +++ b/anymail/webhooks/sendinblue.py @@ -1,8 +1,8 @@ import json from datetime import datetime, timezone -from .base import AnymailBaseWebhookView from ..signals import AnymailTrackingEvent, EventType, RejectReason, tracking +from .base import AnymailBaseWebhookView class SendinBlueTrackingWebhookView(AnymailBaseWebhookView): @@ -12,14 +12,15 @@ class SendinBlueTrackingWebhookView(AnymailBaseWebhookView): signal = tracking def parse_events(self, request): - esp_event = json.loads(request.body.decode('utf-8')) + esp_event = json.loads(request.body.decode("utf-8")) return [self.esp_to_anymail_event(esp_event)] # SendinBlue's webhook payload data doesn't seem to be documented anywhere. # There's a list of webhook events at https://apidocs.sendinblue.com/webhooks/#3. event_types = { # Map SendinBlue event type: Anymail normalized (event type, reject reason) - "request": (EventType.QUEUED, None), # received even if message won't be sent (e.g., before "blocked") + # received even if message won't be sent (e.g., before "blocked"): + "request": (EventType.QUEUED, None), "delivered": (EventType.DELIVERED, None), "hard_bounce": (EventType.BOUNCED, RejectReason.BOUNCED), "soft_bounce": (EventType.BOUNCED, RejectReason.BOUNCED), @@ -30,32 +31,39 @@ class SendinBlueTrackingWebhookView(AnymailBaseWebhookView): "opened": (EventType.OPENED, None), # see also unique_opened below "click": (EventType.CLICKED, None), "unsubscribe": (EventType.UNSUBSCRIBED, None), - "list_addition": (EventType.SUBSCRIBED, None), # shouldn't occur for transactional messages + # shouldn't occur for transactional messages: + "list_addition": (EventType.SUBSCRIBED, None), "unique_opened": (EventType.OPENED, None), # you'll *also* receive an "opened" } def esp_to_anymail_event(self, esp_event): esp_type = esp_event.get("event") - event_type, reject_reason = self.event_types.get(esp_type, (EventType.UNKNOWN, None)) + event_type, reject_reason = self.event_types.get( + esp_type, (EventType.UNKNOWN, None) + ) recipient = esp_event.get("email") try: - # SendinBlue supplies "ts", "ts_event" and "date" fields, which seem to be based on the - # timezone set in the account preferences (and possibly with inconsistent DST adjustment). - # "ts_epoch" is the only field that seems to be consistently UTC; it's in milliseconds - timestamp = datetime.fromtimestamp(esp_event["ts_epoch"] / 1000.0, tz=timezone.utc) + # SendinBlue supplies "ts", "ts_event" and "date" fields, which seem to be + # based on the timezone set in the account preferences (and possibly with + # inconsistent DST adjustment). "ts_epoch" is the only field that seems to + # be consistently UTC; it's in milliseconds + timestamp = datetime.fromtimestamp( + esp_event["ts_epoch"] / 1000.0, tz=timezone.utc + ) except (KeyError, ValueError): timestamp = None tags = [] try: # If `tags` param set on send, webhook payload includes 'tags' array field. - tags = esp_event['tags'] + tags = esp_event["tags"] except KeyError: try: - # If `X-Mailin-Tag` header set on send, webhook payload includes single 'tag' string. - # (If header not set, webhook 'tag' will be the template name for template sends.) - tags = [esp_event['tag']] + # If `X-Mailin-Tag` header set on send, webhook payload includes single + # 'tag' string. (If header not set, webhook 'tag' will be the template + # name for template sends.) + tags = [esp_event["tag"]] except KeyError: pass @@ -67,7 +75,8 @@ class SendinBlueTrackingWebhookView(AnymailBaseWebhookView): return AnymailTrackingEvent( description=None, esp_event=esp_event, - event_id=None, # SendinBlue doesn't provide a unique event id + # SendinBlue doesn't provide a unique event id: + event_id=None, event_type=event_type, message_id=esp_event.get("message-id"), metadata=metadata, diff --git a/anymail/webhooks/sparkpost.py b/anymail/webhooks/sparkpost.py index 96ab99e..95ff00e 100644 --- a/anymail/webhooks/sparkpost.py +++ b/anymail/webhooks/sparkpost.py @@ -2,11 +2,18 @@ import json from base64 import b64decode from datetime import datetime, timezone -from .base import AnymailBaseWebhookView from ..exceptions import AnymailConfigurationError from ..inbound import AnymailInboundMessage -from ..signals import inbound, tracking, AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason +from ..signals import ( + AnymailInboundEvent, + AnymailTrackingEvent, + EventType, + RejectReason, + inbound, + tracking, +) from ..utils import get_anymail_setting +from .base import AnymailBaseWebhookView class SparkPostBaseWebhookView(AnymailBaseWebhookView): @@ -15,7 +22,7 @@ class SparkPostBaseWebhookView(AnymailBaseWebhookView): esp_name = "SparkPost" def parse_events(self, request): - raw_events = json.loads(request.body.decode('utf-8')) + raw_events = json.loads(request.body.decode("utf-8")) unwrapped_events = [self.unwrap_event(raw_event) for raw_event in raw_events] return [ self.esp_to_anymail_event(event_class, event, raw_event) @@ -30,17 +37,19 @@ class SparkPostBaseWebhookView(AnymailBaseWebhookView): Can return None, None, raw_event for SparkPost "ping" raw_event={'msys': {}} """ - event_classes = raw_event['msys'].keys() + event_classes = raw_event["msys"].keys() try: (event_class,) = event_classes - event = raw_event['msys'][event_class] + event = raw_event["msys"][event_class] except ValueError: # too many/not enough event_classes to unpack if len(event_classes) == 0: # Empty event (SparkPost sometimes sends as a "ping") event_class = event = None else: raise TypeError( - "Invalid SparkPost webhook event has multiple event classes: %r" % raw_event) from None + "Invalid SparkPost webhook event has multiple event classes: %r" + % raw_event + ) from None return event_class, event, raw_event def esp_to_anymail_event(self, event_class, event, raw_event): @@ -54,54 +63,54 @@ class SparkPostTrackingWebhookView(SparkPostBaseWebhookView): event_types = { # Map SparkPost event.type: Anymail normalized type - 'bounce': EventType.BOUNCED, - 'delivery': EventType.DELIVERED, - 'injection': EventType.QUEUED, - 'spam_complaint': EventType.COMPLAINED, - 'out_of_band': EventType.BOUNCED, - 'policy_rejection': EventType.REJECTED, - 'delay': EventType.DEFERRED, - 'click': EventType.CLICKED, - 'open': EventType.OPENED, - 'amp_click': EventType.CLICKED, - 'amp_open': EventType.OPENED, - 'generation_failure': EventType.FAILED, - 'generation_rejection': EventType.REJECTED, - 'list_unsubscribe': EventType.UNSUBSCRIBED, - 'link_unsubscribe': EventType.UNSUBSCRIBED, + "bounce": EventType.BOUNCED, + "delivery": EventType.DELIVERED, + "injection": EventType.QUEUED, + "spam_complaint": EventType.COMPLAINED, + "out_of_band": EventType.BOUNCED, + "policy_rejection": EventType.REJECTED, + "delay": EventType.DEFERRED, + "click": EventType.CLICKED, + "open": EventType.OPENED, + "amp_click": EventType.CLICKED, + "amp_open": EventType.OPENED, + "generation_failure": EventType.FAILED, + "generation_rejection": EventType.REJECTED, + "list_unsubscribe": EventType.UNSUBSCRIBED, + "link_unsubscribe": EventType.UNSUBSCRIBED, } # Additional event_types mapping when Anymail setting # SPARKPOST_TRACK_INITIAL_OPEN_AS_OPENED is enabled. initial_open_event_types = { - 'initial_open': EventType.OPENED, - 'amp_initial_open': EventType.OPENED, + "initial_open": EventType.OPENED, + "amp_initial_open": EventType.OPENED, } reject_reasons = { # Map SparkPost event.bounce_class: Anymail normalized reject reason. - # Can also supply (RejectReason, EventType) for bounce_class that affects our event_type. - # https://support.sparkpost.com/customer/portal/articles/1929896 - '1': RejectReason.OTHER, # Undetermined (response text could not be identified) - '10': RejectReason.INVALID, # Invalid Recipient - '20': RejectReason.BOUNCED, # Soft Bounce - '21': RejectReason.BOUNCED, # DNS Failure - '22': RejectReason.BOUNCED, # Mailbox Full - '23': RejectReason.BOUNCED, # Too Large - '24': RejectReason.TIMED_OUT, # Timeout - '25': RejectReason.BLOCKED, # Admin Failure (configured policies) - '30': RejectReason.BOUNCED, # Generic Bounce: No RCPT - '40': RejectReason.BOUNCED, # Generic Bounce: unspecified reasons - '50': RejectReason.BLOCKED, # Mail Block (by the receiver) - '51': RejectReason.SPAM, # Spam Block (by the receiver) - '52': RejectReason.SPAM, # Spam Content (by the receiver) - '53': RejectReason.OTHER, # Prohibited Attachment (by the receiver) - '54': RejectReason.BLOCKED, # Relaying Denied (by the receiver) - '60': (RejectReason.OTHER, EventType.AUTORESPONDED), # Auto-Reply/vacation - '70': RejectReason.BOUNCED, # Transient Failure - '80': (RejectReason.OTHER, EventType.SUBSCRIBED), # Subscribe - '90': (RejectReason.UNSUBSCRIBED, EventType.UNSUBSCRIBED), # Unsubscribe - '100': (RejectReason.OTHER, EventType.AUTORESPONDED), # Challenge-Response + # Can also supply (RejectReason, EventType) for bounce_class that affects our + # event_type. https://support.sparkpost.com/customer/portal/articles/1929896 + "1": RejectReason.OTHER, # Undetermined (response text could not be identified) + "10": RejectReason.INVALID, # Invalid Recipient + "20": RejectReason.BOUNCED, # Soft Bounce + "21": RejectReason.BOUNCED, # DNS Failure + "22": RejectReason.BOUNCED, # Mailbox Full + "23": RejectReason.BOUNCED, # Too Large + "24": RejectReason.TIMED_OUT, # Timeout + "25": RejectReason.BLOCKED, # Admin Failure (configured policies) + "30": RejectReason.BOUNCED, # Generic Bounce: No RCPT + "40": RejectReason.BOUNCED, # Generic Bounce: unspecified reasons + "50": RejectReason.BLOCKED, # Mail Block (by the receiver) + "51": RejectReason.SPAM, # Spam Block (by the receiver) + "52": RejectReason.SPAM, # Spam Content (by the receiver) + "53": RejectReason.OTHER, # Prohibited Attachment (by the receiver) + "54": RejectReason.BLOCKED, # Relaying Denied (by the receiver) + "60": (RejectReason.OTHER, EventType.AUTORESPONDED), # Auto-Reply/vacation + "70": RejectReason.BOUNCED, # Transient Failure + "80": (RejectReason.OTHER, EventType.SUBSCRIBED), # Subscribe + "90": (RejectReason.UNSUBSCRIBED, EventType.UNSUBSCRIBED), # Unsubscribe + "100": (RejectReason.OTHER, EventType.AUTORESPONDED), # Challenge-Response } def __init__(self, **kwargs): @@ -111,34 +120,43 @@ class SparkPostTrackingWebhookView(SparkPostBaseWebhookView): # other ESPs.) Handling "initial_open" is opt-in, to help avoid duplicate # "opened" events on the same first open. track_initial_open_as_opened = get_anymail_setting( - 'track_initial_open_as_opened', default=False, - esp_name=self.esp_name, kwargs=kwargs) + "track_initial_open_as_opened", + default=False, + esp_name=self.esp_name, + kwargs=kwargs, + ) if track_initial_open_as_opened: self.event_types = {**self.event_types, **self.initial_open_event_types} super().__init__(**kwargs) def esp_to_anymail_event(self, event_class, event, raw_event): - if event_class == 'relay_message': + if event_class == "relay_message": # This is an inbound event raise AnymailConfigurationError( "You seem to have set SparkPost's *inbound* relay webhook URL " - "to Anymail's SparkPost *tracking* webhook URL.") + "to Anymail's SparkPost *tracking* webhook URL." + ) - event_type = self.event_types.get(event['type'], EventType.UNKNOWN) + event_type = self.event_types.get(event["type"], EventType.UNKNOWN) try: - timestamp = datetime.fromtimestamp(int(event['timestamp']), tz=timezone.utc) + timestamp = datetime.fromtimestamp(int(event["timestamp"]), tz=timezone.utc) except (KeyError, TypeError, ValueError): timestamp = None try: - tag = event['campaign_id'] # not 'rcpt_tags' -- those don't come from sending a message + tag = event["campaign_id"] + # not "rcpt_tags" -- those don't come from sending a message tags = [tag] if tag else None except KeyError: tags = [] try: - reject_reason = self.reject_reasons.get(event['bounce_class'], RejectReason.OTHER) - try: # unpack (RejectReason, EventType) for reasons that change our event type + reject_reason = self.reject_reasons.get( + event["bounce_class"], RejectReason.OTHER + ) + try: + # unpack (RejectReason, EventType) + # for reasons that change our event type reject_reason, event_type = reject_reason except ValueError: pass @@ -148,16 +166,19 @@ class SparkPostTrackingWebhookView(SparkPostBaseWebhookView): return AnymailTrackingEvent( event_type=event_type, timestamp=timestamp, - message_id=event.get('transmission_id', None), # not 'message_id' -- see SparkPost backend - event_id=event.get('event_id', None), - recipient=event.get('raw_rcpt_to', None), # preserves email case (vs. 'rcpt_to') + # use transmission_id, not message_id -- see SparkPost backend + message_id=event.get("transmission_id", None), + event_id=event.get("event_id", None), + # raw_rcpt_to preserves email case (vs. rcpt_to) + recipient=event.get("raw_rcpt_to", None), reject_reason=reject_reason, - mta_response=event.get('raw_reason', None), + mta_response=event.get("raw_reason", None), # description=???, tags=tags, - metadata=event.get('rcpt_meta', None) or {}, # message + recipient metadata - click_url=event.get('target_link_url', None), - user_agent=event.get('user_agent', None), + # metadata includes message + recipient metadata + metadata=event.get("rcpt_meta", None) or {}, + click_url=event.get("target_link_url", None), + user_agent=event.get("user_agent", None), esp_event=raw_event, ) @@ -168,29 +189,35 @@ class SparkPostInboundWebhookView(SparkPostBaseWebhookView): signal = inbound def esp_to_anymail_event(self, event_class, event, raw_event): - if event_class != 'relay_message': + if event_class != "relay_message": # This is not an inbound event raise AnymailConfigurationError( "You seem to have set SparkPost's *tracking* webhook URL " - "to Anymail's SparkPost *inbound* relay webhook URL.") + "to Anymail's SparkPost *inbound* relay webhook URL." + ) - if event['protocol'] != 'smtp': + if event["protocol"] != "smtp": raise AnymailConfigurationError( - "You cannot use Anymail's webhooks for SparkPost '{protocol}' relay events. " - "Anymail only handles the 'smtp' protocol".format(protocol=event['protocol'])) + "You cannot use Anymail's webhooks for SparkPost '{protocol}' relay" + " events. Anymail only handles the 'smtp' protocol".format( + protocol=event["protocol"] + ) + ) - raw_mime = event['content']['email_rfc822'] - if event['content']['email_rfc822_is_base64']: - raw_mime = b64decode(raw_mime).decode('utf-8') + raw_mime = event["content"]["email_rfc822"] + if event["content"]["email_rfc822_is_base64"]: + raw_mime = b64decode(raw_mime).decode("utf-8") message = AnymailInboundMessage.parse_raw_mime(raw_mime) - message.envelope_sender = event.get('msg_from', None) - message.envelope_recipient = event.get('rcpt_to', None) + message.envelope_sender = event.get("msg_from", None) + message.envelope_recipient = event.get("rcpt_to", None) return AnymailInboundEvent( event_type=EventType.INBOUND, - timestamp=None, # SparkPost does not provide a relay event timestamp - event_id=None, # SparkPost does not provide an idempotent id for relay events + # SparkPost does not provide a relay event timestamp + timestamp=None, + # SparkPost does not provide an idempotent id for relay events + event_id=None, esp_event=raw_event, message=message, ) diff --git a/docs/_static/anymail-config.js b/docs/_static/anymail-config.js index 7dd31a9..8ffb8a3 100644 --- a/docs/_static/anymail-config.js +++ b/docs/_static/anymail-config.js @@ -1,4 +1,4 @@ window.RATETHEDOCS_OPTIONS = { contactLink: "/help/#contact", - privacyLink: "/docs_privacy/" + privacyLink: "/docs_privacy/", }; diff --git a/docs/_static/anymail-theme.css b/docs/_static/anymail-theme.css index bc23c29..6d7a154 100644 --- a/docs/_static/anymail-theme.css +++ b/docs/_static/anymail-theme.css @@ -1,6 +1,5 @@ /* Anymail modifications to sphinx-rtd-theme styles */ - /* Sticky table first column (used for ESP feature matrix) */ table.sticky-left td:first-of-type, table.sticky-left th:first-of-type { @@ -18,7 +17,6 @@ table.sticky-left th:first-of-type[colspan] > p { left: 17px; /* (.wy-table $table-padding-size) + (docutils border="1" in html) */ } - /* Show code cross-reference links as clickable $link-color (blue). Sphinx-rtd-theme usually wants `.rst-content a code` to be $link-color [1], but has @@ -44,13 +42,12 @@ table.sticky-left th:first-of-type[colspan] > p { color: inherit; } - /* Inline search forms (Anymail addition) */ .anymail-inline-search-form { margin-top: -1em; margin-bottom: 1em; } -.anymail-inline-search-form input[type=search] { +.anymail-inline-search-form input[type="search"] { width: 280px; max-width: 100%; border-radius: 50px; diff --git a/docs/_static/version-alert.js b/docs/_static/version-alert.js index 0c98bc1..c7f9803 100644 --- a/docs/_static/version-alert.js +++ b/docs/_static/version-alert.js @@ -4,31 +4,35 @@ // but adds admonition for the "latest" version -- which is (unreleased) main branch. function warnOnLatestVersion() { - // The warning text and link is really specific to RTD hosting, // so we can just check their global to determine version: if (!window.READTHEDOCS_DATA || window.READTHEDOCS_DATA.version !== "latest") { - return; // not latest, or not on RTD + return; // not latest, or not on RTD } - var warning = document.createElement('div'); - warning.setAttribute('class', 'admonition danger'); - warning.innerHTML = "

Note

" + + var warning = document.createElement("div"); + warning.setAttribute("class", "admonition danger"); + warning.innerHTML = + "

Note

" + "

" + "This document is for an unreleased development version. " + "Documentation is available for the current stable release, " + "or for older versions through the “v:” menu at bottom left." + "

"; - warning.querySelector('a').href = window.location.pathname.replace('/latest', '/stable'); + warning.querySelector("a").href = window.location.pathname.replace( + "/latest", + "/stable" + ); - var parent = document.querySelector('div.body') - || document.querySelector('div.document') - || document.body; + var parent = + document.querySelector("div.body") || + document.querySelector("div.document") || + document.body; parent.insertBefore(warning, parent.firstChild); } -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', warnOnLatestVersion); +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", warnOnLatestVersion); } else { warnOnLatestVersion(); } diff --git a/docs/conf.py b/docs/conf.py index ac36fa5..246ecef 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -13,7 +13,7 @@ import os import sys from pathlib import Path -ON_READTHEDOCS = os.environ.get('READTHEDOCS', None) == 'True' +ON_READTHEDOCS = os.environ.get("READTHEDOCS", None) == "True" DOCS_PATH = Path(__file__).parent PROJECT_ROOT_PATH = DOCS_PATH.parent @@ -27,35 +27,35 @@ sys.path.insert(0, PROJECT_ROOT_PATH.resolve()) __version__ = "UNSET" __minor_version__ = "UNSET" version_path = PROJECT_ROOT_PATH / "anymail/_version.py" -code = compile(version_path.read_text(), version_path, 'exec') +code = compile(version_path.read_text(), version_path, "exec") exec(code) # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. -needs_sphinx = '1.0' +needs_sphinx = "1.0" # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.intersphinx', 'sphinx.ext.extlinks'] +extensions = ["sphinx.ext.intersphinx", "sphinx.ext.extlinks"] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix of source filenames. -source_suffix = '.rst' +source_suffix = ".rst" # The encoding of source files. # source_encoding = 'utf-8-sig' # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = 'Anymail' +project = "Anymail" # noinspection PyShadowingBuiltins -copyright = 'Anymail contributors' +copyright = "Anymail contributors" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -78,7 +78,7 @@ release = __version__ # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ['_build'] +exclude_patterns = ["_build"] # The reST default role (used for this markup: `text`) to use for all documents. default_role = "py:obj" @@ -95,7 +95,7 @@ default_role = "py:obj" # show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] @@ -107,7 +107,8 @@ pygments_style = 'sphinx' # a list of builtin themes. if not ON_READTHEDOCS: # only import and set the theme if we're building docs locally import sphinx_rtd_theme # this seems to come with sphinx; if not, pip install it - html_theme = 'sphinx_rtd_theme' + + html_theme = "sphinx_rtd_theme" html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] # else readthedocs.org uses their theme by default (and specifying it here breaks them) @@ -138,7 +139,7 @@ if not ON_READTHEDOCS: # only import and set the theme if we're building docs l # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. @@ -187,7 +188,7 @@ html_static_path = ['_static'] # html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = 'Anymaildoc' +htmlhelp_basename = "Anymaildoc" # -- Options for LaTeX output -------------------------------------------------- @@ -195,10 +196,8 @@ htmlhelp_basename = 'Anymaildoc' latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. # 'preamble': '', } @@ -206,8 +205,7 @@ latex_elements = { # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'Anymail.tex', 'Anymail Documentation', - 'Anymail contributors', 'manual'), + ("index", "Anymail.tex", "Anymail Documentation", "Anymail contributors", "manual"), ] # The name of an image file (relative to this directory) to place at the top of @@ -235,10 +233,7 @@ latex_documents = [ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - ('index', 'anymail', 'Anymail Documentation', - ['Anymail contributors'], 1) -] +man_pages = [("index", "anymail", "Anymail Documentation", ["Anymail contributors"], 1)] # If true, show URL addresses after external links. # man_show_urls = False @@ -250,9 +245,15 @@ man_pages = [ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'Anymail', 'Anymail Documentation', - 'Anymail contributors', 'Anymail', 'Multi-ESP transactional email for Django.', - 'Miscellaneous'), + ( + "index", + "Anymail", + "Anymail Documentation", + "Anymail contributors", + "Anymail", + "Multi-ESP transactional email for Django.", + "Miscellaneous", + ), ] # Documents to append as an appendix to all manuals. @@ -267,16 +268,19 @@ texinfo_documents = [ # -- Options for extlinks --------------------------------------------------- extlinks = { - 'pypi': ('https://pypi.org/project/%s/', ''), + "pypi": ("https://pypi.org/project/%s/", ""), } # -- Options for Intersphinx ------------------------------------------------ intersphinx_mapping = { - 'python': ('https://docs.python.org/3.10', None), - 'django': ('https://docs.djangoproject.com/en/stable/', 'https://docs.djangoproject.com/en/stable/_objects/'), - 'requests': ('https://requests.readthedocs.io/en/stable/', None), - 'urllib3': ('https://urllib3.readthedocs.io/en/stable/', None), + "python": ("https://docs.python.org/3.10", None), + "django": ( + "https://docs.djangoproject.com/en/stable/", + "https://docs.djangoproject.com/en/stable/_objects/", + ), + "requests": ("https://requests.readthedocs.io/en/stable/", None), + "urllib3": ("https://urllib3.readthedocs.io/en/stable/", None), } @@ -289,7 +293,8 @@ def setup(app): app.add_js_file("version-alert.js", **{"async": "async"}) app.add_js_file("https://unpkg.com/rate-the-docs", **{"async": "async"}) - # Django-specific roles, from https://github.com/django/django/blob/master/docs/_ext/djangodocs.py: + # Django-specific roles, from + # https://github.com/django/django/blob/master/docs/_ext/djangodocs.py: app.add_crossref_type( directivename="setting", rolename="setting", @@ -298,12 +303,12 @@ def setup(app): app.add_crossref_type( directivename="templatetag", rolename="ttag", - indextemplate="pair: %s; template tag" + indextemplate="pair: %s; template tag", ) app.add_crossref_type( directivename="templatefilter", rolename="tfilter", - indextemplate="pair: %s; template filter" + indextemplate="pair: %s; template filter", ) app.add_crossref_type( directivename="fieldlookup", diff --git a/docs/sending/tracking.rst b/docs/sending/tracking.rst index b692a2e..55ab4b7 100644 --- a/docs/sending/tracking.rst +++ b/docs/sending/tracking.rst @@ -270,4 +270,3 @@ on your production server, in a hard-to-debug way. See Django's .. _listening to signals: https://docs.djangoproject.com/en/stable/topics/signals/#listening-to-signals - diff --git a/runtests.py b/runtests.py index f29dfc5..db2d01d 100755 --- a/runtests.py +++ b/runtests.py @@ -4,35 +4,37 @@ # or # runtests.py [tests.test_x tests.test_y.SomeTestCase ...] +import os import sys +import warnings import django -import os -import warnings from django.conf import settings from django.test.utils import get_runner def setup_and_run_tests(test_labels=None): """Discover and run project tests. Returns number of failures.""" - test_labels = test_labels or ['tests'] + test_labels = test_labels or ["tests"] - tags = envlist('ANYMAIL_ONLY_TEST') - exclude_tags = envlist('ANYMAIL_SKIP_TESTS') + tags = envlist("ANYMAIL_ONLY_TEST") + exclude_tags = envlist("ANYMAIL_SKIP_TESTS") # In automated testing, don't run live tests unless specifically requested - if envbool('CONTINUOUS_INTEGRATION') and not envbool('ANYMAIL_RUN_LIVE_TESTS'): - exclude_tags.append('live') + if envbool("CONTINUOUS_INTEGRATION") and not envbool("ANYMAIL_RUN_LIVE_TESTS"): + exclude_tags.append("live") if tags: print("Only running tests tagged: %r" % tags) if exclude_tags: print("Excluding tests tagged: %r" % exclude_tags) - warnings.simplefilter('default') # show DeprecationWarning and other default-ignored warnings + # show DeprecationWarning and other default-ignored warnings: + warnings.simplefilter("default") - os.environ['DJANGO_SETTINGS_MODULE'] = \ - 'tests.test_settings.settings_%d_%d' % django.VERSION[:2] + os.environ["DJANGO_SETTINGS_MODULE"] = ( + "tests.test_settings.settings_%d_%d" % django.VERSION[:2] + ) django.setup() TestRunner = get_runner(settings) @@ -54,12 +56,12 @@ def envbool(var, default=False): and `'false'` and similar string representations to `False`. """ # Adapted from the old :func:`~distutils.util.strtobool` - val = os.getenv(var, '').strip().lower() - if val == '': + val = os.getenv(var, "").strip().lower() + if val == "": return default - elif val in ('y', 'yes', 't', 'true', 'on', '1'): + elif val in ("y", "yes", "t", "true", "on", "1"): return True - elif val in ('n', 'no', 'f', 'false', 'off', '0'): + elif val in ("n", "no", "f", "false", "off", "0"): return False else: raise ValueError("invalid boolean value env[%r]=%r" % (var, val)) @@ -70,12 +72,12 @@ def envlist(var): Returns an empty list if variable is empty or not set. """ - val = [item.strip() for item in os.getenv(var, '').split(',')] - if val == ['']: + val = [item.strip() for item in os.getenv(var, "").split(",")] + if val == [""]: # "Splitting an empty string with a specified separator returns ['']" val = [] return val -if __name__ == '__main__': +if __name__ == "__main__": runtests(test_labels=sys.argv[1:]) diff --git a/setup.py b/setup.py index 60c1c41..d4405c8 100644 --- a/setup.py +++ b/setup.py @@ -2,14 +2,15 @@ import re from codecs import open # to use a consistent encoding from collections import OrderedDict from os import path + from setuptools import setup here = path.abspath(path.dirname(__file__)) # get versions from anymail/_version.py, # but without importing from anymail (which would break setup) -with open(path.join(here, "anymail/_version.py"), encoding='utf-8') as f: - code = compile(f.read(), "anymail/_version.py", 'exec') +with open(path.join(here, "anymail/_version.py"), encoding="utf-8") as f: + code = compile(f.read(), "anymail/_version.py", "exec") _version = {} exec(code, _version) version = _version["__version__"] # X.Y or X.Y.Z or X.Y.Z.dev1 etc. @@ -19,14 +20,21 @@ with open(path.join(here, "anymail/_version.py"), encoding='utf-8') as f: def long_description_from_readme(rst): # Freeze external links (on PyPI) to refer to this X.Y or X.Y.Z tag. # (This relies on tagging releases with 'vX.Y' or 'vX.Y.Z' in GitHub.) - rst = re.sub(r'(?<=branch[=:])main' # GitHub Actions build status: branch=main --> branch=vX.Y.Z - r'|(?<=/)stable' # ReadTheDocs links: /stable --> /vX.Y.Z - r'|(?<=version=)stable', # ReadTheDocs badge: version=stable --> version=vX.Y.Z - release_tag, rst) # (?<=...) is "positive lookbehind": must be there, but won't get replaced + rst = re.sub( + # (?<=...) is "positive lookbehind": must be there, but won't get replaced + # GitHub Actions build status: branch=main --> branch=vX.Y.Z: + r"(?<=branch[=:])main" + # ReadTheDocs links: /stable --> /vX.Y.Z: + r"|(?<=/)stable" + # ReadTheDocs badge: version=stable --> version=vX.Y.Z: + r"|(?<=version=)stable", + release_tag, + rst, + ) return rst -with open(path.join(here, 'README.rst'), encoding='utf-8') as f: +with open(path.join(here, "README.rst"), encoding="utf-8") as f: long_description = long_description_from_readme(f.read()) @@ -47,17 +55,22 @@ requirements_test = [] setup( name="django-anymail", version=version, - description='Django email backends and webhooks for Amazon SES, Mailgun, Mailjet, Mandrill, ' - 'Postal, Postmark, SendGrid, SendinBlue, and SparkPost', - keywords="Django, email, email backend, ESP, transactional mail, " - "Amazon SES, Mailgun, Mailjet, Mandrill, Postal, Postmark, SendGrid, SendinBlue, SparkPost", + description=( + "Django email backends and webhooks for Amazon SES, Mailgun, Mailjet," + " Mandrill, Postal, Postmark, SendGrid, SendinBlue, and SparkPost" + ), + keywords=( + "Django, email, email backend, ESP, transactional mail," + " Amazon SES, Mailgun, Mailjet, Mandrill, Postal, Postmark," + " SendGrid, SendinBlue, SparkPost" + ), author="Mike Edmunds and Anymail contributors", author_email="medmunds@gmail.com", url="https://github.com/anymail/django-anymail", license="BSD License", packages=["anymail"], zip_safe=False, - python_requires='>=3.6', + python_requires=">=3.6", install_requires=["django>=2.0", "requests>=2.4.3"], extras_require={ # This can be used if particular backends have unique dependencies. @@ -71,7 +84,8 @@ setup( "sendinblue": [], "sparkpost": [], "postal": ["cryptography"], - # Development/test-only requirements (install with python -m pip -e '.[dev,test]') + # Development/test-only requirements + # (install with python -m pip -e '.[dev,test]') "dev": requirements_dev, "test": requirements_test, }, @@ -103,10 +117,15 @@ setup( ], long_description=long_description, long_description_content_type="text/x-rst", - project_urls=OrderedDict([ - ("Documentation", "https://anymail.readthedocs.io/en/%s/" % release_tag), - ("Source", "https://github.com/anymail/django-anymail"), - ("Changelog", "https://anymail.readthedocs.io/en/%s/changelog/" % release_tag), - ("Tracker", "https://github.com/anymail/django-anymail/issues"), - ]), + project_urls=OrderedDict( + [ + ("Documentation", "https://anymail.readthedocs.io/en/%s/" % release_tag), + ("Source", "https://github.com/anymail/django-anymail"), + ( + "Changelog", + "https://anymail.readthedocs.io/en/%s/changelog/" % release_tag, + ), + ("Tracker", "https://github.com/anymail/django-anymail/issues"), + ] + ), ) diff --git a/tests/mock_requests_backend.py b/tests/mock_requests_backend.py index 1a050df..e63f54f 100644 --- a/tests/mock_requests_backend.py +++ b/tests/mock_requests_backend.py @@ -2,9 +2,9 @@ import json from io import BytesIO from unittest.mock import patch +import requests from django.core import mail from django.test import SimpleTestCase -import requests from anymail.exceptions import AnymailAPIError @@ -21,7 +21,15 @@ class RequestsBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase): class MockResponse(requests.Response): """requests.request return value mock sufficient for testing""" - def __init__(self, status_code=200, raw=b"RESPONSE", encoding='utf-8', reason=None, test_case=None): + + def __init__( + self, + status_code=200, + raw=b"RESPONSE", + encoding="utf-8", + reason=None, + test_case=None, + ): super().__init__() self.status_code = status_code self.encoding = encoding @@ -31,7 +39,7 @@ class RequestsBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase): @property def url(self): - return self.test_case.get_api_call_arg('url', required=False) + return self.test_case.get_api_call_arg("url", required=False) @url.setter def url(self, url): @@ -40,34 +48,45 @@ class RequestsBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase): def setUp(self): super().setUp() - self.patch_request = patch('requests.Session.request', autospec=True) + self.patch_request = patch("requests.Session.request", autospec=True) self.mock_request = self.patch_request.start() self.addCleanup(self.patch_request.stop) self.set_mock_response() - def set_mock_response(self, status_code=DEFAULT_STATUS_CODE, raw=UNSET, encoding='utf-8', reason=None): + def set_mock_response( + self, status_code=DEFAULT_STATUS_CODE, raw=UNSET, encoding="utf-8", reason=None + ): if raw is UNSET: raw = self.DEFAULT_RAW_RESPONSE - mock_response = self.MockResponse(status_code, raw=raw, encoding=encoding, reason=reason, test_case=self) + mock_response = self.MockResponse( + status_code, raw=raw, encoding=encoding, reason=reason, test_case=self + ) self.mock_request.return_value = mock_response return mock_response def assert_esp_called(self, url, method="POST"): """Verifies the (mock) ESP API was called on endpoint. - url can be partial, and is just checked against the end of the url requested" + url can be partial, and is just checked against the end of the url requested """ - # This assumes the last (or only) call to requests.Session.request is the API call of interest. + # This assumes the last (or only) call to requests.Session.request + # is the API call of interest. if self.mock_request.call_args is None: raise AssertionError("No ESP API was called") if method is not None: - actual_method = self.get_api_call_arg('method') + actual_method = self.get_api_call_arg("method") if actual_method != method: - self.fail("API was not called using %s. (%s was used instead.)" % (method, actual_method)) + self.fail( + "API was not called using %s. (%s was used instead.)" + % (method, actual_method) + ) if url is not None: - actual_url = self.get_api_call_arg('url') + actual_url = self.get_api_call_arg("url") if not actual_url.endswith(url): - self.fail("API was not called at %s\n(It was called at %s)" % (url, actual_url)) + self.fail( + "API was not called at %s\n(It was called at %s)" + % (url, actual_url) + ) def get_api_call_arg(self, kwarg, required=True): """Returns an argument passed to the mock ESP API. @@ -84,9 +103,24 @@ class RequestsBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase): try: # positional arg? This is the order of requests.Session.request params: - pos = ('method', 'url', 'params', 'data', 'headers', 'cookies', 'files', 'auth', - 'timeout', 'allow_redirects', 'proxies', 'hooks', 'stream', 'verify', 'cert', 'json', - ).index(kwarg) + pos = ( + "method", + "url", + "params", + "data", + "headers", + "cookies", + "files", + "auth", + "timeout", + "allow_redirects", + "proxies", + "hooks", + "stream", + "verify", + "cert", + "json", + ).index(kwarg) return args[pos] except (ValueError, IndexError): pass @@ -97,37 +131,38 @@ class RequestsBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase): def get_api_call_params(self, required=True): """Returns the query params sent to the mock ESP API.""" - return self.get_api_call_arg('params', required) + return self.get_api_call_arg("params", required) def get_api_call_data(self, required=True): """Returns the raw data sent to the mock ESP API.""" - return self.get_api_call_arg('data', required) + return self.get_api_call_arg("data", required) def get_api_call_json(self, required=True): """Returns the data sent to the mock ESP API, json-parsed""" - # could be either the data param (as json str) or the json param (needing formatting) - value = self.get_api_call_arg('data', required=False) + # could be either the data param (as json str) + # or the json param (needing formatting) + value = self.get_api_call_arg("data", required=False) if value is not None: return json.loads(value) else: - return self.get_api_call_arg('json', required) + return self.get_api_call_arg("json", required) def get_api_call_headers(self, required=True): """Returns the headers sent to the mock ESP API""" - return self.get_api_call_arg('headers', required) + return self.get_api_call_arg("headers", required) def get_api_call_files(self, required=True): """Returns the files sent to the mock ESP API""" - return self.get_api_call_arg('files', required) + return self.get_api_call_arg("files", required) def get_api_call_auth(self, required=True): """Returns the auth sent to the mock ESP API""" - return self.get_api_call_arg('auth', required) + return self.get_api_call_arg("auth", required) def get_api_prepared_request(self): """Returns the PreparedRequest that would have been sent""" (args, kwargs) = self.mock_request.call_args - kwargs.pop('timeout', None) # Session-only param + kwargs.pop("timeout", None) # Session-only param request = requests.Request(**kwargs) return request.prepare() @@ -144,10 +179,10 @@ class SessionSharingTestCases(RequestsBackendMockAPITestCase): - adding or overriding any tests as appropriate """ - def __init__(self, methodName='runTest'): + def __init__(self, methodName="runTest"): if self.__class__ is SessionSharingTestCases: # don't run these tests on the abstract base implementation - methodName = 'runNoTestsInBaseClass' + methodName = "runNoTestsInBaseClass" super().__init__(methodName) def runNoTestsInBaseClass(self): @@ -155,15 +190,15 @@ class SessionSharingTestCases(RequestsBackendMockAPITestCase): def setUp(self): super().setUp() - self.patch_close = patch('requests.Session.close', autospec=True) + self.patch_close = patch("requests.Session.close", autospec=True) self.mock_close = self.patch_close.start() self.addCleanup(self.patch_close.stop) def test_connection_sharing(self): """RequestsBackend reuses one requests session when sending multiple messages""" datatuple = ( - ('Subject 1', 'Body 1', 'from@example.com', ['to@example.com']), - ('Subject 2', 'Body 2', 'from@example.com', ['to@example.com']), + ("Subject 1", "Body 1", "from@example.com", ["to@example.com"]), + ("Subject 2", "Body 2", "from@example.com", ["to@example.com"]), ) mail.send_mass_mail(datatuple) self.assertEqual(self.mock_request.call_count, 2) @@ -176,11 +211,23 @@ class SessionSharingTestCases(RequestsBackendMockAPITestCase): """Calling code can created long-lived connection that it opens and closes""" connection = mail.get_connection() connection.open() - mail.send_mail('Subject 1', 'body', 'from@example.com', ['to@example.com'], connection=connection) + mail.send_mail( + "Subject 1", + "body", + "from@example.com", + ["to@example.com"], + connection=connection, + ) session1 = self.mock_request.call_args[0] self.assertEqual(self.mock_close.call_count, 0) # shouldn't be closed yet - mail.send_mail('Subject 2', 'body', 'from@example.com', ['to@example.com'], connection=connection) + mail.send_mail( + "Subject 2", + "body", + "from@example.com", + ["to@example.com"], + connection=connection, + ) self.assertEqual(self.mock_close.call_count, 0) # still shouldn't be closed session2 = self.mock_request.call_args[0] self.assertEqual(session1, session2) # should have reused same session @@ -191,13 +238,18 @@ class SessionSharingTestCases(RequestsBackendMockAPITestCase): def test_session_closed_after_exception(self): self.set_mock_response(status_code=500) with self.assertRaises(AnymailAPIError): - mail.send_mail('Subject', 'Message', 'from@example.com', ['to@example.com']) + mail.send_mail("Subject", "Message", "from@example.com", ["to@example.com"]) self.assertEqual(self.mock_close.call_count, 1) def test_session_closed_after_fail_silently_exception(self): self.set_mock_response(status_code=500) - sent = mail.send_mail('Subject', 'Message', 'from@example.com', ['to@example.com'], - fail_silently=True) + sent = mail.send_mail( + "Subject", + "Message", + "from@example.com", + ["to@example.com"], + fail_silently=True, + ) self.assertEqual(sent, 0) self.assertEqual(self.mock_close.call_count, 1) @@ -206,8 +258,13 @@ class SessionSharingTestCases(RequestsBackendMockAPITestCase): connection.open() self.set_mock_response(status_code=500) with self.assertRaises(AnymailAPIError): - mail.send_mail('Subject', 'Message', 'from@example.com', ['to@example.com'], - connection=connection) + mail.send_mail( + "Subject", + "Message", + "from@example.com", + ["to@example.com"], + connection=connection, + ) self.assertEqual(self.mock_close.call_count, 0) # wait for us to close it connection.close() diff --git a/tests/test_amazon_ses_backend.py b/tests/test_amazon_ses_backend.py index ab60c20..897a14f 100644 --- a/tests/test_amazon_ses_backend.py +++ b/tests/test_amazon_ses_backend.py @@ -9,44 +9,56 @@ from django.test import SimpleTestCase, override_settings, tag from anymail.exceptions import AnymailAPIError, AnymailUnsupportedFeature from anymail.inbound import AnymailInboundMessage -from anymail.message import attach_inline_image_file, AnymailMessage -from .utils import AnymailTestMixin, SAMPLE_IMAGE_FILENAME, sample_image_content, sample_image_path +from anymail.message import AnymailMessage, attach_inline_image_file + +from .utils import ( + SAMPLE_IMAGE_FILENAME, + AnymailTestMixin, + sample_image_content, + sample_image_path, +) -@tag('amazon_ses') -@override_settings(EMAIL_BACKEND='anymail.backends.amazon_ses.EmailBackend') +@tag("amazon_ses") +@override_settings(EMAIL_BACKEND="anymail.backends.amazon_ses.EmailBackend") class AmazonSESBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase): """TestCase that uses the Amazon SES EmailBackend with a mocked boto3 client""" def setUp(self): super().setUp() - # Mock boto3.session.Session().client('ses').send_raw_email (and any other client operations) - # (We could also use botocore.stub.Stubber, but mock works well with our test structure) - self.patch_boto3_session = patch('anymail.backends.amazon_ses.boto3.session.Session', autospec=True) + # Mock boto3.session.Session().client('ses').send_raw_email (and any other + # client operations). (We could also use botocore.stub.Stubber, but mock works + # well with our test structure.) + self.patch_boto3_session = patch( + "anymail.backends.amazon_ses.boto3.session.Session", autospec=True + ) self.mock_session = self.patch_boto3_session.start() # boto3.session.Session self.addCleanup(self.patch_boto3_session.stop) - self.mock_client = self.mock_session.return_value.client # boto3.session.Session().client - self.mock_client_instance = self.mock_client.return_value # boto3.session.Session().client('ses', ...) + #: boto3.session.Session().client + self.mock_client = self.mock_session.return_value.client + #: boto3.session.Session().client('ses', ...) + self.mock_client_instance = self.mock_client.return_value self.set_mock_response() # Simple message useful for many tests - self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', - 'from@example.com', ['to@example.com']) + self.message = mail.EmailMultiAlternatives( + "Subject", "Text Body", "from@example.com", ["to@example.com"] + ) DEFAULT_SEND_RESPONSE = { - 'MessageId': '1111111111111111-bbbbbbbb-3333-7777-aaaa-eeeeeeeeeeee-000000', - 'ResponseMetadata': { - 'RequestId': 'aaaaaaaa-2222-1111-8888-bbbb3333bbbb', - 'HTTPStatusCode': 200, - 'HTTPHeaders': { - 'x-amzn-requestid': 'aaaaaaaa-2222-1111-8888-bbbb3333bbbb', - 'content-type': 'text/xml', - 'content-length': '338', - 'date': 'Sat, 17 Mar 2018 03:33:33 GMT' + "MessageId": "1111111111111111-bbbbbbbb-3333-7777-aaaa-eeeeeeeeeeee-000000", + "ResponseMetadata": { + "RequestId": "aaaaaaaa-2222-1111-8888-bbbb3333bbbb", + "HTTPStatusCode": 200, + "HTTPHeaders": { + "x-amzn-requestid": "aaaaaaaa-2222-1111-8888-bbbb3333bbbb", + "content-type": "text/xml", + "content-length": "338", + "date": "Sat, 17 Mar 2018 03:33:33 GMT", }, - 'RetryAttempts': 0 - } + "RetryAttempts": 0, + }, } def set_mock_response(self, response=None, operation_name="send_raw_email"): @@ -56,15 +68,20 @@ class AmazonSESBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase): def set_mock_failure(self, response, operation_name="send_raw_email"): from botocore.exceptions import ClientError + mock_operation = getattr(self.mock_client_instance, operation_name) - mock_operation.side_effect = ClientError(response, operation_name=operation_name) + mock_operation.side_effect = ClientError( + response, operation_name=operation_name + ) def get_session_params(self): if self.mock_session.call_args is None: raise AssertionError("boto3 Session was not created") (args, kwargs) = self.mock_session.call_args if args: - raise AssertionError("boto3 Session created with unexpected positional args %r" % args) + raise AssertionError( + "boto3 Session created with unexpected positional args %r" % args + ) return kwargs def get_client_params(self, service="ses"): @@ -76,9 +93,13 @@ class AmazonSESBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase): raise AssertionError("boto3 client was not created") (args, kwargs) = self.mock_client.call_args if len(args) != 1: - raise AssertionError("boto3 client created with unexpected positional args %r" % args) + raise AssertionError( + "boto3 client created with unexpected positional args %r" % args + ) if args[0] != service: - raise AssertionError("boto3 client created with service %r, not %r" % (args[0], service)) + raise AssertionError( + "boto3 client created with service %r, not %r" % (args[0], service) + ) return kwargs def get_send_params(self, operation_name="send_raw_email"): @@ -95,8 +116,12 @@ class AmazonSESBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase): def get_sent_message(self): """Returns a parsed version of the send_raw_email RawMessage.Data param""" - params = self.get_send_params(operation_name="send_raw_email") # (other operations don't have raw mime param) - raw_mime = params['RawMessage']['Data'] + + params = self.get_send_params( + operation_name="send_raw_email" + # (other operations don't have raw mime param) + ) + raw_mime = params["RawMessage"]["Data"] parsed = AnymailInboundMessage.parse_raw_mime_bytes(raw_mime) return parsed @@ -106,25 +131,30 @@ class AmazonSESBackendMockAPITestCase(AnymailTestMixin, SimpleTestCase): raise AssertionError(msg or "ESP API was called and shouldn't have been") -@tag('amazon_ses') +@tag("amazon_ses") class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase): """Test backend support for Django standard email features""" def test_send_mail(self): """Test basic API for simple send""" - mail.send_mail('Subject here', 'Here is the message.', - 'from@example.com', ['to@example.com'], fail_silently=False) + mail.send_mail( + "Subject here", + "Here is the message.", + "from@example.com", + ["to@example.com"], + fail_silently=False, + ) params = self.get_send_params() # send_raw_email takes a fully-formatted MIME message. # This is a simple (if inexact) way to check for expected headers and body: - raw_mime = params['RawMessage']['Data'] + raw_mime = params["RawMessage"]["Data"] self.assertIsInstance(raw_mime, bytes) # SendRawEmail expects Data as bytes self.assertIn(b"\nFrom: from@example.com\n", raw_mime) self.assertIn(b"\nTo: to@example.com\n", raw_mime) self.assertIn(b"\nSubject: Subject here\n", raw_mime) self.assertIn(b"\n\nHere is the message", raw_mime) # Destinations must include all recipients: - self.assertEqual(params['Destinations'], ['to@example.com']) + self.assertEqual(params["Destinations"], ["to@example.com"]) # Since the SES backend generates the MIME message using Django's # EmailMessage.message().to_string(), there's not really a need @@ -133,18 +163,24 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase): # Instead, just spot-check a few things... def test_destinations(self): - self.message.to = ['to1@example.com', '"Recipient, second" '] - self.message.cc = ['cc1@example.com', 'Also cc '] - self.message.bcc = ['bcc1@example.com', 'BCC 2 '] + self.message.to = ["to1@example.com", '"Recipient, second" '] + self.message.cc = ["cc1@example.com", "Also cc "] + self.message.bcc = ["bcc1@example.com", "BCC 2 "] self.message.send() params = self.get_send_params() - self.assertEqual(params['Destinations'], [ - 'to1@example.com', '"Recipient, second" ', - 'cc1@example.com', 'Also cc ', - 'bcc1@example.com', 'BCC 2 ', - ]) + self.assertEqual( + params["Destinations"], + [ + "to1@example.com", + '"Recipient, second" ', + "cc1@example.com", + "Also cc ", + "bcc1@example.com", + "BCC 2 ", + ], + ) # Bcc's shouldn't appear in the message itself: - self.assertNotIn(b'bcc', params['RawMessage']['Data']) + self.assertNotIn(b"bcc", params["RawMessage"]["Data"]) def test_non_ascii_headers(self): self.message.subject = "Thử tin nhắn" # utf-8 in subject header @@ -152,24 +188,35 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase): self.message.cc = ["cc@thư.example.com"] # utf-8 in domain self.message.send() params = self.get_send_params() - raw_mime = params['RawMessage']['Data'] + raw_mime = params["RawMessage"]["Data"] # Non-ASCII headers must use MIME encoded-word syntax: self.assertIn(b"\nSubject: =?utf-8?b?VGjhu60gdGluIG5o4bqvbg==?=\n", raw_mime) # Non-ASCII display names as well: - self.assertIn(b"\nTo: =?utf-8?b?TmfGsOG7nWkgbmjhuq1u?= \n", raw_mime) + self.assertIn( + b"\nTo: =?utf-8?b?TmfGsOG7nWkgbmjhuq1u?= \n", raw_mime + ) # Non-ASCII address domains must use Punycode: self.assertIn(b"\nCc: cc@xn--th-e0a.example.com\n", raw_mime) - # SES doesn't support non-ASCII in the username@ part (RFC 6531 "SMTPUTF8" extension) + # SES doesn't support non-ASCII in the username@ part + # (RFC 6531 "SMTPUTF8" extension) # Destinations must include all recipients: - self.assertEqual(params['Destinations'], [ - '=?utf-8?b?TmfGsOG7nWkgbmjhuq1u?= ', - 'cc@xn--th-e0a.example.com']) + self.assertEqual( + params["Destinations"], + [ + "=?utf-8?b?TmfGsOG7nWkgbmjhuq1u?= ", + "cc@xn--th-e0a.example.com", + ], + ) def test_attachments(self): - text_content = "• Item one\n• Item two\n• Item three" # those are \u2022 bullets ("\N{BULLET}") - self.message.attach(filename="Une pièce jointe.txt", # utf-8 chars in filename - content=text_content, mimetype="text/plain") + # These are \u2022 bullets ("\N{BULLET}") below: + text_content = "• Item one\n• Item two\n• Item three" + self.message.attach( + filename="Une pièce jointe.txt", # utf-8 chars in filename + content=text_content, + mimetype="text/plain", + ) # Should guess mimetype if not provided... png_content = b"PNG\xb4 pretend this is the contents of a png file" @@ -177,7 +224,7 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase): # Should work with a MIMEBase object (also tests no filename)... pdf_content = b"PDF\xb4 pretend this is valid pdf params" - mimeattachment = MIMEApplication(pdf_content, 'pdf') # application/pdf + mimeattachment = MIMEApplication(pdf_content, "pdf") # application/pdf mimeattachment["Content-Disposition"] = "attachment" self.message.attach(mimeattachment) @@ -192,7 +239,8 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase): self.assertEqual(attachments[0].get_content_text(), text_content) self.assertEqual(attachments[1].get_content_type(), "image/png") - self.assertEqual(attachments[1].get_content_disposition(), "attachment") # not inline + # not inline: + self.assertEqual(attachments[1].get_content_disposition(), "attachment") self.assertEqual(attachments[1].get_filename(), "test.png") self.assertEqual(attachments[1].get_content_bytes(), png_content) @@ -206,7 +254,9 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase): image_data = sample_image_content(image_filename) cid = attach_inline_image_file(self.message, image_path, domain="example.com") - html_content = '

This has an inline image.

' % cid + html_content = ( + '

This has an inline image.

' % cid + ) self.message.attach_alternative(html_content, "text/html") self.message.send() @@ -222,8 +272,8 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase): # Make sure neither the html nor the inline image is treated as an attachment: params = self.get_send_params() - raw_mime = params['RawMessage']['Data'] - self.assertNotIn(b'\nContent-Disposition: attachment', raw_mime) + raw_mime = params["RawMessage"]["Data"] + self.assertNotIn(b"\nContent-Disposition: attachment", raw_mime) def test_multiple_html_alternatives(self): # Multiple alternatives *are* allowed @@ -231,32 +281,41 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase): self.message.attach_alternative("

And so is second

", "text/html") self.message.send() params = self.get_send_params() - raw_mime = params['RawMessage']['Data'] - # just check the alternative smade it into the message (assume that Django knows how to format them properly) - self.assertIn(b'\n\n

First html is OK

\n', raw_mime) - self.assertIn(b'\n\n

And so is second

\n', raw_mime) + raw_mime = params["RawMessage"]["Data"] + # just check the alternative smade it into the message + # (assume that Django knows how to format them properly) + self.assertIn(b"\n\n

First html is OK

\n", raw_mime) + self.assertIn(b"\n\n

And so is second

\n", raw_mime) def test_alternative(self): # Non-HTML alternatives *are* allowed self.message.attach_alternative('{"is": "allowed"}', "application/json") self.message.send() params = self.get_send_params() - raw_mime = params['RawMessage']['Data'] - # just check the alternative made it into the message (assume that Django knows how to format it properly) + raw_mime = params["RawMessage"]["Data"] + # just check the alternative made it into the message + # (assume that Django knows how to format it properly) self.assertIn(b"\nContent-Type: application/json\n", raw_mime) def test_multiple_from(self): - # Amazon allows multiple addresses in the From header, but must specify which is Source + # Amazon allows multiple addresses in the From header, + # but must specify which is Source self.message.from_email = "from1@example.com, from2@example.com" self.message.send() params = self.get_send_params() - raw_mime = params['RawMessage']['Data'] + raw_mime = params["RawMessage"]["Data"] self.assertIn(b"\nFrom: from1@example.com, from2@example.com\n", raw_mime) - self.assertEqual(params['Source'], "from1@example.com") + self.assertEqual(params["Source"], "from1@example.com") def test_commas_in_subject(self): - """There used to be a Python email header bug that added unwanted spaces after commas in long subjects""" - self.message.subject = "100,000,000 isn't a number you'd really want to break up in this email subject, right?" + """ + There used to be a Python email header bug that added unwanted spaces + after commas in long subjects + """ + self.message.subject = ( + "100,000,000 isn't a number you'd really want" + " to break up in this email subject, right?" + ) self.message.send() sent_message = self.get_sent_message() self.assertEqual(sent_message["Subject"], self.message.subject) @@ -270,36 +329,41 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase): sent_message = self.get_sent_message() # Make sure none of the text parts use `Content-Transfer-Encoding: 8bit`. - # (Technically, either quoted-printable or base64 would be OK, but base64 text parts - # have a reputation for triggering spam filters, so just require quoted-printable.) + # (Technically, either quoted-printable or base64 would be OK, but base64 text + # parts have a reputation for triggering spam filters, so just require + # quoted-printable.) text_part_encodings = [ (part.get_content_type(), part["Content-Transfer-Encoding"]) for part in sent_message.walk() - if part.get_content_maintype() == "text"] - self.assertEqual(text_part_encodings, [ - ("text/plain", "quoted-printable"), - ("text/html", "quoted-printable"), - ]) + if part.get_content_maintype() == "text" + ] + self.assertEqual( + text_part_encodings, + [ + ("text/plain", "quoted-printable"), + ("text/html", "quoted-printable"), + ], + ) def test_api_failure(self): error_response = { - 'Error': { - 'Type': 'Sender', - 'Code': 'MessageRejected', - 'Message': 'Email address is not verified. The following identities failed ' - 'the check in region US-EAST-1: to@example.com' + "Error": { + "Type": "Sender", + "Code": "MessageRejected", + "Message": "Email address is not verified. The following identities" + " failed the check in region US-EAST-1: to@example.com", }, - 'ResponseMetadata': { - 'RequestId': 'aaaaaaaa-2222-1111-8888-bbbb3333bbbb', - 'HTTPStatusCode': 400, - 'HTTPHeaders': { - 'x-amzn-requestid': 'aaaaaaaa-2222-1111-8888-bbbb3333bbbb', - 'content-type': 'text/xml', - 'content-length': '277', - 'date': 'Sat, 17 Mar 2018 04:44:44 GMT' + "ResponseMetadata": { + "RequestId": "aaaaaaaa-2222-1111-8888-bbbb3333bbbb", + "HTTPStatusCode": 400, + "HTTPHeaders": { + "x-amzn-requestid": "aaaaaaaa-2222-1111-8888-bbbb3333bbbb", + "content-type": "text/xml", + "content-length": "277", + "date": "Sat, 17 Mar 2018 04:44:44 GMT", }, - 'RetryAttempts': 0 - } + "RetryAttempts": 0, + }, } self.set_mock_failure(error_response) @@ -307,35 +371,61 @@ class AmazonSESBackendStandardEmailTests(AmazonSESBackendMockAPITestCase): self.message.send() err = cm.exception # AWS error is included in Anymail message: - self.assertIn('Email address is not verified. The following identities failed ' - 'the check in region US-EAST-1: to@example.com', - str(err)) + self.assertIn( + "Email address is not verified. The following identities failed " + "the check in region US-EAST-1: to@example.com", + str(err), + ) # Raw AWS response is available on the exception: self.assertEqual(err.response, error_response) def test_api_failure_fail_silently(self): # Make sure fail_silently is respected - self.set_mock_failure({ - 'Error': {'Type': 'Sender', 'Code': 'InvalidParameterValue', 'Message': 'That is not allowed'}}) + self.set_mock_failure( + { + "Error": { + "Type": "Sender", + "Code": "InvalidParameterValue", + "Message": "That is not allowed", + } + } + ) sent = self.message.send(fail_silently=True) self.assertEqual(sent, 0) def test_prevents_header_injection(self): - # Since we build the raw MIME message, we're responsible for preventing header injection. - # django.core.mail.EmailMessage.message() implements most of that (for the SMTP backend); - # spot check some likely cases just to be sure... + # Since we build the raw MIME message, we're responsible for preventing header + # injection. django.core.mail.EmailMessage.message() implements most of that + # (for the SMTP backend); spot check some likely cases just to be sure... with self.assertRaises(BadHeaderError): - mail.send_mail('Subject\r\ninjected', 'Body', 'from@example.com', ['to@example.com']) + mail.send_mail( + "Subject\r\ninjected", "Body", "from@example.com", ["to@example.com"] + ) with self.assertRaises(BadHeaderError): - mail.send_mail('Subject', 'Body', '"Display-Name\nInjected" ', ['to@example.com']) + mail.send_mail( + "Subject", + "Body", + '"Display-Name\nInjected" ', + ["to@example.com"], + ) with self.assertRaises(BadHeaderError): - mail.send_mail('Subject', 'Body', 'from@example.com', ['"Display-Name\rInjected" ']) + mail.send_mail( + "Subject", + "Body", + "from@example.com", + ['"Display-Name\rInjected" '], + ) with self.assertRaises(BadHeaderError): - mail.EmailMessage('Subject', 'Body', 'from@example.com', ['to@example.com'], - headers={"X-Header": "custom header value\r\ninjected"}).send() + mail.EmailMessage( + "Subject", + "Body", + "from@example.com", + ["to@example.com"], + headers={"X-Header": "custom header value\r\ninjected"}, + ).send() -@tag('amazon_ses') +@tag("amazon_ses") class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase): """Test backend support for Anymail added features""" @@ -343,7 +433,7 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase): self.message.envelope_sender = "bounce-handler@bounces.example.com" self.message.send() params = self.get_send_params() - self.assertEqual(params['Source'], "bounce-handler@bounces.example.com") + self.assertEqual(params["Source"], "bounce-handler@bounces.example.com") def test_spoofed_to(self): # Amazon SES is one of the few ESPs that actually permits the To header @@ -352,22 +442,28 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase): self.message.extra_headers["To"] = "Spoofed " self.message.send() params = self.get_send_params() - raw_mime = params['RawMessage']['Data'] - self.assertEqual(params['Destinations'], ["Envelope "]) + raw_mime = params["RawMessage"]["Data"] + self.assertEqual(params["Destinations"], ["Envelope "]) self.assertIn(b"\nTo: Spoofed \n", raw_mime) self.assertNotIn(b"envelope-to@example.com", raw_mime) def test_metadata(self): # (that \n is a header-injection test) self.message.metadata = { - 'User ID': 12345, 'items': 'Correct horse,Battery,\nStaple', 'Cart-Total': '22.70'} + "User ID": 12345, + "items": "Correct horse,Battery,\nStaple", + "Cart-Total": "22.70", + } self.message.send() # Metadata is passed as JSON in a message header field: sent_message = self.get_sent_message() self.assertJSONEqual( sent_message["X-Metadata"], - '{"User ID": 12345, "items": "Correct horse,Battery,\\nStaple", "Cart-Total": "22.70"}') + '{"User ID": 12345,' + ' "items": "Correct horse,Battery,\\nStaple",' + ' "Cart-Total": "22.70"}', + ) def test_send_at(self): # Amazon SES does not support delayed sending @@ -381,8 +477,9 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase): # Tags are added as multiple X-Tag message headers: sent_message = self.get_sent_message() - self.assertCountEqual(sent_message.get_all("X-Tag"), - ["Transactional", "Cohort 12/2017"]) + self.assertCountEqual( + sent_message.get_all("X-Tag"), ["Transactional", "Cohort 12/2017"] + ) # Tags are *not* by default used as Amazon SES "Message Tags": params = self.get_send_params() @@ -390,17 +487,19 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase): @override_settings(ANYMAIL_AMAZON_SES_MESSAGE_TAG_NAME="Campaign") def test_amazon_message_tags(self): - """The Anymail AMAZON_SES_MESSAGE_TAG_NAME setting enables a single Message Tag""" + """ + The Anymail AMAZON_SES_MESSAGE_TAG_NAME setting enables a single Message Tag + """ self.message.tags = ["Welcome"] self.message.send() params = self.get_send_params() - self.assertEqual(params['Tags'], [{"Name": "Campaign", "Value": "Welcome"}]) + self.assertEqual(params["Tags"], [{"Name": "Campaign", "Value": "Welcome"}]) # Multiple Anymail tags are not supported when using this feature self.message.tags = ["Welcome", "Variation_A"] with self.assertRaisesMessage( AnymailUnsupportedFeature, - "multiple tags with the AMAZON_SES_MESSAGE_TAG_NAME setting" + "multiple tags with the AMAZON_SES_MESSAGE_TAG_NAME setting", ): self.message.send() @@ -411,7 +510,7 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase): self.message.track_clicks = True with self.assertRaisesMessage(AnymailUnsupportedFeature, "track_clicks"): self.message.send() - delattr(self.message, 'track_clicks') + delattr(self.message, "track_clicks") self.message.track_opens = True with self.assertRaisesMessage(AnymailUnsupportedFeature, "track_opens"): @@ -420,109 +519,165 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase): def test_merge_data(self): # Amazon SES only supports merging when using templates (see below) self.message.merge_data = {} - with self.assertRaisesMessage(AnymailUnsupportedFeature, "merge_data without template_id"): + with self.assertRaisesMessage( + AnymailUnsupportedFeature, "merge_data without template_id" + ): self.message.send() - delattr(self.message, 'merge_data') + delattr(self.message, "merge_data") - self.message.merge_global_data = {'group': "Users", 'site': "ExampleCo"} - with self.assertRaisesMessage(AnymailUnsupportedFeature, "global_merge_data without template_id"): + self.message.merge_global_data = {"group": "Users", "site": "ExampleCo"} + with self.assertRaisesMessage( + AnymailUnsupportedFeature, "global_merge_data without template_id" + ): self.message.send() - @override_settings(ANYMAIL_AMAZON_SES_MESSAGE_TAG_NAME="Campaign") # only way to use tags with template_id + @override_settings( + # only way to use tags with template_id: + ANYMAIL_AMAZON_SES_MESSAGE_TAG_NAME="Campaign" + ) def test_template(self): """With template_id, Anymail switches to SES SendBulkTemplatedEmail""" - # SendBulkTemplatedEmail uses a completely different API call and payload structure, - # so this re-tests a bunch of Anymail features that were handled differently above. - # (See test_amazon_ses_integration for a more realistic template example.) + # SendBulkTemplatedEmail uses a completely different API call and payload + # structure, so this re-tests a bunch of Anymail features that were handled + # differently above. (See test_amazon_ses_integration for a more realistic + # template example.) raw_response = { "Status": [ - {"Status": "Success", "MessageId": "1111111111111111-bbbbbbbb-3333-7777"}, + { + "Status": "Success", + "MessageId": "1111111111111111-bbbbbbbb-3333-7777", + }, {"Status": "AccountThrottled"}, ], - "ResponseMetadata": self.DEFAULT_SEND_RESPONSE["ResponseMetadata"] + "ResponseMetadata": self.DEFAULT_SEND_RESPONSE["ResponseMetadata"], } self.set_mock_response(raw_response, operation_name="send_bulk_templated_email") message = AnymailMessage( template_id="welcome_template", from_email='"Example, Inc." ', - to=['alice@example.com', '罗伯特 '], - cc=['cc@example.com'], - reply_to=['reply1@example.com', 'Reply 2 '], + to=["alice@example.com", "罗伯特 "], + cc=["cc@example.com"], + reply_to=["reply1@example.com", "Reply 2 "], merge_data={ - 'alice@example.com': {'name': "Alice", 'group': "Developers"}, - 'bob@example.com': {'name': "Bob"}, # and leave group undefined - 'nobody@example.com': {'name': "Not a recipient for this message"}, + "alice@example.com": {"name": "Alice", "group": "Developers"}, + "bob@example.com": {"name": "Bob"}, # and leave group undefined + "nobody@example.com": {"name": "Not a recipient for this message"}, }, - merge_global_data={'group': "Users", 'site': "ExampleCo"}, - tags=["WelcomeVariantA"], # (only with AMAZON_SES_MESSAGE_TAG_NAME when using template) + merge_global_data={"group": "Users", "site": "ExampleCo"}, + # (only works with AMAZON_SES_MESSAGE_TAG_NAME when using template): + tags=["WelcomeVariantA"], envelope_sender="bounces@example.com", - esp_extra={'SourceArn': "arn:aws:ses:us-east-1:123456789012:identity/example.com"}, + esp_extra={ + "SourceArn": "arn:aws:ses:us-east-1:123456789012:identity/example.com" + }, ) message.send() - self.assert_esp_not_called(operation_name="send_raw_email") # templates use a different API call... + # templates use a different API call... + self.assert_esp_not_called(operation_name="send_raw_email") params = self.get_send_params(operation_name="send_bulk_templated_email") - self.assertEqual(params['Template'], "welcome_template") - self.assertEqual(params['Source'], '"Example, Inc." ') - destinations = params['Destinations'] + self.assertEqual(params["Template"], "welcome_template") + self.assertEqual(params["Source"], '"Example, Inc." ') + destinations = params["Destinations"] self.assertEqual(len(destinations), 2) - self.assertEqual(destinations[0]['Destination'], - {"ToAddresses": ['alice@example.com'], - "CcAddresses": ['cc@example.com']}) - self.assertEqual(json.loads(destinations[0]['ReplacementTemplateData']), - {'name': "Alice", 'group': "Developers"}) - self.assertEqual(destinations[1]['Destination'], - {"ToAddresses": ['=?utf-8?b?572X5Lyv54m5?= '], # SES requires RFC2047 - "CcAddresses": ['cc@example.com']}) - self.assertEqual(json.loads(destinations[1]['ReplacementTemplateData']), - {'name': "Bob"}) - self.assertEqual(json.loads(params['DefaultTemplateData']), - {'group': "Users", 'site': "ExampleCo"}) - self.assertEqual(params['ReplyToAddresses'], - ['reply1@example.com', 'Reply 2 ']) - self.assertEqual(params['DefaultTags'], [{"Name": "Campaign", "Value": "WelcomeVariantA"}]) - self.assertEqual(params['ReturnPath'], "bounces@example.com") - self.assertEqual(params['SourceArn'], "arn:aws:ses:us-east-1:123456789012:identity/example.com") # esp_extra + self.assertEqual( + destinations[0]["Destination"], + {"ToAddresses": ["alice@example.com"], "CcAddresses": ["cc@example.com"]}, + ) + self.assertEqual( + json.loads(destinations[0]["ReplacementTemplateData"]), + {"name": "Alice", "group": "Developers"}, + ) + self.assertEqual( + destinations[1]["Destination"], + { + # SES requires RFC2047: + "ToAddresses": ["=?utf-8?b?572X5Lyv54m5?= "], + "CcAddresses": ["cc@example.com"], + }, + ) + self.assertEqual( + json.loads(destinations[1]["ReplacementTemplateData"]), {"name": "Bob"} + ) + self.assertEqual( + json.loads(params["DefaultTemplateData"]), + {"group": "Users", "site": "ExampleCo"}, + ) + self.assertEqual( + params["ReplyToAddresses"], + ["reply1@example.com", "Reply 2 "], + ) + self.assertEqual( + params["DefaultTags"], [{"Name": "Campaign", "Value": "WelcomeVariantA"}] + ) + self.assertEqual(params["ReturnPath"], "bounces@example.com") + self.assertEqual( + params["SourceArn"], + "arn:aws:ses:us-east-1:123456789012:identity/example.com", # esp_extra + ) self.assertEqual(message.anymail_status.status, {"queued", "failed"}) - self.assertEqual(message.anymail_status.message_id, - {"1111111111111111-bbbbbbbb-3333-7777", None}) # different for each recipient - self.assertEqual(message.anymail_status.recipients["alice@example.com"].status, "queued") - self.assertEqual(message.anymail_status.recipients["bob@example.com"].status, "failed") - self.assertEqual(message.anymail_status.recipients["alice@example.com"].message_id, - "1111111111111111-bbbbbbbb-3333-7777") - self.assertIsNone(message.anymail_status.recipients["bob@example.com"].message_id) + self.assertEqual( + # different for each recipient + message.anymail_status.message_id, + {"1111111111111111-bbbbbbbb-3333-7777", None}, + ) + self.assertEqual( + message.anymail_status.recipients["alice@example.com"].status, "queued" + ) + self.assertEqual( + message.anymail_status.recipients["bob@example.com"].status, "failed" + ) + self.assertEqual( + message.anymail_status.recipients["alice@example.com"].message_id, + "1111111111111111-bbbbbbbb-3333-7777", + ) + self.assertIsNone( + message.anymail_status.recipients["bob@example.com"].message_id + ) self.assertEqual(message.anymail_status.esp_response, raw_response) def test_template_unsupported(self): """A lot of options are not compatible with SendBulkTemplatedEmail""" - message = AnymailMessage(template_id="welcome_template", to=['to@example.com']) + message = AnymailMessage(template_id="welcome_template", to=["to@example.com"]) message.subject = "nope, can't change template subject" - with self.assertRaisesMessage(AnymailUnsupportedFeature, "overriding template subject"): + with self.assertRaisesMessage( + AnymailUnsupportedFeature, "overriding template subject" + ): message.send() message.subject = None message.body = "nope, can't change text body" - with self.assertRaisesMessage(AnymailUnsupportedFeature, "overriding template body content"): + with self.assertRaisesMessage( + AnymailUnsupportedFeature, "overriding template body content" + ): message.send() message.content_subtype = "html" - with self.assertRaisesMessage(AnymailUnsupportedFeature, "overriding template body content"): + with self.assertRaisesMessage( + AnymailUnsupportedFeature, "overriding template body content" + ): message.send() message.body = None message.attach("attachment.txt", "this is an attachment", "text/plain") - with self.assertRaisesMessage(AnymailUnsupportedFeature, "attachments with template"): + with self.assertRaisesMessage( + AnymailUnsupportedFeature, "attachments with template" + ): message.send() message.attachments = [] message.extra_headers = {"X-Custom": "header"} - with self.assertRaisesMessage(AnymailUnsupportedFeature, "extra_headers with template"): + with self.assertRaisesMessage( + AnymailUnsupportedFeature, "extra_headers with template" + ): message.send() message.extra_headers = {} message.metadata = {"meta": "data"} - with self.assertRaisesMessage(AnymailUnsupportedFeature, "metadata with template"): + with self.assertRaisesMessage( + AnymailUnsupportedFeature, "metadata with template" + ): message.send() message.metadata = None @@ -533,10 +688,13 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase): def test_send_anymail_message_without_template(self): # Make sure SendRawEmail is used for non-template_id messages - message = AnymailMessage(from_email="from@example.com", to=["to@example.com"], subject="subject") + message = AnymailMessage( + from_email="from@example.com", to=["to@example.com"], subject="subject" + ) message.send() self.assert_esp_not_called(operation_name="send_bulk_templated_email") - self.get_send_params(operation_name="send_raw_email") # fails if send_raw_email not called + # fails if send_raw_email not called: + self.get_send_params(operation_name="send_raw_email") def test_default_omits_options(self): """Make sure by default we don't send any ESP-specific options. @@ -547,46 +705,59 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase): """ self.message.send() params = self.get_send_params() - self.assertNotIn('ConfigurationSetName', params) - self.assertNotIn('DefaultTags', params) - self.assertNotIn('DefaultTemplateData', params) - self.assertNotIn('FromArn', params) - self.assertNotIn('Message', params) - self.assertNotIn('ReplyToAddresses', params) - self.assertNotIn('ReturnPath', params) - self.assertNotIn('ReturnPathArn', params) - self.assertNotIn('Source', params) - self.assertNotIn('SourceArn', params) - self.assertNotIn('Tags', params) - self.assertNotIn('Template', params) - self.assertNotIn('TemplateArn', params) - self.assertNotIn('TemplateData', params) + self.assertNotIn("ConfigurationSetName", params) + self.assertNotIn("DefaultTags", params) + self.assertNotIn("DefaultTemplateData", params) + self.assertNotIn("FromArn", params) + self.assertNotIn("Message", params) + self.assertNotIn("ReplyToAddresses", params) + self.assertNotIn("ReturnPath", params) + self.assertNotIn("ReturnPathArn", params) + self.assertNotIn("Source", params) + self.assertNotIn("SourceArn", params) + self.assertNotIn("Tags", params) + self.assertNotIn("Template", params) + self.assertNotIn("TemplateArn", params) + self.assertNotIn("TemplateData", params) sent_message = self.get_sent_message() - self.assertNotIn("X-Metadata", sent_message) # custom headers not added if not needed + # custom headers not added if not needed: + self.assertNotIn("X-Metadata", sent_message) self.assertNotIn("X-Tag", sent_message) def test_esp_extra(self): # Values in esp_extra are merged into the Amazon SES SendRawEmail parameters self.message.esp_extra = { - # E.g., if you've set up a configuration set that disables open/click tracking: - 'ConfigurationSetName': 'NoTrackingConfigurationSet', + # E.g., if you've set up a configuration set + # that disables open/click tracking: + "ConfigurationSetName": "NoTrackingConfigurationSet", } self.message.send() params = self.get_send_params() - self.assertEqual(params['ConfigurationSetName'], 'NoTrackingConfigurationSet') + self.assertEqual(params["ConfigurationSetName"], "NoTrackingConfigurationSet") def test_send_attaches_anymail_status(self): - """The anymail_status should be attached to the message when it is sent """ - msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],) + """The anymail_status should be attached to the message when it is sent""" + msg = mail.EmailMessage( + "Subject", + "Message", + "from@example.com", + ["to1@example.com"], + ) sent = msg.send() self.assertEqual(sent, 1) - self.assertEqual(msg.anymail_status.status, {'queued'}) - self.assertEqual(msg.anymail_status.message_id, - '1111111111111111-bbbbbbbb-3333-7777-aaaa-eeeeeeeeeeee-000000') - self.assertEqual(msg.anymail_status.recipients['to1@example.com'].status, 'queued') - self.assertEqual(msg.anymail_status.recipients['to1@example.com'].message_id, - '1111111111111111-bbbbbbbb-3333-7777-aaaa-eeeeeeeeeeee-000000') + self.assertEqual(msg.anymail_status.status, {"queued"}) + self.assertEqual( + msg.anymail_status.message_id, + "1111111111111111-bbbbbbbb-3333-7777-aaaa-eeeeeeeeeeee-000000", + ) + self.assertEqual( + msg.anymail_status.recipients["to1@example.com"].status, "queued" + ) + self.assertEqual( + msg.anymail_status.recipients["to1@example.com"].message_id, + "1111111111111111-bbbbbbbb-3333-7777-aaaa-eeeeeeeeeeee-000000", + ) self.assertEqual(msg.anymail_status.esp_response, self.DEFAULT_SEND_RESPONSE) # Amazon SES doesn't report rejected addresses at send time in a form that can be @@ -595,10 +766,15 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase): # noinspection PyUnresolvedReferences def test_send_unparsable_response(self): - """If the send succeeds, but result is unexpected format, should raise an API exception""" - response_content = {'wrong': 'format'} + """ + If the send succeeds, but result is unexpected format, + should raise an API exception + """ + response_content = {"wrong": "format"} self.set_mock_response(response_content) - with self.assertRaisesMessage(AnymailAPIError, "parsing Amazon SES send result"): + with self.assertRaisesMessage( + AnymailAPIError, "parsing Amazon SES send result" + ): self.message.send() self.assertIsNone(self.message.anymail_status.status) self.assertIsNone(self.message.anymail_status.message_id) @@ -606,7 +782,7 @@ class AmazonSESBackendAnymailFeatureTests(AmazonSESBackendMockAPITestCase): self.assertEqual(self.message.anymail_status.esp_response, response_content) -@tag('amazon_ses') +@tag("amazon_ses") class AmazonSESBackendConfigurationTests(AmazonSESBackendMockAPITestCase): """Test configuration options""" @@ -618,72 +794,99 @@ class AmazonSESBackendConfigurationTests(AmazonSESBackendMockAPITestCase): self.message.send() session_params = self.get_session_params() - self.assertEqual(session_params, {}) # no additional params passed to boto3.session.Session() + # no additional params passed to boto3.session.Session(): + self.assertEqual(session_params, {}) client_params = self.get_client_params() - config = client_params.pop("config") # Anymail adds a default config, which doesn't support == - self.assertEqual(client_params, {}) # no additional params passed to session.client('ses') - self.assertRegex(config.user_agent_extra, r'django-anymail/\d(\.\w+){1,}-amazon-ses') + # Ignore botocore.config.Config, which doesn't support == + config = client_params.pop("config") + # no additional params passed to session.client('ses'): + self.assertEqual(client_params, {}) + self.assertRegex( + config.user_agent_extra, r"django-anymail/\d(\.\w+){1,}-amazon-ses" + ) - @override_settings(ANYMAIL={ - "AMAZON_SES_CLIENT_PARAMS": { - # Example for testing; it's not a good idea to hardcode credentials in your code - "aws_access_key_id": "test-access-key-id", # safer: `os.getenv("MY_SPECIAL_AWS_KEY_ID")` - "aws_secret_access_key": "test-secret-access-key", - "region_name": "ap-northeast-1", - # config can be given as dict of botocore.config.Config params - "config": { - "read_timeout": 30, - "retries": {"max_attempts": 2}, - }, + @override_settings( + ANYMAIL={ + "AMAZON_SES_CLIENT_PARAMS": { + # Example for testing; it's not a good idea to hardcode credentials in + # your code. Safer: `os.getenv("MY_SPECIAL_AWS_KEY_ID")` etc. + "aws_access_key_id": "test-access-key-id", + "aws_secret_access_key": "test-secret-access-key", + "region_name": "ap-northeast-1", + # config can be given as dict of botocore.config.Config params + "config": { + "read_timeout": 30, + "retries": {"max_attempts": 2}, + }, + } } - }) + ) def test_client_params_in_setting(self): - """The Anymail AMAZON_SES_CLIENT_PARAMS setting specifies boto3 session.client() params for Anymail""" + """ + The Anymail AMAZON_SES_CLIENT_PARAMS setting specifies + boto3 session.client() params for Anymail + """ self.message.send() client_params = self.get_client_params() - config = client_params.pop("config") # botocore.config.Config doesn't support == - self.assertEqual(client_params, { - "aws_access_key_id": "test-access-key-id", - "aws_secret_access_key": "test-secret-access-key", - "region_name": "ap-northeast-1", - }) + # Ignore botocore.config.Config, which doesn't support == + config = client_params.pop("config") + self.assertEqual( + client_params, + { + "aws_access_key_id": "test-access-key-id", + "aws_secret_access_key": "test-secret-access-key", + "region_name": "ap-northeast-1", + }, + ) self.assertEqual(config.read_timeout, 30) self.assertEqual(config.retries, {"max_attempts": 2}) def test_client_params_in_connection_init(self): - """You can also supply credentials specifically for a particular EmailBackend connection instance""" + """ + You can also supply credentials specifically + for a particular EmailBackend connection instance + """ from botocore.config import Config + boto_config = Config(connect_timeout=30) conn = mail.get_connection( - 'anymail.backends.amazon_ses.EmailBackend', - client_params={"aws_session_token": "test-session-token", "config": boto_config}) + "anymail.backends.amazon_ses.EmailBackend", + client_params={ + "aws_session_token": "test-session-token", + "config": boto_config, + }, + ) conn.send_messages([self.message]) client_params = self.get_client_params() - config = client_params.pop("config") # botocore.config.Config doesn't support == + # Ignore botocore.config.Config, which doesn't support == + config = client_params.pop("config") self.assertEqual(client_params, {"aws_session_token": "test-session-token"}) self.assertEqual(config.connect_timeout, 30) - @override_settings(ANYMAIL={ - "AMAZON_SES_SESSION_PARAMS": { - "profile_name": "anymail-testing" - } - }) + @override_settings( + ANYMAIL={"AMAZON_SES_SESSION_PARAMS": {"profile_name": "anymail-testing"}} + ) def test_session_params_in_setting(self): - """The Anymail AMAZON_SES_SESSION_PARAMS setting specifies boto3.session.Session() params for Anymail""" + """ + The Anymail AMAZON_SES_SESSION_PARAMS setting + specifies boto3.session.Session() params for Anymail + """ self.message.send() session_params = self.get_session_params() self.assertEqual(session_params, {"profile_name": "anymail-testing"}) client_params = self.get_client_params() - client_params.pop("config") # Anymail adds a default config, which doesn't support == - self.assertEqual(client_params, {}) # no additional params passed to session.client('ses') + # Ignore botocore.config.Config, which doesn't support == + client_params.pop("config") + # no additional params passed to session.client('ses'): + self.assertEqual(client_params, {}) - @override_settings(ANYMAIL={ - "AMAZON_SES_CONFIGURATION_SET_NAME": "MyConfigurationSet" - }) + @override_settings( + ANYMAIL={"AMAZON_SES_CONFIGURATION_SET_NAME": "MyConfigurationSet"} + ) def test_config_set_setting(self): """You can supply a default ConfigurationSetName""" self.message.send() diff --git a/tests/test_amazon_ses_inbound.py b/tests/test_amazon_ses_inbound.py index 57f5184..d0af17f 100644 --- a/tests/test_amazon_ses_inbound.py +++ b/tests/test_amazon_ses_inbound.py @@ -15,26 +15,30 @@ from .test_amazon_ses_webhooks import AmazonSESWebhookTestsMixin from .webhook_cases import WebhookTestCase -@tag('amazon_ses') +@tag("amazon_ses") class AmazonSESInboundTests(WebhookTestCase, AmazonSESWebhookTestsMixin): - def setUp(self): super().setUp() - # Mock boto3.session.Session().client('s3').download_fileobj - # (We could also use botocore.stub.Stubber, but mock works well with our test structure) - self.patch_boto3_session = patch('anymail.webhooks.amazon_ses.boto3.session.Session', autospec=True) + # Mock boto3.session.Session().client('s3').download_fileobj. (We could also + # use botocore.stub.Stubber, but mock works well with our test structure.) + self.patch_boto3_session = patch( + "anymail.webhooks.amazon_ses.boto3.session.Session", autospec=True + ) self.mock_session = self.patch_boto3_session.start() # boto3.session.Session self.addCleanup(self.patch_boto3_session.stop) def mock_download_fileobj(bucket, key, fileobj): fileobj.write(self.mock_s3_downloadables[bucket][key]) - self.mock_s3_downloadables = {} # bucket: key: bytes - self.mock_client = self.mock_session.return_value.client # boto3.session.Session().client - self.mock_s3 = self.mock_client.return_value # boto3.session.Session().client('s3', ...) + self.mock_s3_downloadables = {} #: bucket: key: bytes + #: boto3.session.Session().client + self.mock_client = self.mock_session.return_value.client + #: boto3.session.Session().client('s3', ...) + self.mock_s3 = self.mock_client.return_value self.mock_s3.download_fileobj.side_effect = mock_download_fileobj - TEST_MIME_MESSAGE = dedent("""\ + TEST_MIME_MESSAGE = dedent( + """\ Return-Path: Received: from mail.example.org by inbound-smtp.us-east-1.amazonaws.com... MIME-Version: 1.0 @@ -59,7 +63,8 @@ class AmazonSESInboundTests(WebhookTestCase, AmazonSESWebhookTestsMixin):
It's a body=E2=80=A6
--94eb2c05e174adb140055b6339c5-- - """).replace("\n", "\r\n") + """ + ).replace("\n", "\r\n") def test_inbound_sns_utf8(self): raw_ses_event = { @@ -67,27 +72,50 @@ class AmazonSESInboundTests(WebhookTestCase, AmazonSESWebhookTestsMixin): "mail": { "timestamp": "2018-03-30T17:21:51.636Z", "source": "envelope-from@example.org", - "messageId": "jili9m351il3gkburn7o2f0u6788stij94c8ld01", # assigned by Amazon SES + # messageId is assigned by Amazon SES: + "messageId": "jili9m351il3gkburn7o2f0u6788stij94c8ld01", "destination": ["inbound@example.com", "someone-else@example.org"], "headersTruncated": False, "headers": [ # (omitting a few headers that Amazon SES adds on receipt) - {"name": "Return-Path", "value": ""}, - {"name": "Received", "value": "from mail.example.org by inbound-smtp.us-east-1.amazonaws.com..."}, + { + "name": "Return-Path", + "value": "", + }, + { + "name": "Received", + "value": "from mail.example.org by" + " inbound-smtp.us-east-1.amazonaws.com...", + }, {"name": "MIME-Version", "value": "1.0"}, - {"name": "Received", "value": "by 10.1.1.1 with HTTP; Fri, 30 Mar 2018 10:21:49 -0700 (PDT)"}, + { + "name": "Received", + "value": "by 10.1.1.1 with HTTP;" + " Fri, 30 Mar 2018 10:21:49 -0700 (PDT)", + }, {"name": "From", "value": '"Sender, Inc." '}, {"name": "Date", "value": "Fri, 30 Mar 2018 10:21:50 -0700"}, {"name": "Message-ID", "value": ""}, {"name": "Subject", "value": "Test inbound message"}, - {"name": "To", "value": "Recipient , someone-else@example.org"}, - {"name": "Content-Type", "value": 'multipart/alternative; boundary="94eb2c05e174adb140055b6339c5"'}, + { + "name": "To", + "value": "Recipient ," + " someone-else@example.org", + }, + { + "name": "Content-Type", + "value": "multipart/alternative;" + ' boundary="94eb2c05e174adb140055b6339c5"', + }, ], "commonHeaders": { "returnPath": "bounce-handler@mail.example.org", "from": ['"Sender, Inc." '], "date": "Fri, 30 Mar 2018 10:21:50 -0700", - "to": ["Recipient ", "someone-else@example.org"], + "to": [ + "Recipient ", + "someone-else@example.org", + ], "messageId": "", "subject": "Test inbound message", }, @@ -119,32 +147,46 @@ class AmazonSESInboundTests(WebhookTestCase, AmazonSESWebhookTestsMixin): "Timestamp": "2018-03-30T17:17:36.516Z", "SignatureVersion": "1", "Signature": "EXAMPLE_SIGNATURE==", - "SigningCertURL": "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-12345abcde.pem", - "UnsubscribeURL": "https://sns.us-east-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn...", + "SigningCertURL": "https://sns.us-east-1.amazonaws.com" + "/SimpleNotificationService-12345abcde.pem", + "UnsubscribeURL": "https://sns.us-east-1.amazonaws.com" + "/?Action=Unsubscribe&SubscriptionArn=arn...", } - response = self.post_from_sns('/anymail/amazon_ses/inbound/', raw_sns_message) + response = self.post_from_sns("/anymail/amazon_ses/inbound/", raw_sns_message) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.inbound_handler, sender=AmazonSESInboundWebhookView, - event=ANY, esp_name='Amazon SES') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.inbound_handler, + sender=AmazonSESInboundWebhookView, + event=ANY, + esp_name="Amazon SES", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailInboundEvent) - self.assertEqual(event.event_type, 'inbound') - self.assertEqual(event.timestamp, datetime(2018, 3, 30, 17, 21, 51, microsecond=636000, tzinfo=timezone.utc)) + self.assertEqual(event.event_type, "inbound") + self.assertEqual( + event.timestamp, + datetime(2018, 3, 30, 17, 21, 51, microsecond=636000, tzinfo=timezone.utc), + ) self.assertEqual(event.event_id, "jili9m351il3gkburn7o2f0u6788stij94c8ld01") self.assertIsInstance(event.message, AnymailInboundMessage) self.assertEqual(event.esp_event, raw_ses_event) message = event.message self.assertIsInstance(message, AnymailInboundMessage) - self.assertEqual(message.envelope_sender, 'envelope-from@example.org') - self.assertEqual(message.envelope_recipient, 'inbound@example.com') + self.assertEqual(message.envelope_sender, "envelope-from@example.org") + self.assertEqual(message.envelope_recipient, "inbound@example.com") self.assertEqual(str(message.from_email), '"Sender, Inc." ') - self.assertEqual([str(to) for to in message.to], - ['Recipient ', 'someone-else@example.org']) - self.assertEqual(message.subject, 'Test inbound message') + self.assertEqual( + [str(to) for to in message.to], + ["Recipient ", "someone-else@example.org"], + ) + self.assertEqual(message.subject, "Test inbound message") self.assertEqual(message.text, "It's a body\N{HORIZONTAL ELLIPSIS}\r\n") - self.assertEqual(message.html, """
It's a body\N{HORIZONTAL ELLIPSIS}
\r\n""") + self.assertEqual( + message.html, + """
It's a body\N{HORIZONTAL ELLIPSIS}
\r\n""", + ) self.assertIs(message.spam_detected, False) def test_inbound_sns_base64(self): @@ -155,7 +197,8 @@ class AmazonSESInboundTests(WebhookTestCase, AmazonSESWebhookTestsMixin): "mail": { "source": "envelope-from@example.org", "timestamp": "2018-03-30T17:21:51.636Z", - "messageId": "jili9m351il3gkburn7o2f0u6788stij94c8ld01", # assigned by Amazon SES + # messageId is assigned by Amazon SES + "messageId": "jili9m351il3gkburn7o2f0u6788stij94c8ld01", "destination": ["inbound@example.com", "someone-else@example.org"], }, "receipt": { @@ -167,7 +210,9 @@ class AmazonSESInboundTests(WebhookTestCase, AmazonSESWebhookTestsMixin): }, "spamVerdict": {"status": "FAIL"}, }, - "content": b64encode(self.TEST_MIME_MESSAGE.encode('ascii')).decode('ascii'), + "content": b64encode(self.TEST_MIME_MESSAGE.encode("ascii")).decode( + "ascii" + ), } raw_sns_message = { @@ -177,35 +222,49 @@ class AmazonSESInboundTests(WebhookTestCase, AmazonSESWebhookTestsMixin): "Message": json.dumps(raw_ses_event), } - response = self.post_from_sns('/anymail/amazon_ses/inbound/', raw_sns_message) + response = self.post_from_sns("/anymail/amazon_ses/inbound/", raw_sns_message) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.inbound_handler, sender=AmazonSESInboundWebhookView, - event=ANY, esp_name='Amazon SES') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.inbound_handler, + sender=AmazonSESInboundWebhookView, + event=ANY, + esp_name="Amazon SES", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailInboundEvent) - self.assertEqual(event.event_type, 'inbound') - self.assertEqual(event.timestamp, datetime(2018, 3, 30, 17, 21, 51, microsecond=636000, tzinfo=timezone.utc)) + self.assertEqual(event.event_type, "inbound") + self.assertEqual( + event.timestamp, + datetime(2018, 3, 30, 17, 21, 51, microsecond=636000, tzinfo=timezone.utc), + ) self.assertEqual(event.event_id, "jili9m351il3gkburn7o2f0u6788stij94c8ld01") self.assertIsInstance(event.message, AnymailInboundMessage) self.assertEqual(event.esp_event, raw_ses_event) message = event.message self.assertIsInstance(message, AnymailInboundMessage) - self.assertEqual(message.envelope_sender, 'envelope-from@example.org') - self.assertEqual(message.envelope_recipient, 'inbound@example.com') + self.assertEqual(message.envelope_sender, "envelope-from@example.org") + self.assertEqual(message.envelope_recipient, "inbound@example.com") self.assertEqual(str(message.from_email), '"Sender, Inc." ') - self.assertEqual([str(to) for to in message.to], - ['Recipient ', 'someone-else@example.org']) - self.assertEqual(message.subject, 'Test inbound message') + self.assertEqual( + [str(to) for to in message.to], + ["Recipient ", "someone-else@example.org"], + ) + self.assertEqual(message.subject, "Test inbound message") self.assertEqual(message.text, "It's a body\N{HORIZONTAL ELLIPSIS}\r\n") - self.assertEqual(message.html, """
It's a body\N{HORIZONTAL ELLIPSIS}
\r\n""") + self.assertEqual( + message.html, + """
It's a body\N{HORIZONTAL ELLIPSIS}
\r\n""", + ) self.assertIs(message.spam_detected, True) def test_inbound_s3(self): """Should handle 'S3' receipt action""" self.mock_s3_downloadables["InboundEmailBucket-KeepPrivate"] = { - "inbound/fqef5sop459utgdf4o9lqbsv7jeo73pejig34301": self.TEST_MIME_MESSAGE.encode('ascii') + "inbound/fqef5sop459utgdf4o9lqbsv7jeo73pejig34301": ( + self.TEST_MIME_MESSAGE.encode("ascii") + ) } raw_ses_event = { @@ -214,7 +273,8 @@ class AmazonSESInboundTests(WebhookTestCase, AmazonSESWebhookTestsMixin): "mail": { "source": "envelope-from@example.org", "timestamp": "2018-03-30T17:21:51.636Z", - "messageId": "fqef5sop459utgdf4o9lqbsv7jeo73pejig34301", # assigned by Amazon SES + # messageId is assigned by Amazon SES + "messageId": "fqef5sop459utgdf4o9lqbsv7jeo73pejig34301", "destination": ["inbound@example.com", "someone-else@example.org"], }, "receipt": { @@ -224,7 +284,7 @@ class AmazonSESInboundTests(WebhookTestCase, AmazonSESWebhookTestsMixin): "topicArn": "arn:aws:sns:us-east-1:111111111111:SES_Inbound", "bucketName": "InboundEmailBucket-KeepPrivate", "objectKeyPrefix": "inbound", - "objectKey": "inbound/fqef5sop459utgdf4o9lqbsv7jeo73pejig34301" + "objectKey": "inbound/fqef5sop459utgdf4o9lqbsv7jeo73pejig34301", }, "spamVerdict": {"status": "GRAY"}, }, @@ -235,46 +295,69 @@ class AmazonSESInboundTests(WebhookTestCase, AmazonSESWebhookTestsMixin): "TopicArn": "arn:aws:sns:us-east-1:111111111111:SES_Inbound", "Message": json.dumps(raw_ses_event), } - response = self.post_from_sns('/anymail/amazon_ses/inbound/', raw_sns_message) + response = self.post_from_sns("/anymail/amazon_ses/inbound/", raw_sns_message) self.assertEqual(response.status_code, 200) - self.mock_client.assert_called_once_with('s3', config=ANY) + self.mock_client.assert_called_once_with("s3", config=ANY) self.mock_s3.download_fileobj.assert_called_once_with( - "InboundEmailBucket-KeepPrivate", "inbound/fqef5sop459utgdf4o9lqbsv7jeo73pejig34301", ANY) + "InboundEmailBucket-KeepPrivate", + "inbound/fqef5sop459utgdf4o9lqbsv7jeo73pejig34301", + ANY, + ) - kwargs = self.assert_handler_called_once_with(self.inbound_handler, sender=AmazonSESInboundWebhookView, - event=ANY, esp_name='Amazon SES') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.inbound_handler, + sender=AmazonSESInboundWebhookView, + event=ANY, + esp_name="Amazon SES", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailInboundEvent) - self.assertEqual(event.event_type, 'inbound') - self.assertEqual(event.timestamp, datetime(2018, 3, 30, 17, 21, 51, microsecond=636000, tzinfo=timezone.utc)) + self.assertEqual(event.event_type, "inbound") + self.assertEqual( + event.timestamp, + datetime(2018, 3, 30, 17, 21, 51, microsecond=636000, tzinfo=timezone.utc), + ) self.assertEqual(event.event_id, "fqef5sop459utgdf4o9lqbsv7jeo73pejig34301") self.assertIsInstance(event.message, AnymailInboundMessage) self.assertEqual(event.esp_event, raw_ses_event) message = event.message self.assertIsInstance(message, AnymailInboundMessage) - self.assertEqual(message.envelope_sender, 'envelope-from@example.org') - self.assertEqual(message.envelope_recipient, 'inbound@example.com') + self.assertEqual(message.envelope_sender, "envelope-from@example.org") + self.assertEqual(message.envelope_recipient, "inbound@example.com") self.assertEqual(str(message.from_email), '"Sender, Inc." ') - self.assertEqual([str(to) for to in message.to], - ['Recipient ', 'someone-else@example.org']) - self.assertEqual(message.subject, 'Test inbound message') + self.assertEqual( + [str(to) for to in message.to], + ["Recipient ", "someone-else@example.org"], + ) + self.assertEqual(message.subject, "Test inbound message") self.assertEqual(message.text, "It's a body\N{HORIZONTAL ELLIPSIS}\n") - self.assertEqual(message.html, """
It's a body\N{HORIZONTAL ELLIPSIS}
\n""") + self.assertEqual( + message.html, + """
It's a body\N{HORIZONTAL ELLIPSIS}
\n""", + ) self.assertIsNone(message.spam_detected) def test_inbound_s3_failure_message(self): """Issue a helpful error when S3 download fails""" - # Boto's error: "An error occurred (403) when calling the HeadObject operation: Forbidden") + # Boto's error: + # "An error occurred (403) when calling the HeadObject operation: Forbidden" from botocore.exceptions import ClientError + self.mock_s3.download_fileobj.side_effect = ClientError( - {'Error': {'Code': 403, 'Message': 'Forbidden'}}, operation_name='HeadObject') + {"Error": {"Code": 403, "Message": "Forbidden"}}, + operation_name="HeadObject", + ) raw_ses_event = { "notificationType": "Received", "receipt": { - "action": {"type": "S3", "bucketName": "YourBucket", "objectKey": "inbound/the_object_key"} + "action": { + "type": "S3", + "bucketName": "YourBucket", + "objectKey": "inbound/the_object_key", + } }, } raw_sns_message = { @@ -285,12 +368,18 @@ class AmazonSESInboundTests(WebhookTestCase, AmazonSESWebhookTestsMixin): } with self.assertRaisesMessage( AnymailAPIError, - "Anymail AmazonSESInboundWebhookView couldn't download S3 object 'YourBucket:inbound/the_object_key'" + "Anymail AmazonSESInboundWebhookView couldn't download" + " S3 object 'YourBucket:inbound/the_object_key'", ) as cm: - self.post_from_sns('/anymail/amazon_ses/inbound/', raw_sns_message) - self.assertIsInstance(cm.exception, ClientError) # both Boto and Anymail exception class - self.assertIn("ClientError: An error occurred (403) when calling the HeadObject operation: Forbidden", - str(cm.exception)) # original Boto message included + self.post_from_sns("/anymail/amazon_ses/inbound/", raw_sns_message) + # both Boto and Anymail exception class: + self.assertIsInstance(cm.exception, ClientError) + # original Boto message included: + self.assertIn( + "ClientError: An error occurred (403) when calling" + " the HeadObject operation: Forbidden", + str(cm.exception), + ) def test_incorrect_tracking_event(self): """The inbound webhook should warn if it receives tracking events""" @@ -303,7 +392,8 @@ class AmazonSESInboundTests(WebhookTestCase, AmazonSESWebhookTestsMixin): with self.assertRaisesMessage( AnymailConfigurationError, - "You seem to have set an Amazon SES *sending* event or notification to publish to an SNS Topic " - "that posts to Anymail's *inbound* webhook URL. (SNS TopicArn arn:...:111111111111:SES_Tracking)" + "You seem to have set an Amazon SES *sending* event or notification" + " to publish to an SNS Topic that posts to Anymail's *inbound* webhook URL." + " (SNS TopicArn arn:...:111111111111:SES_Tracking)", ): - self.post_from_sns('/anymail/amazon_ses/inbound/', raw_sns_message) + self.post_from_sns("/anymail/amazon_ses/inbound/", raw_sns_message) diff --git a/tests/test_amazon_ses_integration.py b/tests/test_amazon_ses_integration.py index 6dac2e1..dbdfea2 100644 --- a/tests/test_amazon_ses_integration.py +++ b/tests/test_amazon_ses_integration.py @@ -10,10 +10,15 @@ from anymail.message import AnymailMessage from .utils import AnymailTestMixin, sample_image_path - -ANYMAIL_TEST_AMAZON_SES_ACCESS_KEY_ID = os.getenv("ANYMAIL_TEST_AMAZON_SES_ACCESS_KEY_ID") -ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY = os.getenv("ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY") -ANYMAIL_TEST_AMAZON_SES_REGION_NAME = os.getenv("ANYMAIL_TEST_AMAZON_SES_REGION_NAME", "us-east-1") +ANYMAIL_TEST_AMAZON_SES_ACCESS_KEY_ID = os.getenv( + "ANYMAIL_TEST_AMAZON_SES_ACCESS_KEY_ID" +) +ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY = os.getenv( + "ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY" +) +ANYMAIL_TEST_AMAZON_SES_REGION_NAME = os.getenv( + "ANYMAIL_TEST_AMAZON_SES_REGION_NAME", "us-east-1" +) ANYMAIL_TEST_AMAZON_SES_DOMAIN = os.getenv("ANYMAIL_TEST_AMAZON_SES_DOMAIN") @@ -21,30 +26,37 @@ ANYMAIL_TEST_AMAZON_SES_DOMAIN = os.getenv("ANYMAIL_TEST_AMAZON_SES_DOMAIN") ANYMAIL_TEST_AMAZON_SES_ACCESS_KEY_ID and ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY and ANYMAIL_TEST_AMAZON_SES_DOMAIN, - "Set ANYMAIL_TEST_AMAZON_SES_ACCESS_KEY_ID and ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY " - "and ANYMAIL_TEST_AMAZON_SES_DOMAIN environment variables to run Amazon SES integration tests") + "Set ANYMAIL_TEST_AMAZON_SES_ACCESS_KEY_ID and" + " ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY and ANYMAIL_TEST_AMAZON_SES_DOMAIN" + " environment variables to run Amazon SES integration tests", +) @override_settings( EMAIL_BACKEND="anymail.backends.amazon_ses.EmailBackend", ANYMAIL={ "AMAZON_SES_CLIENT_PARAMS": { # This setting provides Anymail-specific AWS credentials to boto3.client(), # overriding any credentials in the environment or boto config. It's often - # *not* the best approach -- see the Anymail and boto3 docs for other options. + # *not* the best approach. See the Anymail and boto3 docs for other options. "aws_access_key_id": ANYMAIL_TEST_AMAZON_SES_ACCESS_KEY_ID, "aws_secret_access_key": ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY, "region_name": ANYMAIL_TEST_AMAZON_SES_REGION_NAME, - # Can supply any other boto3.client params, including botocore.config.Config as dict + # Can supply any other boto3.client params, + # including botocore.config.Config as dict "config": {"retries": {"max_attempts": 2}}, }, - "AMAZON_SES_CONFIGURATION_SET_NAME": "TestConfigurationSet", # actual config set in Anymail test account - }) -@tag('amazon_ses', 'live') + # actual config set in Anymail test account: + "AMAZON_SES_CONFIGURATION_SET_NAME": "TestConfigurationSet", + }, +) +@tag("amazon_ses", "live") class AmazonSESBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): """Amazon SES API integration tests These tests run against the **live** Amazon SES API, using the environment - variables `ANYMAIL_TEST_AMAZON_SES_ACCESS_KEY_ID` and `ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY` - as AWS credentials. If those variables are not set, these tests won't run. + variables `ANYMAIL_TEST_AMAZON_SES_ACCESS_KEY_ID` and + `ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY` as AWS credentials. + If those variables are not set, these tests won't run. + (You can also set the environment variable `ANYMAIL_TEST_AMAZON_SES_REGION_NAME` to test SES using a region other than the default "us-east-1".) @@ -56,17 +68,24 @@ class AmazonSESBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): def setUp(self): super().setUp() - self.from_email = 'test@%s' % ANYMAIL_TEST_AMAZON_SES_DOMAIN - self.message = AnymailMessage('Anymail Amazon SES integration test', 'Text content', - self.from_email, ['success@simulator.amazonses.com']) - self.message.attach_alternative('

HTML content

', "text/html") + self.from_email = "test@%s" % ANYMAIL_TEST_AMAZON_SES_DOMAIN + self.message = AnymailMessage( + "Anymail Amazon SES integration test", + "Text content", + self.from_email, + ["success@simulator.amazonses.com"], + ) + self.message.attach_alternative("

HTML content

", "text/html") - # boto3 relies on GC to close connections. Python 3 warns about unclosed ssl.SSLSocket during cleanup. - # We don't care. (It may be a false positive, or it may be a botocore problem, but it's not *our* problem.) + # boto3 relies on GC to close connections. Python 3 warns about unclosed + # ssl.SSLSocket during cleanup. We don't care. (It may be a false positive, + # or it may be a botocore problem, but it's not *our* problem.) # https://github.com/boto/boto3/issues/454#issuecomment-586033745 - # Filter in TestCase.setUp because unittest resets the warning filters for each test. - # https://stackoverflow.com/a/26620811/647002 - warnings.filterwarnings("ignore", message=r"unclosed "], - cc=["success+cc1@simulator.amazonses.com", "Copy 2 "], - bcc=["success+bcc1@simulator.amazonses.com", "Blind Copy 2 "], + to=[ + "success+to1@simulator.amazonses.com", + "Recipient 2 ", + ], + cc=[ + "success+cc1@simulator.amazonses.com", + "Copy 2 ", + ], + bcc=[ + "success+bcc1@simulator.amazonses.com", + "Blind Copy 2 ", + ], reply_to=["reply1@example.com", "Reply 2 "], headers={"X-Anymail-Test": "value"}, metadata={"meta1": "simple_string", "meta2": 2}, @@ -101,55 +136,72 @@ class AmazonSESBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): message.attach_alternative( "

HTML: with link" "and image: " % cid, - "text/html") + "text/html", + ) message.attach_alternative( "Amazon SES SendRawEmail actually supports multiple alternative parts", - "text/x-note-for-email-geeks") + "text/x-note-for-email-geeks", + ) message.send() - self.assertEqual(message.anymail_status.status, {'queued'}) + self.assertEqual(message.anymail_status.status, {"queued"}) def test_stored_template(self): # Using a template created like this: # boto3.client('ses').create_template(Template={ # "TemplateName": "TestTemplate", # "SubjectPart": "Your order {{order}} shipped", - # "HtmlPart": "

Dear {{name}}:

Your order {{order}} shipped {{ship_date}}.

", - # "TextPart": "Dear {{name}}:\r\nYour order {{order}} shipped {{ship_date}}." + # "HtmlPart": "

Dear {{name}}:

" + # "

Your order {{order}} shipped {{ship_date}}.

", + # "TextPart": "Dear {{name}}:\r\n" + # "Your order {{order}} shipped {{ship_date}}." # }) message = AnymailMessage( - template_id='TestTemplate', + template_id="TestTemplate", from_email=formataddr(("Test From", self.from_email)), - to=["First Recipient ", - "success+to2@simulator.amazonses.com"], + to=[ + "First Recipient ", + "success+to2@simulator.amazonses.com", + ], merge_data={ - 'success+to1@simulator.amazonses.com': {'order': 12345, 'name': "Test Recipient"}, - 'success+to2@simulator.amazonses.com': {'order': 6789}, - }, - merge_global_data={ - 'name': "Customer", # default - 'ship_date': "today" + "success+to1@simulator.amazonses.com": { + "order": 12345, + "name": "Test Recipient", + }, + "success+to2@simulator.amazonses.com": {"order": 6789}, }, + merge_global_data={"name": "Customer", "ship_date": "today"}, # default ) message.send() recipient_status = message.anymail_status.recipients - self.assertEqual(recipient_status['success+to1@simulator.amazonses.com'].status, 'queued') - self.assertRegex(recipient_status['success+to1@simulator.amazonses.com'].message_id, r'[0-9a-f-]+') - self.assertEqual(recipient_status['success+to2@simulator.amazonses.com'].status, 'queued') - self.assertRegex(recipient_status['success+to2@simulator.amazonses.com'].message_id, r'[0-9a-f-]+') + self.assertEqual( + recipient_status["success+to1@simulator.amazonses.com"].status, "queued" + ) + self.assertRegex( + recipient_status["success+to1@simulator.amazonses.com"].message_id, + r"[0-9a-f-]+", + ) + self.assertEqual( + recipient_status["success+to2@simulator.amazonses.com"].status, "queued" + ) + self.assertRegex( + recipient_status["success+to2@simulator.amazonses.com"].message_id, + r"[0-9a-f-]+", + ) - @override_settings(ANYMAIL={ - "AMAZON_SES_CLIENT_PARAMS": { - "aws_access_key_id": "test-invalid-access-key-id", - "aws_secret_access_key": "test-invalid-secret-access-key", - "region_name": ANYMAIL_TEST_AMAZON_SES_REGION_NAME, + @override_settings( + ANYMAIL={ + "AMAZON_SES_CLIENT_PARAMS": { + "aws_access_key_id": "test-invalid-access-key-id", + "aws_secret_access_key": "test-invalid-secret-access-key", + "region_name": ANYMAIL_TEST_AMAZON_SES_REGION_NAME, + } } - }) + ) def test_invalid_aws_credentials(self): # Make sure the exception message includes AWS's response: with self.assertRaisesMessage( - AnymailAPIError, - "The security token included in the request is invalid" + AnymailAPIError, "The security token included in the request is invalid" ): self.message.send() diff --git a/tests/test_amazon_ses_webhooks.py b/tests/test_amazon_ses_webhooks.py index f8772c7..3f62569 100644 --- a/tests/test_amazon_ses_webhooks.py +++ b/tests/test_amazon_ses_webhooks.py @@ -16,50 +16,65 @@ class AmazonSESWebhookTestsMixin(SimpleTestCase): def post_from_sns(self, path, raw_sns_message, **kwargs): return self.client.post( path, - content_type='text/plain; charset=UTF-8', # SNS posts JSON as text/plain + content_type="text/plain; charset=UTF-8", # SNS posts JSON as text/plain data=json.dumps(raw_sns_message), HTTP_X_AMZ_SNS_MESSAGE_ID=raw_sns_message["MessageId"], HTTP_X_AMZ_SNS_MESSAGE_TYPE=raw_sns_message["Type"], # Anymail doesn't use other x-amz-sns-* headers - **kwargs) + **kwargs, + ) -@tag('amazon_ses') -class AmazonSESWebhookSecurityTests(AmazonSESWebhookTestsMixin, WebhookBasicAuthTestCase): +@tag("amazon_ses") +class AmazonSESWebhookSecurityTests( + AmazonSESWebhookTestsMixin, WebhookBasicAuthTestCase +): def call_webhook(self): - return self.post_from_sns('/anymail/amazon_ses/tracking/', - {"Type": "Notification", "MessageId": "123", "Message": "{}"}) + return self.post_from_sns( + "/anymail/amazon_ses/tracking/", + {"Type": "Notification", "MessageId": "123", "Message": "{}"}, + ) # Most actual tests are in WebhookBasicAuthTestCase def test_verifies_missing_auth(self): - # Must handle missing auth header slightly differently from Anymail default 400 SuspiciousOperation: - # SNS will only send basic auth after missing auth responds 401 WWW-Authenticate: Basic realm="..." + # Must handle missing auth header slightly differently from Anymail default 400 + # SuspiciousOperation: SNS will only send basic auth after missing auth responds + # 401 WWW-Authenticate: Basic realm="..." self.clear_basic_auth() response = self.call_webhook() self.assertEqual(response.status_code, 401) - self.assertEqual(response["WWW-Authenticate"], 'Basic realm="Anymail WEBHOOK_SECRET"') + self.assertEqual( + response["WWW-Authenticate"], 'Basic realm="Anymail WEBHOOK_SECRET"' + ) -@tag('amazon_ses') +@tag("amazon_ses") class AmazonSESNotificationsTests(WebhookTestCase, AmazonSESWebhookTestsMixin): def test_bounce_event(self): - # This test includes a complete Amazon SES example event. (Later tests omit some payload for brevity.) + # This test includes a complete Amazon SES example event. + # (Later tests omit some payload for brevity.) # https://docs.aws.amazon.com/ses/latest/DeveloperGuide/notification-examples.html#notification-examples-bounce raw_ses_event = { "notificationType": "Bounce", "bounce": { "bounceType": "Permanent", "reportingMTA": "dns; email.example.com", - "bouncedRecipients": [{ - "emailAddress": "jane@example.com", - "status": "5.1.1", - "action": "failed", - "diagnosticCode": "smtp; 550 5.1.1 ... User unknown", - }], + "bouncedRecipients": [ + { + "emailAddress": "jane@example.com", + "status": "5.1.1", + "action": "failed", + "diagnosticCode": "smtp; 550 5.1.1 ..." + " User unknown", + } + ], "bounceSubType": "General", - "timestamp": "2016-01-27T14:59:44.101Z", # when bounce sent (by receiving ISP) - "feedbackId": "00000138111222aa-44455566-cccc-cccc-cccc-ddddaaaa068a-000000", # unique id for bounce + # when bounce sent (by receiving ISP): + "timestamp": "2016-01-27T14:59:44.101Z", + # unique id for bounce: + "feedbackId": "00000138111222aa-44455566-cccc" + "-cccc-cccc-ddddaaaa068a-000000", "remoteMtaIp": "127.0.2.0", }, "mail": { @@ -68,13 +83,22 @@ class AmazonSESNotificationsTests(WebhookTestCase, AmazonSESWebhookTestsMixin): "sourceArn": "arn:aws:ses:us-west-2:888888888888:identity/example.com", "sourceIp": "127.0.3.0", "sendingAccountId": "123456789012", - "messageId": "00000138111222aa-33322211-cccc-cccc-cccc-ddddaaaa0680-000000", - "destination": ["jane@example.com", "mary@example.com", "richard@example.com"], + "messageId": "00000138111222aa-33322211-cccc" + "-cccc-cccc-ddddaaaa0680-000000", + "destination": [ + "jane@example.com", + "mary@example.com", + "richard@example.com", + ], "headersTruncated": False, "headers": [ {"name": "From", "value": '"John Doe" '}, - {"name": "To", "value": '"Jane Doe" , "Mary Doe" ,' - ' "Richard Doe" '}, + { + "name": "To", + "value": '"Jane Doe" ,' + ' "Mary Doe" ,' + ' "Richard Doe" ', + }, {"name": "Message-ID", "value": "custom-message-ID"}, {"name": "Subject", "value": "Hello"}, {"name": "Content-Type", "value": 'text/plain; charset="UTF-8"'}, @@ -87,8 +111,10 @@ class AmazonSESNotificationsTests(WebhookTestCase, AmazonSESWebhookTestsMixin): "commonHeaders": { "from": ["John Doe "], "date": "Wed, 27 Jan 2016 14:05:45 +0000", - "to": ["Jane Doe , Mary Doe ," - " Richard Doe "], + "to": [ + "Jane Doe , Mary Doe ," + " Richard Doe " + ], "messageId": "custom-message-ID", "subject": "Hello", }, @@ -96,35 +122,48 @@ class AmazonSESNotificationsTests(WebhookTestCase, AmazonSESWebhookTestsMixin): } raw_sns_message = { "Type": "Notification", - "MessageId": "19ba9823-d7f2-53c1-860e-cb10e0d13dfc", # unique id for SNS event + # unique id for SNS event: + "MessageId": "19ba9823-d7f2-53c1-860e-cb10e0d13dfc", "TopicArn": "arn:aws:sns:us-east-1:1234567890:SES_Events", "Subject": "Amazon SES Email Event Notification", "Message": json.dumps(raw_ses_event) + "\n", "Timestamp": "2018-03-26T17:58:59.675Z", "SignatureVersion": "1", "Signature": "EXAMPLE-SIGNATURE==", - "SigningCertURL": "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-12345abcde.pem", - "UnsubscribeURL": "https://sns.us-east-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn...", + "SigningCertURL": "https://sns.us-east-1.amazonaws.com" + "/SimpleNotificationService-12345abcde.pem", + "UnsubscribeURL": "https://sns.us-east-1.amazonaws.com" + "/?Action=Unsubscribe&SubscriptionArn=arn...", } - response = self.post_from_sns('/anymail/amazon_ses/tracking/', raw_sns_message) + response = self.post_from_sns("/anymail/amazon_ses/tracking/", raw_sns_message) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=AmazonSESTrackingWebhookView, - event=ANY, esp_name='Amazon SES') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=AmazonSESTrackingWebhookView, + event=ANY, + esp_name="Amazon SES", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "bounced") self.assertEqual(event.esp_event, raw_ses_event) + # timestamp from SNS: self.assertEqual( event.timestamp, - datetime(2018, 3, 26, 17, 58, 59, microsecond=675000, tzinfo=timezone.utc) - ) # SNS - self.assertEqual(event.message_id, "00000138111222aa-33322211-cccc-cccc-cccc-ddddaaaa0680-000000") + datetime(2018, 3, 26, 17, 58, 59, microsecond=675000, tzinfo=timezone.utc), + ) + self.assertEqual( + event.message_id, + "00000138111222aa-33322211-cccc-cccc-cccc-ddddaaaa0680-000000", + ) self.assertEqual(event.event_id, "19ba9823-d7f2-53c1-860e-cb10e0d13dfc") self.assertEqual(event.recipient, "jane@example.com") self.assertEqual(event.reject_reason, "bounced") self.assertEqual(event.description, "Permanent: General") - self.assertEqual(event.mta_response, "smtp; 550 5.1.1 ... User unknown") + self.assertEqual( + event.mta_response, "smtp; 550 5.1.1 ... User unknown" + ) self.assertEqual(event.tags, ["tag 1", "tag 2"]) self.assertEqual(event.metadata, {"meta1": "string", "meta2": 2}) @@ -139,13 +178,18 @@ class AmazonSESNotificationsTests(WebhookTestCase, AmazonSESWebhookTestsMixin): "bounceSubType": "General", "bouncedRecipients": [ {"emailAddress": "jane@example.com"}, - {"emailAddress": "richard@example.com"} + {"emailAddress": "richard@example.com"}, ], }, "mail": { - "messageId": "00000137860315fd-34208509-5b74-41f3-95c5-22c1edc3c924-000000", - "destination": ["jane@example.com", "mary@example.com", "richard@example.com"], - } + "messageId": "00000137860315fd-34208509-5b74" + "-41f3-95c5-22c1edc3c924-000000", + "destination": [ + "jane@example.com", + "mary@example.com", + "richard@example.com", + ], + }, } raw_sns_message = { "Type": "Notification", @@ -153,7 +197,7 @@ class AmazonSESNotificationsTests(WebhookTestCase, AmazonSESWebhookTestsMixin): "Message": json.dumps(raw_ses_event) + "\n", "Timestamp": "2018-03-26T17:58:59.675Z", } - response = self.post_from_sns('/anymail/amazon_ses/tracking/', raw_sns_message) + response = self.post_from_sns("/anymail/amazon_ses/tracking/", raw_sns_message) self.assertEqual(response.status_code, 200) # tracking handler should be called twice -- once for each bounced recipient @@ -161,14 +205,14 @@ class AmazonSESNotificationsTests(WebhookTestCase, AmazonSESWebhookTestsMixin): self.assertEqual(self.tracking_handler.call_count, 2) _, kwargs = self.tracking_handler.call_args_list[0] - event = kwargs['event'] + event = kwargs["event"] self.assertEqual(event.event_type, "bounced") self.assertEqual(event.recipient, "jane@example.com") self.assertEqual(event.description, "Permanent: General") self.assertIsNone(event.mta_response) _, kwargs = self.tracking_handler.call_args_list[1] - event = kwargs['event'] + event = kwargs["event"] self.assertEqual(event.esp_event, raw_ses_event) self.assertEqual(event.recipient, "richard@example.com") @@ -181,9 +225,14 @@ class AmazonSESNotificationsTests(WebhookTestCase, AmazonSESWebhookTestsMixin): "complaintFeedbackType": "abuse", }, "mail": { - "messageId": "000001378603177f-7a5433e7-8edb-42ae-af10-f0181f34d6ee-000000", - "destination": ["jane@example.com", "mary@example.com", "richard@example.com"], - } + "messageId": "000001378603177f-7a5433e7-8edb" + "-42ae-af10-f0181f34d6ee-000000", + "destination": [ + "jane@example.com", + "mary@example.com", + "richard@example.com", + ], + }, } raw_sns_message = { "Type": "Notification", @@ -191,11 +240,15 @@ class AmazonSESNotificationsTests(WebhookTestCase, AmazonSESWebhookTestsMixin): "Message": json.dumps(raw_ses_event) + "\n", "Timestamp": "2018-03-26T17:58:59.675Z", } - response = self.post_from_sns('/anymail/amazon_ses/tracking/', raw_sns_message) + response = self.post_from_sns("/anymail/amazon_ses/tracking/", raw_sns_message) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=AmazonSESTrackingWebhookView, - event=ANY, esp_name='Amazon SES') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=AmazonSESTrackingWebhookView, + event=ANY, + esp_name="Amazon SES", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "complained") self.assertEqual(event.recipient, "richard@example.com") self.assertEqual(event.reject_reason, "spam") @@ -207,8 +260,13 @@ class AmazonSESNotificationsTests(WebhookTestCase, AmazonSESWebhookTestsMixin): "notificationType": "Delivery", "mail": { "timestamp": "2016-01-27T14:59:38.237Z", - "messageId": "0000014644fe5ef6-9a483358-9170-4cb4-a269-f5dcdf415321-000000", - "destination": ["jane@example.com", "mary@example.com", "richard@example.com"], + "messageId": "0000014644fe5ef6-9a483358-9170" + "-4cb4-a269-f5dcdf415321-000000", + "destination": [ + "jane@example.com", + "mary@example.com", + "richard@example.com", + ], }, "delivery": { "timestamp": "2016-01-27T14:59:38.237Z", @@ -216,8 +274,8 @@ class AmazonSESNotificationsTests(WebhookTestCase, AmazonSESWebhookTestsMixin): "processingTimeMillis": 546, "reportingMTA": "a8-70.smtp-out.amazonses.com", "smtpResponse": "250 ok: Message 64111812 accepted", - "remoteMtaIp": "127.0.2.0" - } + "remoteMtaIp": "127.0.2.0", + }, } raw_sns_message = { "Type": "Notification", @@ -225,11 +283,15 @@ class AmazonSESNotificationsTests(WebhookTestCase, AmazonSESWebhookTestsMixin): "Message": json.dumps(raw_ses_event) + "\n", "Timestamp": "2018-03-26T17:58:59.675Z", } - response = self.post_from_sns('/anymail/amazon_ses/tracking/', raw_sns_message) + response = self.post_from_sns("/anymail/amazon_ses/tracking/", raw_sns_message) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=AmazonSESTrackingWebhookView, - event=ANY, esp_name='Amazon SES') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=AmazonSESTrackingWebhookView, + event=ANY, + esp_name="Amazon SES", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "delivered") self.assertEqual(event.recipient, "jane@example.com") self.assertEqual(event.mta_response, "250 ok: Message 64111812 accepted") @@ -247,10 +309,10 @@ class AmazonSESNotificationsTests(WebhookTestCase, AmazonSESWebhookTestsMixin): "ses:from-domain": ["example.com"], "ses:caller-identity": ["ses_user"], "myCustomTag1": ["myCustomTagValue1"], - "myCustomTag2": ["myCustomTagValue2"] - } + "myCustomTag2": ["myCustomTagValue2"], + }, }, - "send": {} + "send": {}, } raw_sns_message = { "Type": "Notification", @@ -258,22 +320,30 @@ class AmazonSESNotificationsTests(WebhookTestCase, AmazonSESWebhookTestsMixin): "Message": json.dumps(raw_ses_event) + "\n", "Timestamp": "2018-03-26T17:58:59.675Z", } - response = self.post_from_sns('/anymail/amazon_ses/tracking/', raw_sns_message) + response = self.post_from_sns("/anymail/amazon_ses/tracking/", raw_sns_message) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=AmazonSESTrackingWebhookView, - event=ANY, esp_name='Amazon SES') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=AmazonSESTrackingWebhookView, + event=ANY, + esp_name="Amazon SES", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "sent") self.assertEqual(event.esp_event, raw_ses_event) + # timestamp from SNS: self.assertEqual( event.timestamp, - datetime(2018, 3, 26, 17, 58, 59, microsecond=675000, tzinfo=timezone.utc) - ) # SNS - self.assertEqual(event.message_id, "7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000") + datetime(2018, 3, 26, 17, 58, 59, microsecond=675000, tzinfo=timezone.utc), + ) + self.assertEqual( + event.message_id, "7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000" + ) self.assertEqual(event.event_id, "19ba9823-d7f2-53c1-860e-cb10e0d13dfc") self.assertEqual(event.recipient, "recipient@example.com") - self.assertEqual(event.tags, []) # Anymail doesn't load Amazon SES "Message Tags" + # Anymail doesn't load Amazon SES "Message Tags": + self.assertEqual(event.tags, []) self.assertEqual(event.metadata, {}) def test_reject_event(self): @@ -284,9 +354,7 @@ class AmazonSESNotificationsTests(WebhookTestCase, AmazonSESWebhookTestsMixin): "messageId": "7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000", "destination": ["recipient@example.com"], }, - "reject": { - "reason": "Bad content" - } + "reject": {"reason": "Bad content"}, } raw_sns_message = { "Type": "Notification", @@ -294,11 +362,15 @@ class AmazonSESNotificationsTests(WebhookTestCase, AmazonSESWebhookTestsMixin): "Message": json.dumps(raw_ses_event) + "\n", "Timestamp": "2018-03-26T17:58:59.675Z", } - response = self.post_from_sns('/anymail/amazon_ses/tracking/', raw_sns_message) + response = self.post_from_sns("/anymail/amazon_ses/tracking/", raw_sns_message) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=AmazonSESTrackingWebhookView, - event=ANY, esp_name='Amazon SES') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=AmazonSESTrackingWebhookView, + event=ANY, + esp_name="Amazon SES", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "rejected") self.assertEqual(event.reject_reason, "blocked") self.assertEqual(event.description, "Bad content") @@ -314,8 +386,9 @@ class AmazonSESNotificationsTests(WebhookTestCase, AmazonSESWebhookTestsMixin): "open": { "ipAddress": "192.0.2.1", "timestamp": "2017-08-09T22:00:19.652Z", - "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_3 like Mac OS X)..." - } + "userAgent": "Mozilla/5.0" + " (iPhone; CPU iPhone OS 10_3_3 like Mac OS X)...", + }, } raw_sns_message = { "Type": "Notification", @@ -323,14 +396,21 @@ class AmazonSESNotificationsTests(WebhookTestCase, AmazonSESWebhookTestsMixin): "Message": json.dumps(raw_ses_event) + "\n", "Timestamp": "2018-03-26T17:58:59.675Z", } - response = self.post_from_sns('/anymail/amazon_ses/tracking/', raw_sns_message) + response = self.post_from_sns("/anymail/amazon_ses/tracking/", raw_sns_message) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=AmazonSESTrackingWebhookView, - event=ANY, esp_name='Amazon SES') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=AmazonSESTrackingWebhookView, + event=ANY, + esp_name="Amazon SES", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "opened") self.assertEqual(event.recipient, "recipient@example.com") - self.assertEqual(event.user_agent, "Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_3 like Mac OS X)...") + self.assertEqual( + event.user_agent, + "Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_3 like Mac OS X)...", + ) def test_click_event(self): raw_ses_event = { @@ -343,12 +423,13 @@ class AmazonSESNotificationsTests(WebhookTestCase, AmazonSESWebhookTestsMixin): "samplekey1": ["samplevalue1"], }, "timestamp": "2017-08-09T23:51:25.570Z", - "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36..." + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" + " AppleWebKit/537.36...", }, "mail": { "destination": ["recipient@example.com"], "messageId": "7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000", - } + }, } raw_sns_message = { "Type": "Notification", @@ -356,15 +437,24 @@ class AmazonSESNotificationsTests(WebhookTestCase, AmazonSESWebhookTestsMixin): "Message": json.dumps(raw_ses_event) + "\n", "Timestamp": "2018-03-26T17:58:59.675Z", } - response = self.post_from_sns('/anymail/amazon_ses/tracking/', raw_sns_message) + response = self.post_from_sns("/anymail/amazon_ses/tracking/", raw_sns_message) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=AmazonSESTrackingWebhookView, - event=ANY, esp_name='Amazon SES') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=AmazonSESTrackingWebhookView, + event=ANY, + esp_name="Amazon SES", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "clicked") self.assertEqual(event.recipient, "recipient@example.com") - self.assertEqual(event.user_agent, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36...") - self.assertEqual(event.click_url, "https://docs.aws.amazon.com/ses/latest/DeveloperGuide/") + self.assertEqual( + event.user_agent, + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36...", + ) + self.assertEqual( + event.click_url, "https://docs.aws.amazon.com/ses/latest/DeveloperGuide/" + ) def test_rendering_failure_event(self): raw_ses_event = { @@ -374,9 +464,10 @@ class AmazonSESNotificationsTests(WebhookTestCase, AmazonSESWebhookTestsMixin): "destination": ["recipient@example.com"], }, "failure": { - "errorMessage": "Attribute 'attributeName' is not present in the rendering data.", - "templateName": "MyTemplate" - } + "errorMessage": "Attribute 'attributeName' is not present" + " in the rendering data.", + "templateName": "MyTemplate", + }, } raw_sns_message = { "Type": "Notification", @@ -384,14 +475,21 @@ class AmazonSESNotificationsTests(WebhookTestCase, AmazonSESWebhookTestsMixin): "Message": json.dumps(raw_ses_event) + "\n", "Timestamp": "2018-03-26T17:58:59.675Z", } - response = self.post_from_sns('/anymail/amazon_ses/tracking/', raw_sns_message) + response = self.post_from_sns("/anymail/amazon_ses/tracking/", raw_sns_message) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=AmazonSESTrackingWebhookView, - event=ANY, esp_name='Amazon SES') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=AmazonSESTrackingWebhookView, + event=ANY, + esp_name="Amazon SES", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "failed") self.assertEqual(event.recipient, "recipient@example.com") - self.assertEqual(event.description, "Attribute 'attributeName' is not present in the rendering data.") + self.assertEqual( + event.description, + "Attribute 'attributeName' is not present in the rendering data.", + ) def test_incorrect_received_event(self): """The tracking webhook should warn if it receives inbound events""" @@ -403,13 +501,14 @@ class AmazonSESNotificationsTests(WebhookTestCase, AmazonSESWebhookTestsMixin): } with self.assertRaisesMessage( AnymailConfigurationError, - "You seem to have set an Amazon SES *inbound* receipt rule to publish to an SNS Topic that posts " - "to Anymail's *tracking* webhook URL. (SNS TopicArn arn:aws:sns:us-east-1:111111111111:SES_Inbound)" + "You seem to have set an Amazon SES *inbound* receipt rule to publish to an" + " SNS Topic that posts to Anymail's *tracking* webhook URL. (SNS TopicArn" + " arn:aws:sns:us-east-1:111111111111:SES_Inbound)", ): - self.post_from_sns('/anymail/amazon_ses/tracking/', raw_sns_message) + self.post_from_sns("/anymail/amazon_ses/tracking/", raw_sns_message) -@tag('amazon_ses') +@tag("amazon_ses") class AmazonSESSubscriptionManagementTests(WebhookTestCase, AmazonSESWebhookTestsMixin): # Anymail will automatically respond to SNS subscription notifications # if Anymail is configured to require basic auth via WEBHOOK_SECRET. @@ -417,15 +516,22 @@ class AmazonSESSubscriptionManagementTests(WebhookTestCase, AmazonSESWebhookTest def setUp(self): super().setUp() - # Mock boto3.session.Session().client('sns').confirm_subscription (and any other client operations) - # (We could also use botocore.stub.Stubber, but mock works well with our test structure) - self.patch_boto3_session = patch('anymail.webhooks.amazon_ses.boto3.session.Session', autospec=True) - self.mock_session = self.patch_boto3_session.start() # boto3.session.Session + # Mock boto3.session.Session().client('sns').confirm_subscription (and any + # other client operations). (We could also use botocore.stub.Stubber, but mock + # works well with our test structure.) + self.patch_boto3_session = patch( + "anymail.webhooks.amazon_ses.boto3.session.Session", autospec=True + ) + #: boto3.session.Session + self.mock_session = self.patch_boto3_session.start() self.addCleanup(self.patch_boto3_session.stop) - self.mock_client = self.mock_session.return_value.client # boto3.session.Session().client - self.mock_client_instance = self.mock_client.return_value # boto3.session.Session().client('sns', ...) + #: boto3.session.Session().client + self.mock_client = self.mock_session.return_value.client + #: boto3.session.Session().client('sns', ...) + self.mock_client_instance = self.mock_client.return_value self.mock_client_instance.confirm_subscription.return_value = { - 'SubscriptionArn': 'arn:aws:sns:us-west-2:123456789012:SES_Notifications:aaaaaaa-...' + "SubscriptionArn": "arn:aws:sns:us-west-2:123456789012:SES_Notifications" + ":aaaaaaa-..." } SNS_SUBSCRIPTION_CONFIRMATION = { @@ -433,23 +539,32 @@ class AmazonSESSubscriptionManagementTests(WebhookTestCase, AmazonSESWebhookTest "MessageId": "165545c9-2a5c-472c-8df2-7ff2be2b3b1b", "Token": "EXAMPLE_TOKEN", "TopicArn": "arn:aws:sns:us-west-2:123456789012:SES_Notifications", - "Message": "You have chosen to subscribe ...\nTo confirm..., visit the SubscribeURL included in this message.", - "SubscribeURL": "https://sns.us-west-2.amazonaws.com/?Action=ConfirmSubscription&TopicArn=...", + "Message": "You have chosen to subscribe ...\n" + "To confirm..., visit the SubscribeURL included in this message.", + "SubscribeURL": "https://sns.us-west-2.amazonaws.com/" + "?Action=ConfirmSubscription&TopicArn=...", "Timestamp": "2012-04-26T20:45:04.751Z", "SignatureVersion": "1", "Signature": "EXAMPLE-SIGNATURE==", - "SigningCertURL": "https://sns.us-west-2.amazonaws.com/SimpleNotificationService-12345abcde.pem" + "SigningCertURL": "https://sns.us-west-2.amazonaws.com/" + "SimpleNotificationService-12345abcde.pem", } def test_sns_subscription_auto_confirmation(self): """Anymail webhook will auto-confirm SNS topic subscriptions""" - response = self.post_from_sns('/anymail/amazon_ses/tracking/', self.SNS_SUBSCRIPTION_CONFIRMATION) + response = self.post_from_sns( + "/anymail/amazon_ses/tracking/", self.SNS_SUBSCRIPTION_CONFIRMATION + ) self.assertEqual(response.status_code, 200) # auto-confirmed: - self.mock_client.assert_called_once_with('sns', config=ANY, region_name="us-west-2") + self.mock_client.assert_called_once_with( + "sns", config=ANY, region_name="us-west-2" + ) self.mock_client_instance.confirm_subscription.assert_called_once_with( TopicArn="arn:aws:sns:us-west-2:123456789012:SES_Notifications", - Token="EXAMPLE_TOKEN", AuthenticateOnUnsubscribe="true") + Token="EXAMPLE_TOKEN", + AuthenticateOnUnsubscribe="true", + ) # didn't notify receivers: self.assertEqual(self.tracking_handler.call_count, 0) self.assertEqual(self.inbound_handler.call_count, 0) @@ -457,43 +572,68 @@ class AmazonSESSubscriptionManagementTests(WebhookTestCase, AmazonSESWebhookTest def test_sns_subscription_confirmation_failure(self): """Auto-confirmation allows error through if confirm call fails""" from botocore.exceptions import ClientError - self.mock_client_instance.confirm_subscription.side_effect = ClientError({ - 'Error': { - 'Type': 'Sender', - 'Code': 'InternalError', - 'Message': 'Gremlins!', + + self.mock_client_instance.confirm_subscription.side_effect = ClientError( + { + "Error": { + "Type": "Sender", + "Code": "InternalError", + "Message": "Gremlins!", + }, + "ResponseMetadata": { + "RequestId": "aaaaaaaa-2222-1111-8888-bbbb3333bbbb", + "HTTPStatusCode": 500, + }, }, - 'ResponseMetadata': { - 'RequestId': 'aaaaaaaa-2222-1111-8888-bbbb3333bbbb', - 'HTTPStatusCode': 500, - } - }, operation_name="confirm_subscription") + operation_name="confirm_subscription", + ) with self.assertRaisesMessage(ClientError, "Gremlins!"): - self.post_from_sns('/anymail/amazon_ses/tracking/', self.SNS_SUBSCRIPTION_CONFIRMATION) + self.post_from_sns( + "/anymail/amazon_ses/tracking/", self.SNS_SUBSCRIPTION_CONFIRMATION + ) # didn't notify receivers: self.assertEqual(self.tracking_handler.call_count, 0) self.assertEqual(self.inbound_handler.call_count, 0) @override_settings(ANYMAIL_AMAZON_SES_CLIENT_PARAMS={"region_name": "us-east-1"}) def test_sns_subscription_confirmation_different_region(self): - """Anymail confirms the subscription in the SNS Topic's own region, rather than any default region""" + """ + Anymail confirms the subscription in the SNS Topic's own region, + rather than any default region + """ # (The SNS_SUBSCRIPTION_CONFIRMATION above has a TopicArn in region us-west-2) - self.post_from_sns('/anymail/amazon_ses/tracking/', self.SNS_SUBSCRIPTION_CONFIRMATION) - self.mock_client.assert_called_once_with('sns', config=ANY, region_name="us-west-2") + self.post_from_sns( + "/anymail/amazon_ses/tracking/", self.SNS_SUBSCRIPTION_CONFIRMATION + ) + self.mock_client.assert_called_once_with( + "sns", config=ANY, region_name="us-west-2" + ) - @override_settings(ANYMAIL={}) # clear WEBHOOK_SECRET setting from base WebhookTestCase + # clear WEBHOOK_SECRET setting from base WebhookTestCase + @override_settings(ANYMAIL={}) def test_sns_subscription_confirmation_auth_disabled(self): - """Anymail *won't* auto-confirm SNS subscriptions if WEBHOOK_SECRET isn't in use""" - warnings.simplefilter("ignore", AnymailInsecureWebhookWarning) # (this gets tested elsewhere) - with self.assertLogs('django.security.AnymailWebhookValidationFailure') as cm: - response = self.post_from_sns('/anymail/amazon_ses/tracking/', self.SNS_SUBSCRIPTION_CONFIRMATION) + """ + Anymail *won't* auto-confirm SNS subscriptions if WEBHOOK_SECRET isn't in use + """ + # (warning gets tested elsewhere) + warnings.simplefilter("ignore", AnymailInsecureWebhookWarning) + with self.assertLogs("django.security.AnymailWebhookValidationFailure") as cm: + response = self.post_from_sns( + "/anymail/amazon_ses/tracking/", self.SNS_SUBSCRIPTION_CONFIRMATION + ) self.assertEqual(response.status_code, 400) # bad request self.assertEqual( - ["Anymail received an unexpected SubscriptionConfirmation request for Amazon SNS topic " - "'arn:aws:sns:us-west-2:123456789012:SES_Notifications'. (Anymail can automatically confirm " - "SNS subscriptions if you set a WEBHOOK_SECRET and use that in your SNS notification url. Or " - "you can manually confirm this subscription in the SNS dashboard with token 'EXAMPLE_TOKEN'.)"], - [record.getMessage() for record in cm.records]) + [ + "Anymail received an unexpected SubscriptionConfirmation request for" + " Amazon SNS topic" + " 'arn:aws:sns:us-west-2:123456789012:SES_Notifications'." + " (Anymail can automatically confirm SNS subscriptions if you set" + " a WEBHOOK_SECRET and use that in your SNS notification url." + " Or you can manually confirm this subscription in the SNS dashboard" + " with token 'EXAMPLE_TOKEN'.)" + ], + [record.getMessage() for record in cm.records], + ) # *didn't* try to confirm the subscription: self.assertEqual(self.mock_client_instance.confirm_subscription.call_count, 0) # didn't notify receivers: @@ -501,37 +641,55 @@ class AmazonSESSubscriptionManagementTests(WebhookTestCase, AmazonSESWebhookTest self.assertEqual(self.inbound_handler.call_count, 0) def test_sns_confirmation_success_notification(self): - """Anymail ignores the 'Successfully validated' notification after confirming an SNS subscription""" - response = self.post_from_sns('/anymail/amazon_ses/tracking/', { - "Type": "Notification", - "MessageId": "7fbca0d9-eeab-5285-ae27-f3f57f2e84b0", - "TopicArn": "arn:aws:sns:us-west-2:123456789012:SES_Notifications", - "Message": "Successfully validated SNS topic for Amazon SES event publishing.", - "Timestamp": "2018-03-21T16:58:45.077Z", - "SignatureVersion": "1", - "Signature": "EXAMPLE_SIGNATURE==", - "SigningCertURL": "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-12345abcde.pem", - "UnsubscribeURL": "https://sns.us-east-1.amazonaws.com/?Action=Unsubscribe...", - }) + """ + Anymail ignores the 'Successfully validated' notification + after confirming an SNS subscription + """ + response = self.post_from_sns( + "/anymail/amazon_ses/tracking/", + { + "Type": "Notification", + "MessageId": "7fbca0d9-eeab-5285-ae27-f3f57f2e84b0", + "TopicArn": "arn:aws:sns:us-west-2:123456789012:SES_Notifications", + "Message": "Successfully validated SNS topic" + " for Amazon SES event publishing.", + "Timestamp": "2018-03-21T16:58:45.077Z", + "SignatureVersion": "1", + "Signature": "EXAMPLE_SIGNATURE==", + "SigningCertURL": "https://sns.us-east-1.amazonaws.com" + "/SimpleNotificationService-12345abcde.pem", + "UnsubscribeURL": "https://sns.us-east-1.amazonaws.com" + "/?Action=Unsubscribe...", + }, + ) self.assertEqual(response.status_code, 200) # didn't notify receivers: self.assertEqual(self.tracking_handler.call_count, 0) self.assertEqual(self.inbound_handler.call_count, 0) def test_sns_unsubscribe_confirmation(self): - """Anymail ignores the UnsubscribeConfirmation SNS message after deleting a subscription""" - response = self.post_from_sns('/anymail/amazon_ses/tracking/', { - "Type": "UnsubscribeConfirmation", - "MessageId": "47138184-6831-46b8-8f7c-afc488602d7d", - "Token": "EXAMPLE_TOKEN", - "TopicArn": "arn:aws:sns:us-west-2:123456789012:SES_Notifications", - "Message": "You have chosen to deactivate subscription ...\nTo cancel ... visit the SubscribeURL...", - "SubscribeURL": "https://sns.us-west-2.amazonaws.com/?Action=ConfirmSubscription&TopicArn=...", - "Timestamp": "2012-04-26T20:06:41.581Z", - "SignatureVersion": "1", - "Signature": "EXAMPLE_SIGNATURE==", - "SigningCertURL": "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-12345abcde.pem", - }) + """ + Anymail ignores the UnsubscribeConfirmation SNS message + after deleting a subscription + """ + response = self.post_from_sns( + "/anymail/amazon_ses/tracking/", + { + "Type": "UnsubscribeConfirmation", + "MessageId": "47138184-6831-46b8-8f7c-afc488602d7d", + "Token": "EXAMPLE_TOKEN", + "TopicArn": "arn:aws:sns:us-west-2:123456789012:SES_Notifications", + "Message": "You have chosen to deactivate subscription ...\n" + "To cancel ... visit the SubscribeURL...", + "SubscribeURL": "https://sns.us-west-2.amazonaws.com" + "/?Action=ConfirmSubscription&TopicArn=...", + "Timestamp": "2012-04-26T20:06:41.581Z", + "SignatureVersion": "1", + "Signature": "EXAMPLE_SIGNATURE==", + "SigningCertURL": "https://sns.us-east-1.amazonaws.com" + "/SimpleNotificationService-12345abcde.pem", + }, + ) self.assertEqual(response.status_code, 200) # *didn't* try to use the Token to re-enable the subscription: self.assertEqual(self.mock_client_instance.confirm_subscription.call_count, 0) @@ -541,8 +699,13 @@ class AmazonSESSubscriptionManagementTests(WebhookTestCase, AmazonSESWebhookTest @override_settings(ANYMAIL_AMAZON_SES_AUTO_CONFIRM_SNS_SUBSCRIPTIONS=False) def test_disable_auto_confirmation(self): - """The ANYMAIL setting AMAZON_SES_AUTO_CONFIRM_SNS_SUBSCRIPTIONS will disable this feature""" - response = self.post_from_sns('/anymail/amazon_ses/tracking/', self.SNS_SUBSCRIPTION_CONFIRMATION) + """ + The ANYMAIL setting AMAZON_SES_AUTO_CONFIRM_SNS_SUBSCRIPTIONS + will disable this feature + """ + response = self.post_from_sns( + "/anymail/amazon_ses/tracking/", self.SNS_SUBSCRIPTION_CONFIRMATION + ) self.assertEqual(response.status_code, 200) # *didn't* try to subscribe: self.assertEqual(self.mock_session.call_count, 0) diff --git a/tests/test_base_backends.py b/tests/test_base_backends.py index d9f65e6..bb73a06 100644 --- a/tests/test_base_backends.py +++ b/tests/test_base_backends.py @@ -1,4 +1,4 @@ -from django.test import override_settings, SimpleTestCase, tag +from django.test import SimpleTestCase, override_settings, tag from anymail.backends.base_requests import AnymailRequestsBackend, RequestsPayload from anymail.message import AnymailMessage, AnymailRecipientStatus @@ -21,7 +21,7 @@ class MinimalRequestsBackend(AnymailRequestsBackend): return MinimalRequestsPayload(message, defaults, self, **_payload_init) def parse_recipient_status(self, response, payload, message): - return {'to@example.com': AnymailRecipientStatus('message-id', 'sent')} + return {"to@example.com": AnymailRecipientStatus("message-id", "sent")} class MinimalRequestsPayload(RequestsPayload): @@ -41,13 +41,15 @@ class MinimalRequestsPayload(RequestsPayload): add_attachment = _noop -@override_settings(EMAIL_BACKEND='tests.test_base_backends.MinimalRequestsBackend') +@override_settings(EMAIL_BACKEND="tests.test_base_backends.MinimalRequestsBackend") class RequestsBackendBaseTestCase(RequestsBackendMockAPITestCase): """Test common functionality in AnymailRequestsBackend""" def setUp(self): super().setUp() - self.message = AnymailMessage('Subject', 'Text Body', 'from@example.com', ['to@example.com']) + self.message = AnymailMessage( + "Subject", "Text Body", "from@example.com", ["to@example.com"] + ) def test_minimal_requests_backend(self): """Make sure the testing backend defined above actually works""" @@ -57,23 +59,25 @@ class RequestsBackendBaseTestCase(RequestsBackendMockAPITestCase): def test_timeout_default(self): """All requests have a 30 second default timeout""" self.message.send() - timeout = self.get_api_call_arg('timeout') + timeout = self.get_api_call_arg("timeout") self.assertEqual(timeout, 30) @override_settings(ANYMAIL_REQUESTS_TIMEOUT=5) def test_timeout_setting(self): """You can use the Anymail setting REQUESTS_TIMEOUT to override the default""" self.message.send() - timeout = self.get_api_call_arg('timeout') + timeout = self.get_api_call_arg("timeout") self.assertEqual(timeout, 5) -@tag('live') -@override_settings(EMAIL_BACKEND='tests.test_base_backends.MinimalRequestsBackend') +@tag("live") +@override_settings(EMAIL_BACKEND="tests.test_base_backends.MinimalRequestsBackend") class RequestsBackendLiveTestCase(AnymailTestMixin, SimpleTestCase): @override_settings(ANYMAIL_DEBUG_API_REQUESTS=True) def test_debug_logging(self): - message = AnymailMessage('Subject', 'Text Body', 'from@example.com', ['to@example.com']) + message = AnymailMessage( + "Subject", "Text Body", "from@example.com", ["to@example.com"] + ) message._payload_init = dict( data="Request body", headers={ @@ -84,8 +88,9 @@ class RequestsBackendLiveTestCase(AnymailTestMixin, SimpleTestCase): with self.assertPrints("===== Anymail API request") as outbuf: message.send() - # Header order and response data vary to much to do a full comparison, but make sure - # that the output contains some expected pieces of the request and the response" + # Header order and response data vary too much to do a full comparison, + # but make sure that the output contains some expected pieces of the request + # and the response output = outbuf.getvalue() self.assertIn("\nPOST https://httpbin.org/post\n", output) self.assertIn("\nUser-Agent: django-anymail/", output) @@ -98,7 +103,9 @@ class RequestsBackendLiveTestCase(AnymailTestMixin, SimpleTestCase): def test_no_debug_logging(self): # Make sure it doesn't output anything when DEBUG_API_REQUESTS is not set - message = AnymailMessage('Subject', 'Text Body', 'from@example.com', ['to@example.com']) + message = AnymailMessage( + "Subject", "Text Body", "from@example.com", ["to@example.com"] + ) message._payload_init = dict( data="Request body", headers={ diff --git a/tests/test_checks.py b/tests/test_checks.py index c4d9c2f..f2d8f75 100644 --- a/tests/test_checks.py +++ b/tests/test_checks.py @@ -11,20 +11,32 @@ class DeprecatedSettingsTests(AnymailTestMixin, SimpleTestCase): @override_settings(ANYMAIL={"WEBHOOK_AUTHORIZATION": "abcde:12345"}) def test_webhook_authorization(self): errors = check_deprecated_settings(None) - self.assertEqual(errors, [checks.Error( - "The ANYMAIL setting 'WEBHOOK_AUTHORIZATION' has been renamed 'WEBHOOK_SECRET' to improve security.", - hint="You must update your settings.py.", - id="anymail.E001", - )]) + self.assertEqual( + errors, + [ + checks.Error( + "The ANYMAIL setting 'WEBHOOK_AUTHORIZATION' has been renamed" + " 'WEBHOOK_SECRET' to improve security.", + hint="You must update your settings.py.", + id="anymail.E001", + ) + ], + ) @override_settings(ANYMAIL_WEBHOOK_AUTHORIZATION="abcde:12345", ANYMAIL={}) def test_anymail_webhook_authorization(self): errors = check_deprecated_settings(None) - self.assertEqual(errors, [checks.Error( - "The ANYMAIL_WEBHOOK_AUTHORIZATION setting has been renamed ANYMAIL_WEBHOOK_SECRET to improve security.", - hint="You must update your settings.py.", - id="anymail.E001", - )]) + self.assertEqual( + errors, + [ + checks.Error( + "The ANYMAIL_WEBHOOK_AUTHORIZATION setting has been renamed" + " ANYMAIL_WEBHOOK_SECRET to improve security.", + hint="You must update your settings.py.", + id="anymail.E001", + ) + ], + ) class InsecureSettingsTests(AnymailTestMixin, SimpleTestCase): diff --git a/tests/test_general_backend.py b/tests/test_general_backend.py index 1112c39..d0d55d6 100644 --- a/tests/test_general_backend.py +++ b/tests/test_general_backend.py @@ -10,7 +10,12 @@ from django.utils.functional import Promise from django.utils.translation import gettext_lazy from anymail.backends.test import EmailBackend as TestBackend, TestPayload -from anymail.exceptions import AnymailConfigurationError, AnymailError, AnymailInvalidAddress, AnymailUnsupportedFeature +from anymail.exceptions import ( + AnymailConfigurationError, + AnymailError, + AnymailInvalidAddress, + AnymailUnsupportedFeature, +) from anymail.message import AnymailMessage from anymail.utils import get_anymail_setting @@ -19,25 +24,31 @@ from .utils import AnymailTestMixin class SettingsTestBackend(TestBackend): """(useful only for these tests)""" + def __init__(self, *args, **kwargs): esp_name = self.esp_name - self.sample_setting = get_anymail_setting('sample_setting', esp_name=esp_name, - kwargs=kwargs, allow_bare=True) - self.username = get_anymail_setting('username', esp_name=esp_name, kwargs=kwargs, - default=None, allow_bare=True) - self.password = get_anymail_setting('password', esp_name=esp_name, kwargs=kwargs, - default=None, allow_bare=True) + self.sample_setting = get_anymail_setting( + "sample_setting", esp_name=esp_name, kwargs=kwargs, allow_bare=True + ) + self.username = get_anymail_setting( + "username", esp_name=esp_name, kwargs=kwargs, default=None, allow_bare=True + ) + self.password = get_anymail_setting( + "password", esp_name=esp_name, kwargs=kwargs, default=None, allow_bare=True + ) super().__init__(*args, **kwargs) -@override_settings(EMAIL_BACKEND='anymail.backends.test.EmailBackend') +@override_settings(EMAIL_BACKEND="anymail.backends.test.EmailBackend") class TestBackendTestCase(AnymailTestMixin, SimpleTestCase): """Base TestCase using Anymail's Test EmailBackend""" def setUp(self): super().setUp() # Simple message useful for many tests - self.message = AnymailMessage('Subject', 'Text Body', 'from@example.com', ['to@example.com']) + self.message = AnymailMessage( + "Subject", "Text Body", "from@example.com", ["to@example.com"] + ) @staticmethod def get_send_count(): @@ -45,7 +56,9 @@ class TestBackendTestCase(AnymailTestMixin, SimpleTestCase): try: return len(mail.outbox) except AttributeError: - return 0 # mail.outbox not initialized by either Anymail test or Django locmem backend + # mail.outbox not initialized by either Anymail test + # or Django locmem backend + return 0 @staticmethod def get_send_params(): @@ -53,35 +66,40 @@ class TestBackendTestCase(AnymailTestMixin, SimpleTestCase): try: return mail.outbox[-1].anymail_test_params except IndexError: - raise IndexError("No messages have been sent through the Anymail test backend") + raise IndexError( + "No messages have been sent through the Anymail test backend" + ) except AttributeError: - raise AttributeError("The last message sent was not processed through the Anymail test backend") + raise AttributeError( + "The last message sent was not processed" + " through the Anymail test backend" + ) -@override_settings(EMAIL_BACKEND='tests.test_general_backend.SettingsTestBackend') +@override_settings(EMAIL_BACKEND="tests.test_general_backend.SettingsTestBackend") class BackendSettingsTests(TestBackendTestCase): """Test settings initializations for Anymail EmailBackends""" - @override_settings(ANYMAIL={'TEST_SAMPLE_SETTING': 'setting_from_anymail_settings'}) + @override_settings(ANYMAIL={"TEST_SAMPLE_SETTING": "setting_from_anymail_settings"}) def test_anymail_setting(self): """ESP settings usually come from ANYMAIL settings dict""" backend = get_connection() - self.assertEqual(backend.sample_setting, 'setting_from_anymail_settings') + self.assertEqual(backend.sample_setting, "setting_from_anymail_settings") - @override_settings(TEST_SAMPLE_SETTING='setting_from_bare_settings') + @override_settings(TEST_SAMPLE_SETTING="setting_from_bare_settings") def test_bare_setting(self): """ESP settings are also usually allowed at root of settings file""" backend = get_connection() - self.assertEqual(backend.sample_setting, 'setting_from_bare_settings') + self.assertEqual(backend.sample_setting, "setting_from_bare_settings") - @override_settings(ANYMAIL={'TEST_SAMPLE_SETTING': 'setting_from_settings'}) + @override_settings(ANYMAIL={"TEST_SAMPLE_SETTING": "setting_from_settings"}) def test_connection_kwargs_overrides_settings(self): """Can override settings file in get_connection""" backend = get_connection() - self.assertEqual(backend.sample_setting, 'setting_from_settings') + self.assertEqual(backend.sample_setting, "setting_from_settings") - backend = get_connection(sample_setting='setting_from_kwargs') - self.assertEqual(backend.sample_setting, 'setting_from_kwargs') + backend = get_connection(sample_setting="setting_from_kwargs") + self.assertEqual(backend.sample_setting, "setting_from_kwargs") def test_missing_setting(self): """Settings without defaults must be provided""" @@ -89,22 +107,28 @@ class BackendSettingsTests(TestBackendTestCase): get_connection() self.assertIsInstance(cm.exception, ImproperlyConfigured) # Django consistency errmsg = str(cm.exception) - self.assertRegex(errmsg, r'\bTEST_SAMPLE_SETTING\b') - self.assertRegex(errmsg, r'\bANYMAIL_TEST_SAMPLE_SETTING\b') + self.assertRegex(errmsg, r"\bTEST_SAMPLE_SETTING\b") + self.assertRegex(errmsg, r"\bANYMAIL_TEST_SAMPLE_SETTING\b") - @override_settings(ANYMAIL={'TEST_USERNAME': 'username_from_settings', - 'TEST_PASSWORD': 'password_from_settings', - 'TEST_SAMPLE_SETTING': 'required'}) + @override_settings( + ANYMAIL={ + "TEST_USERNAME": "username_from_settings", + "TEST_PASSWORD": "password_from_settings", + "TEST_SAMPLE_SETTING": "required", + } + ) def test_username_password_kwargs_overrides(self): """Overrides for 'username' and 'password' should work like other overrides""" # These are special-cased because of default args in Django core mail functions. backend = get_connection() - self.assertEqual(backend.username, 'username_from_settings') - self.assertEqual(backend.password, 'password_from_settings') + self.assertEqual(backend.username, "username_from_settings") + self.assertEqual(backend.password, "password_from_settings") - backend = get_connection(username='username_from_kwargs', password='password_from_kwargs') - self.assertEqual(backend.username, 'username_from_kwargs') - self.assertEqual(backend.password, 'password_from_kwargs') + backend = get_connection( + username="username_from_kwargs", password="password_from_kwargs" + ) + self.assertEqual(backend.username, "username_from_kwargs") + self.assertEqual(backend.password, "password_from_kwargs") class UnsupportedFeatureTests(TestBackendTestCase): @@ -113,124 +137,149 @@ class UnsupportedFeatureTests(TestBackendTestCase): def test_unsupported_feature(self): """Unsupported features raise AnymailUnsupportedFeature""" # Test EmailBackend doesn't support non-HTML alternative parts - self.message.attach_alternative(b'FAKE_MP3_DATA', 'audio/mpeg') + self.message.attach_alternative(b"FAKE_MP3_DATA", "audio/mpeg") with self.assertRaises(AnymailUnsupportedFeature): self.message.send() - @override_settings(ANYMAIL={ - 'IGNORE_UNSUPPORTED_FEATURES': True - }) + @override_settings(ANYMAIL={"IGNORE_UNSUPPORTED_FEATURES": True}) def test_ignore_unsupported_features(self): """Setting prevents exception""" - self.message.attach_alternative(b'FAKE_MP3_DATA', 'audio/mpeg') + self.message.attach_alternative(b"FAKE_MP3_DATA", "audio/mpeg") self.message.send() # should not raise exception class SendDefaultsTests(TestBackendTestCase): """Tests backend support for global SEND_DEFAULTS and _SEND_DEFAULTS""" - @override_settings(ANYMAIL={ - 'SEND_DEFAULTS': { - # This isn't an exhaustive list of Anymail message attrs; just one of each type - 'metadata': {'global': 'globalvalue'}, - 'send_at': datetime(2016, 5, 12, 4, 17, 0, tzinfo=timezone.utc), - 'tags': ['globaltag'], - 'template_id': 'my-template', - 'track_clicks': True, - 'esp_extra': {'globalextra': 'globalsetting'}, + @override_settings( + ANYMAIL={ + "SEND_DEFAULTS": { + # This isn't an exhaustive list of Anymail message attrs; + # just one of each type + "metadata": {"global": "globalvalue"}, + "send_at": datetime(2016, 5, 12, 4, 17, 0, tzinfo=timezone.utc), + "tags": ["globaltag"], + "template_id": "my-template", + "track_clicks": True, + "esp_extra": {"globalextra": "globalsetting"}, + } } - }) + ) def test_send_defaults(self): """Test that (non-esp-specific) send defaults are applied""" self.message.send() params = self.get_send_params() # All these values came from ANYMAIL_SEND_DEFAULTS: - self.assertEqual(params['metadata'], {'global': 'globalvalue'}) - self.assertEqual(params['send_at'], datetime(2016, 5, 12, 4, 17, 0, tzinfo=timezone.utc)) - self.assertEqual(params['tags'], ['globaltag']) - self.assertEqual(params['template_id'], 'my-template') - self.assertEqual(params['track_clicks'], True) - self.assertEqual(params['globalextra'], 'globalsetting') # Test EmailBackend merges esp_extra into params + self.assertEqual(params["metadata"], {"global": "globalvalue"}) + self.assertEqual( + params["send_at"], datetime(2016, 5, 12, 4, 17, 0, tzinfo=timezone.utc) + ) + self.assertEqual(params["tags"], ["globaltag"]) + self.assertEqual(params["template_id"], "my-template") + self.assertEqual(params["track_clicks"], True) + # Test EmailBackend merges esp_extra into params: + self.assertEqual(params["globalextra"], "globalsetting") - @override_settings(ANYMAIL={ - 'TEST_SEND_DEFAULTS': { # "TEST" is the name of the Test EmailBackend's ESP - 'metadata': {'global': 'espvalue'}, - 'tags': ['esptag'], - 'track_opens': False, - 'esp_extra': {'globalextra': 'espsetting'}, + @override_settings( + ANYMAIL={ + # SEND_DEFAULTS for the Test EmailBackend, because + # "TEST" is the name of the Test EmailBackend's ESP + "TEST_SEND_DEFAULTS": { + "metadata": {"global": "espvalue"}, + "tags": ["esptag"], + "track_opens": False, + "esp_extra": {"globalextra": "espsetting"}, + } } - }) + ) def test_esp_send_defaults(self): """Test that esp-specific send defaults are applied""" self.message.send() params = self.get_send_params() - self.assertEqual(params['metadata'], {'global': 'espvalue'}) - self.assertEqual(params['tags'], ['esptag']) - self.assertEqual(params['track_opens'], False) - self.assertEqual(params['globalextra'], 'espsetting') # Test EmailBackend merges esp_extra into params + self.assertEqual(params["metadata"], {"global": "espvalue"}) + self.assertEqual(params["tags"], ["esptag"]) + self.assertEqual(params["track_opens"], False) + # Test EmailBackend merges esp_extra into params: + self.assertEqual(params["globalextra"], "espsetting") - @override_settings(ANYMAIL={ - 'SEND_DEFAULTS': { - 'metadata': {'global': 'globalvalue', 'other': 'othervalue'}, - 'tags': ['globaltag'], - 'track_clicks': True, - 'track_opens': False, - 'esp_extra': {'globalextra': 'globalsetting'}, + @override_settings( + ANYMAIL={ + "SEND_DEFAULTS": { + "metadata": {"global": "globalvalue", "other": "othervalue"}, + "tags": ["globaltag"], + "track_clicks": True, + "track_opens": False, + "esp_extra": {"globalextra": "globalsetting"}, + } } - }) + ) def test_send_defaults_combine_with_message(self): """Individual message settings are *merged into* the global send defaults""" - self.message.metadata = {'message': 'messagevalue', 'other': 'override'} - self.message.tags = ['messagetag'] + self.message.metadata = {"message": "messagevalue", "other": "override"} + self.message.tags = ["messagetag"] self.message.track_clicks = False - self.message.esp_extra = {'messageextra': 'messagesetting'} + self.message.esp_extra = {"messageextra": "messagesetting"} self.message.send() params = self.get_send_params() - self.assertEqual(params['metadata'], { # metadata merged - 'global': 'globalvalue', # global default preserved - 'message': 'messagevalue', # message setting added - 'other': 'override'}) # message setting overrides global default - self.assertEqual(params['tags'], ['globaltag', 'messagetag']) # tags concatenated - self.assertEqual(params['track_clicks'], False) # message overrides - self.assertEqual(params['track_opens'], False) # (no message setting) - self.assertEqual(params['globalextra'], 'globalsetting') - self.assertEqual(params['messageextra'], 'messagesetting') + self.assertEqual( + params["metadata"], + { # metadata merged + "global": "globalvalue", # global default preserved + "message": "messagevalue", # message setting added + "other": "override", # message setting overrides global default + }, + ) + # tags concatenated: + self.assertEqual(params["tags"], ["globaltag", "messagetag"]) + self.assertEqual(params["track_clicks"], False) # message overrides + self.assertEqual(params["track_opens"], False) # (no message setting) + self.assertEqual(params["globalextra"], "globalsetting") + self.assertEqual(params["messageextra"], "messagesetting") # Send another message to make sure original SEND_DEFAULTS unchanged - send_mail('subject', 'body', 'from@example.com', ['to@example.com']) + send_mail("subject", "body", "from@example.com", ["to@example.com"]) params = self.get_send_params() - self.assertEqual(params['metadata'], {'global': 'globalvalue', 'other': 'othervalue'}) - self.assertEqual(params['tags'], ['globaltag']) - self.assertEqual(params['track_clicks'], True) - self.assertEqual(params['track_opens'], False) - self.assertEqual(params['globalextra'], 'globalsetting') + self.assertEqual( + params["metadata"], {"global": "globalvalue", "other": "othervalue"} + ) + self.assertEqual(params["tags"], ["globaltag"]) + self.assertEqual(params["track_clicks"], True) + self.assertEqual(params["track_opens"], False) + self.assertEqual(params["globalextra"], "globalsetting") - @override_settings(ANYMAIL={ - 'SEND_DEFAULTS': { - # This isn't an exhaustive list of Anymail message attrs; just one of each type - 'metadata': {'global': 'globalvalue'}, - 'tags': ['globaltag'], - 'template_id': 'global-template', - 'esp_extra': {'globalextra': 'globalsetting'}, - }, - 'TEST_SEND_DEFAULTS': { # "TEST" is the name of the Test EmailBackend's ESP - 'merge_global_data': {'esp': 'espmerge'}, - 'metadata': {'esp': 'espvalue'}, - 'tags': ['esptag'], - 'esp_extra': {'espextra': 'espsetting'}, + @override_settings( + ANYMAIL={ + "SEND_DEFAULTS": { + # This isn't an exhaustive list of Anymail message attrs; + # just one of each type + "metadata": {"global": "globalvalue"}, + "tags": ["globaltag"], + "template_id": "global-template", + "esp_extra": {"globalextra": "globalsetting"}, + }, + # "TEST" is the name of the Test EmailBackend's ESP + "TEST_SEND_DEFAULTS": { + "merge_global_data": {"esp": "espmerge"}, + "metadata": {"esp": "espvalue"}, + "tags": ["esptag"], + "esp_extra": {"espextra": "espsetting"}, + }, } - }) + ) def test_esp_send_defaults_override_globals(self): """ESP-specific send defaults override *individual* global defaults""" self.message.send() params = self.get_send_params() - self.assertEqual(params['merge_global_data'], {'esp': 'espmerge'}) # esp-defaults only - self.assertEqual(params['metadata'], {'esp': 'espvalue'}) - self.assertEqual(params['tags'], ['esptag']) - self.assertEqual(params['template_id'], 'global-template') # global-defaults only - self.assertEqual(params['espextra'], 'espsetting') - self.assertNotIn('globalextra', params) # entire esp_extra is overriden by esp-send-defaults + # esp-defaults only: + self.assertEqual(params["merge_global_data"], {"esp": "espmerge"}) + self.assertEqual(params["metadata"], {"esp": "espvalue"}) + self.assertEqual(params["tags"], ["esptag"]) + # global-defaults only: + self.assertEqual(params["template_id"], "global-template") + self.assertEqual(params["espextra"], "espsetting") + # entire esp_extra is overriden by esp-send-defaults: + self.assertNotIn("globalextra", params) class LazyStringsTest(TestBackendTestCase): @@ -248,66 +297,69 @@ class LazyStringsTest(TestBackendTestCase): """ def assertNotLazy(self, s, msg=None): - self.assertNotIsInstance(s, Promise, - msg=msg or "String %r is lazy" % str(s)) + self.assertNotIsInstance(s, Promise, msg=msg or "String %r is lazy" % str(s)) def test_lazy_from(self): - # This sometimes ends up lazy when settings.DEFAULT_FROM_EMAIL is meant to be localized + # This sometimes ends up lazy when settings.DEFAULT_FROM_EMAIL + # is meant to be localized self.message.from_email = gettext_lazy('"Global Sales" ') self.message.send() params = self.get_send_params() - self.assertNotLazy(params['from'].address) + self.assertNotLazy(params["from"].address) def test_lazy_subject(self): self.message.subject = gettext_lazy("subject") self.message.send() params = self.get_send_params() - self.assertNotLazy(params['subject']) + self.assertNotLazy(params["subject"]) def test_lazy_body(self): self.message.body = gettext_lazy("text body") self.message.attach_alternative(gettext_lazy("html body"), "text/html") self.message.send() params = self.get_send_params() - self.assertNotLazy(params['text_body']) - self.assertNotLazy(params['html_body']) + self.assertNotLazy(params["text_body"]) + self.assertNotLazy(params["html_body"]) def test_lazy_headers(self): - self.message.extra_headers['X-Test'] = gettext_lazy("Test Header") + self.message.extra_headers["X-Test"] = gettext_lazy("Test Header") self.message.send() params = self.get_send_params() - self.assertNotLazy(params['extra_headers']['X-Test']) + self.assertNotLazy(params["extra_headers"]["X-Test"]) def test_lazy_attachments(self): - self.message.attach(gettext_lazy("test.csv"), gettext_lazy("test,csv,data"), "text/csv") + self.message.attach( + gettext_lazy("test.csv"), gettext_lazy("test,csv,data"), "text/csv" + ) self.message.attach(MIMEText(gettext_lazy("contact info"))) self.message.send() params = self.get_send_params() - self.assertNotLazy(params['attachments'][0].name) - self.assertNotLazy(params['attachments'][0].content) - self.assertNotLazy(params['attachments'][1].content) + self.assertNotLazy(params["attachments"][0].name) + self.assertNotLazy(params["attachments"][0].content) + self.assertNotLazy(params["attachments"][1].content) def test_lazy_tags(self): self.message.tags = [gettext_lazy("Shipping"), gettext_lazy("Sales")] self.message.send() params = self.get_send_params() - self.assertNotLazy(params['tags'][0]) - self.assertNotLazy(params['tags'][1]) + self.assertNotLazy(params["tags"][0]) + self.assertNotLazy(params["tags"][1]) def test_lazy_metadata(self): - self.message.metadata = {'order_type': gettext_lazy("Subscription")} + self.message.metadata = {"order_type": gettext_lazy("Subscription")} self.message.send() params = self.get_send_params() - self.assertNotLazy(params['metadata']['order_type']) + self.assertNotLazy(params["metadata"]["order_type"]) def test_lazy_merge_data(self): self.message.merge_data = { - 'to@example.com': {'duration': gettext_lazy("One Month")}} - self.message.merge_global_data = {'order_type': gettext_lazy("Subscription")} + "to@example.com": {"duration": gettext_lazy("One Month")} + } + self.message.merge_global_data = {"order_type": gettext_lazy("Subscription")} self.message.send() params = self.get_send_params() - self.assertNotLazy(params['merge_data']['to@example.com']['duration']) - self.assertNotLazy(params['merge_global_data']['order_type']) + self.assertNotLazy(params["merge_data"]["to@example.com"]["duration"]) + self.assertNotLazy(params["merge_global_data"]["order_type"]) class CatchCommonErrorsTests(TestBackendTestCase): @@ -321,46 +373,60 @@ class CatchCommonErrorsTests(TestBackendTestCase): # in EmailMessage.recipients (called from EmailMessage.send) before # Anymail gets a chance to complain.) self.message.reply_to = "single-reply-to@example.com" - with self.assertRaisesMessage(TypeError, '"reply_to" attribute must be a list or other iterable'): + with self.assertRaisesMessage( + TypeError, '"reply_to" attribute must be a list or other iterable' + ): self.message.send() def test_explains_reply_to_must_be_list_lazy(self): """Same as previous tests, with lazy strings""" # Lazy strings can fool string/iterable detection self.message.reply_to = gettext_lazy("single-reply-to@example.com") - with self.assertRaisesMessage(TypeError, '"reply_to" attribute must be a list or other iterable'): + with self.assertRaisesMessage( + TypeError, '"reply_to" attribute must be a list or other iterable' + ): self.message.send() def test_identifies_source_of_parsing_errors(self): """Errors parsing email addresses should say which field had the problem""" - # Note: General email address parsing tests are in test_utils.ParseAddressListTests. - # This just checks the error includes the field name when parsing for sending a message. - self.message.from_email = '' - with self.assertRaisesMessage(AnymailInvalidAddress, - "Invalid email address '' parsed from '' in `from_email`."): + # Note: General email address parsing tests are in + # test_utils.ParseAddressListTests. This just checks the error includes the + # field name when parsing for sending a message. + self.message.from_email = "" + with self.assertRaisesMessage( + AnymailInvalidAddress, + "Invalid email address '' parsed from '' in `from_email`.", + ): self.message.send() - self.message.from_email = 'from@example.com' + self.message.from_email = "from@example.com" # parse_address_list - self.message.to = ['ok@example.com', 'oops'] - with self.assertRaisesMessage(AnymailInvalidAddress, - "Invalid email address 'oops' parsed from 'ok@example.com, oops' in `to`."): + self.message.to = ["ok@example.com", "oops"] + with self.assertRaisesMessage( + AnymailInvalidAddress, + "Invalid email address 'oops' parsed from 'ok@example.com, oops' in `to`.", + ): self.message.send() - self.message.to = ['test@example.com'] + self.message.to = ["test@example.com"] # parse_single_address - self.message.envelope_sender = 'one@example.com, two@example.com' - with self.assertRaisesMessage(AnymailInvalidAddress, - "Only one email address is allowed; found 2" - " in 'one@example.com, two@example.com' in `envelope_sender`."): + self.message.envelope_sender = "one@example.com, two@example.com" + with self.assertRaisesMessage( + AnymailInvalidAddress, + "Only one email address is allowed; found 2" + " in 'one@example.com, two@example.com' in `envelope_sender`.", + ): self.message.send() - delattr(self.message, 'envelope_sender') + delattr(self.message, "envelope_sender") # process_extra_headers - self.message.extra_headers['From'] = 'Mail, Inc. ' - with self.assertRaisesMessage(AnymailInvalidAddress, - "Invalid email address 'Mail' parsed from 'Mail, Inc. '" - " in `extra_headers['From']`. (Maybe missing quotes around a display-name?)"): + self.message.extra_headers["From"] = "Mail, Inc. " + with self.assertRaisesMessage( + AnymailInvalidAddress, + "Invalid email address 'Mail' parsed from" + " 'Mail, Inc. ' in `extra_headers['From']`." + " (Maybe missing quotes around a display-name?)", + ): self.message.send() def test_error_minimizes_pii_leakage(self): @@ -390,39 +456,55 @@ class SpecialHeaderTests(TestBackendTestCase): """Anymail should handle special extra_headers the same way Django does""" def test_reply_to(self): - """Django allows message.reply_to and message.extra_headers['Reply-To'], and the latter takes precedence""" + """ + Django allows message.reply_to and message.extra_headers['Reply-To'], + and the latter takes precedence + """ self.message.reply_to = ["attr@example.com"] self.message.extra_headers = {"X-Extra": "extra"} self.message.send() params = self.get_send_params() - self.assertEqual(flatten_emails(params['reply_to']), ["attr@example.com"]) - self.assertEqual(params['extra_headers'], {"X-Extra": "extra"}) + self.assertEqual(flatten_emails(params["reply_to"]), ["attr@example.com"]) + self.assertEqual(params["extra_headers"], {"X-Extra": "extra"}) self.message.reply_to = None - self.message.extra_headers = {"Reply-To": "header@example.com", "X-Extra": "extra"} + self.message.extra_headers = { + "Reply-To": "header@example.com", + "X-Extra": "extra", + } self.message.send() params = self.get_send_params() - self.assertEqual(flatten_emails(params['reply_to']), ["header@example.com"]) - self.assertEqual(params['extra_headers'], {"X-Extra": "extra"}) # Reply-To no longer there + self.assertEqual(flatten_emails(params["reply_to"]), ["header@example.com"]) + # Reply-To no longer there: + self.assertEqual(params["extra_headers"], {"X-Extra": "extra"}) - # If both are supplied, the header wins (to match Django EmailMessage.message() behavior). - # Also, header names are case-insensitive. + # If both are supplied, the header wins (to match Django EmailMessage.message() + # behavior). Also, header names are case-insensitive. self.message.reply_to = ["attr@example.com"] - self.message.extra_headers = {"REPLY-to": "header@example.com", "X-Extra": "extra"} + self.message.extra_headers = { + "REPLY-to": "header@example.com", + "X-Extra": "extra", + } self.message.send() params = self.get_send_params() - self.assertEqual(flatten_emails(params['reply_to']), ["header@example.com"]) - self.assertEqual(params['extra_headers'], {"X-Extra": "extra"}) # Reply-To no longer there + self.assertEqual(flatten_emails(params["reply_to"]), ["header@example.com"]) + # Reply-To no longer there + self.assertEqual(params["extra_headers"], {"X-Extra": "extra"}) def test_envelope_sender(self): - """Django treats message.from_email as envelope-sender if messsage.extra_headers['From'] is set""" + """ + Django treats message.from_email as envelope-sender + if message.extra_headers['From'] is set + """ # Using Anymail's envelope_sender extension self.message.from_email = "Header From " - self.message.envelope_sender = "Envelope From " # Anymail extension + self.message.envelope_sender = ( + "Envelope From " # Anymail extension + ) self.message.send() params = self.get_send_params() - self.assertEqual(params['from'].address, "Header From ") - self.assertEqual(params['envelope_sender'], "envelope@bounces.example.com") + self.assertEqual(params["from"].address, "Header From ") + self.assertEqual(params["envelope_sender"], "envelope@bounces.example.com") # Using Django's undocumented message.extra_headers['From'] extension # (see https://code.djangoproject.com/ticket/9214) @@ -430,21 +512,31 @@ class SpecialHeaderTests(TestBackendTestCase): self.message.extra_headers = {"From": "Header From "} self.message.send() params = self.get_send_params() - self.assertEqual(params['from'].address, "Header From ") - self.assertEqual(params['envelope_sender'], "envelope@bounces.example.com") - self.assertNotIn("From", params.get('extra_headers', {})) # From was removed from extra-headers + self.assertEqual(params["from"].address, "Header From ") + self.assertEqual(params["envelope_sender"], "envelope@bounces.example.com") + # From was removed from extra-headers: + self.assertNotIn("From", params.get("extra_headers", {})) def test_spoofed_to_header(self): - """Django treats message.to as envelope-recipient if message.extra_headers['To'] is set""" + """ + Django treats message.to as envelope-recipient + if message.extra_headers['To'] is set + """ # No current ESP supports this (and it's unlikely they would) self.message.to = ["actual-recipient@example.com"] - self.message.extra_headers = {"To": "Apparent Recipient "} - with self.assertRaisesMessage(AnymailUnsupportedFeature, "spoofing `To` header"): + self.message.extra_headers = { + "To": "Apparent Recipient " + } + with self.assertRaisesMessage( + AnymailUnsupportedFeature, "spoofing `To` header" + ): self.message.send() class AlternativePartsTests(TestBackendTestCase): - """Anymail should handle alternative parts consistently with Django's SMTP backend""" + """ + Anymail should handle alternative parts consistently with Django's SMTP backend + """ def test_default_usage(self): """Body defaults to text/plain, use alternative for html""" @@ -452,9 +544,9 @@ class AlternativePartsTests(TestBackendTestCase): self.message.attach_alternative("html body", "text/html") self.message.send() params = self.get_send_params() - self.assertEqual(params['text_body'], "plain body") - self.assertEqual(params['html_body'], "html body") - self.assertNotIn('alternatives', params) + self.assertEqual(params["text_body"], "plain body") + self.assertEqual(params["html_body"], "html body") + self.assertNotIn("alternatives", params) def test_content_subtype_html(self): """Change body to text/html, use alternative for plain""" @@ -463,20 +555,22 @@ class AlternativePartsTests(TestBackendTestCase): self.message.attach_alternative("plain body", "text/plain") self.message.send() params = self.get_send_params() - self.assertEqual(params['text_body'], "plain body") - self.assertEqual(params['html_body'], "html body") - self.assertNotIn('alternatives', params) + self.assertEqual(params["text_body"], "plain body") + self.assertEqual(params["html_body"], "html body") + self.assertNotIn("alternatives", params) def test_attach_plain_and_html(self): """Use alternatives for both bodies""" - message = AnymailMessage(subject="Subject", from_email="from@example.com", to=["to@example.com"]) + message = AnymailMessage( + subject="Subject", from_email="from@example.com", to=["to@example.com"] + ) message.attach_alternative("plain body", "text/plain") message.attach_alternative("html body", "text/html") message.send() params = self.get_send_params() - self.assertEqual(params['text_body'], "plain body") - self.assertEqual(params['html_body'], "html body") - self.assertNotIn('alternatives', params) + self.assertEqual(params["text_body"], "plain body") + self.assertEqual(params["html_body"], "html body") + self.assertNotIn("alternatives", params) def test_additional_plain_part(self): """Two plaintext bodies""" @@ -486,22 +580,24 @@ class AlternativePartsTests(TestBackendTestCase): self.message.attach_alternative("second plain body", "text/plain") self.message.send() params = self.get_send_params() - self.assertEqual(params['text_body'], "plain body") - self.assertEqual(params['alternatives'], [("second plain body", "text/plain")]) + self.assertEqual(params["text_body"], "plain body") + self.assertEqual(params["alternatives"], [("second plain body", "text/plain")]) def test_exotic_content_subtype(self): """Change body to text/calendar, use alternatives for plain and html""" - # This is unlikely to work with most ESPs, but we can try to communicate the intent... - # (You probably want an attachment rather than an alternative part.) + # This is unlikely to work with most ESPs, but we can try to communicate the + # intent... (You probably want an attachment rather than an alternative part.) self.message.content_subtype = "calendar" self.message.body = "BEGIN:VCALENDAR..." self.message.attach_alternative("plain body", "text/plain") self.message.attach_alternative("html body", "text/html") self.message.send() params = self.get_send_params() - self.assertEqual(params['text_body'], "plain body") - self.assertEqual(params['html_body'], "html body") - self.assertEqual(params['alternatives'], [("BEGIN:VCALENDAR...", "text/calendar")]) + self.assertEqual(params["text_body"], "plain body") + self.assertEqual(params["html_body"], "html body") + self.assertEqual( + params["alternatives"], [("BEGIN:VCALENDAR...", "text/calendar")] + ) class BatchSendDetectionTestCase(TestBackendTestCase): @@ -510,25 +606,25 @@ class BatchSendDetectionTestCase(TestBackendTestCase): def test_default_is_not_batch(self): self.message.send() params = self.get_send_params() - self.assertFalse(params['is_batch_send']) + self.assertFalse(params["is_batch_send"]) def test_merge_data_implies_batch(self): self.message.merge_data = {} # *anything* (even empty dict) implies batch self.message.send() params = self.get_send_params() - self.assertTrue(params['is_batch_send']) + self.assertTrue(params["is_batch_send"]) def test_merge_metadata_implies_batch(self): self.message.merge_metadata = {} # *anything* (even empty dict) implies batch self.message.send() params = self.get_send_params() - self.assertTrue(params['is_batch_send']) + self.assertTrue(params["is_batch_send"]) def test_merge_global_data_does_not_imply_batch(self): self.message.merge_global_data = {} self.message.send() params = self.get_send_params() - self.assertFalse(params['is_batch_send']) + self.assertFalse(params["is_batch_send"]) def test_cannot_call_is_batch_during_init(self): # It's tempting to try to warn about unsupported batch features in setters, @@ -539,8 +635,11 @@ class BatchSendDetectionTestCase(TestBackendTestCase): self.unsupported_feature("cc with batch send") super().set_cc(emails) - connection = mail.get_connection('anymail.backends.test.EmailBackend', - payload_class=ImproperlyImplementedPayload) - with self.assertRaisesMessage(AssertionError, - "Cannot call is_batch before all attributes processed"): + connection = mail.get_connection( + "anymail.backends.test.EmailBackend", + payload_class=ImproperlyImplementedPayload, + ) + with self.assertRaisesMessage( + AssertionError, "Cannot call is_batch before all attributes processed" + ): connection.send_messages([self.message]) diff --git a/tests/test_inbound.py b/tests/test_inbound.py index d3eb26f..b659bf2 100644 --- a/tests/test_inbound.py +++ b/tests/test_inbound.py @@ -16,42 +16,63 @@ SAMPLE_IMAGE_CONTENT = sample_image_content() class AnymailInboundMessageConstructionTests(SimpleTestCase): def test_construct_params(self): msg = AnymailInboundMessage.construct( - from_email="from@example.com", to="to@example.com", cc="cc@example.com", - subject="test subject") - self.assertEqual(msg['From'], "from@example.com") - self.assertEqual(msg['To'], "to@example.com") - self.assertEqual(msg['Cc'], "cc@example.com") - self.assertEqual(msg['Subject'], "test subject") + from_email="from@example.com", + to="to@example.com", + cc="cc@example.com", + subject="test subject", + ) + self.assertEqual(msg["From"], "from@example.com") + self.assertEqual(msg["To"], "to@example.com") + self.assertEqual(msg["Cc"], "cc@example.com") + self.assertEqual(msg["Subject"], "test subject") - self.assertEqual(msg.defects, []) # ensures email.message.Message.__init__ ran - self.assertIsNone(msg.envelope_recipient) # ensures AnymailInboundMessage.__init__ ran + # ensures email.message.Message.__init__ ran: + self.assertEqual(msg.defects, []) + # ensures AnymailInboundMessage.__init__ ran: + self.assertIsNone(msg.envelope_recipient) def test_construct_headers_from_mapping(self): msg = AnymailInboundMessage.construct( - headers={'Reply-To': "reply@example.com", 'X-Test': "anything"}) - self.assertEqual(msg['reply-to'], "reply@example.com") # headers are case-insensitive - self.assertEqual(msg['X-TEST'], "anything") + headers={"Reply-To": "reply@example.com", "X-Test": "anything"} + ) + # headers are case-insensitive: + self.assertEqual(msg["reply-to"], "reply@example.com") + self.assertEqual(msg["X-TEST"], "anything") def test_construct_headers_from_pairs(self): # allows multiple instances of a header msg = AnymailInboundMessage.construct( - headers=[['Reply-To', "reply@example.com"], - ['Received', "by 10.1.1.4 with SMTP id q4csp; Sun, 22 Oct 2017 00:23:22 -0700 (PDT)"], - ['Received', "from mail.example.com (mail.example.com. [10.10.1.9])" - " by mx.example.com with SMTPS id 93s8iok for ;" - " Sun, 22 Oct 2017 00:23:21 -0700 (PDT)"], - ]) - self.assertEqual(msg['Reply-To'], "reply@example.com") - self.assertEqual(msg.get_all('Received'), [ - "by 10.1.1.4 with SMTP id q4csp; Sun, 22 Oct 2017 00:23:22 -0700 (PDT)", - "from mail.example.com (mail.example.com. [10.10.1.9])" - " by mx.example.com with SMTPS id 93s8iok for ;" - " Sun, 22 Oct 2017 00:23:21 -0700 (PDT)"]) + headers=[ + ["Reply-To", "reply@example.com"], + [ + "Received", + "by 10.1.1.4 with SMTP id q4csp;" + " Sun, 22 Oct 2017 00:23:22 -0700 (PDT)", + ], + [ + "Received", + "from mail.example.com (mail.example.com. [10.10.1.9])" + " by mx.example.com with SMTPS id 93s8iok for ;" + " Sun, 22 Oct 2017 00:23:21 -0700 (PDT)", + ], + ] + ) + self.assertEqual(msg["Reply-To"], "reply@example.com") + self.assertEqual( + msg.get_all("Received"), + [ + "by 10.1.1.4 with SMTP id q4csp; Sun, 22 Oct 2017 00:23:22 -0700 (PDT)", + "from mail.example.com (mail.example.com. [10.10.1.9])" + " by mx.example.com with SMTPS id 93s8iok for ;" + " Sun, 22 Oct 2017 00:23:21 -0700 (PDT)", + ], + ) def test_construct_headers_from_raw(self): # (note header "folding" in second Received header) msg = AnymailInboundMessage.construct( - raw_headers=dedent("""\ + raw_headers=dedent( + """\ Reply-To: reply@example.com Subject: raw subject Content-Type: x-custom/custom @@ -59,86 +80,125 @@ class AnymailInboundMessageConstructionTests(SimpleTestCase): Received: from mail.example.com (mail.example.com. [10.10.1.9]) by mx.example.com with SMTPS id 93s8iok for ; Sun, 22 Oct 2017 00:23:21 -0700 (PDT) - """), - subject="Explicit subject overrides raw") - self.assertEqual(msg['Reply-To'], "reply@example.com") - self.assertEqual(msg.get_all('Received'), [ - "by 10.1.1.4 with SMTP id q4csp; Sun, 22 Oct 2017 00:23:22 -0700 (PDT)", - "from mail.example.com (mail.example.com. [10.10.1.9])" # unfolding should have stripped newlines - " by mx.example.com with SMTPS id 93s8iok for ;" - " Sun, 22 Oct 2017 00:23:21 -0700 (PDT)"]) - self.assertEqual(msg.get_all('Subject'), ["Explicit subject overrides raw"]) - self.assertEqual(msg.get_all('Content-Type'), ["multipart/mixed"]) # Content-Type in raw header ignored + """ # NOQA: E501 + ), + subject="Explicit subject overrides raw", + ) + self.assertEqual(msg["Reply-To"], "reply@example.com") + self.assertEqual( + msg.get_all("Received"), + [ + # unfolding should have stripped newlines + "by 10.1.1.4 with SMTP id q4csp; Sun, 22 Oct 2017 00:23:22 -0700 (PDT)", + "from mail.example.com (mail.example.com. [10.10.1.9])" + " by mx.example.com with SMTPS id 93s8iok for ;" + " Sun, 22 Oct 2017 00:23:21 -0700 (PDT)", + ], + ) + self.assertEqual(msg.get_all("Subject"), ["Explicit subject overrides raw"]) + # Content-Type in raw header ignored: + self.assertEqual(msg.get_all("Content-Type"), ["multipart/mixed"]) def test_construct_bodies(self): # this verifies we construct the expected MIME structure; # see the `text` and `html` props (in the ConveniencePropTests below) # for an easier way to get to these fields (that works however constructed) msg = AnymailInboundMessage.construct(text="Plaintext body", html="HTML body") - self.assertEqual(msg['Content-Type'], "multipart/mixed") + self.assertEqual(msg["Content-Type"], "multipart/mixed") self.assertEqual(len(msg.get_payload()), 1) related = msg.get_payload(0) - self.assertEqual(related['Content-Type'], "multipart/related") + self.assertEqual(related["Content-Type"], "multipart/related") self.assertEqual(len(related.get_payload()), 1) alternative = related.get_payload(0) - self.assertEqual(alternative['Content-Type'], "multipart/alternative") + self.assertEqual(alternative["Content-Type"], "multipart/alternative") self.assertEqual(len(alternative.get_payload()), 2) plaintext = alternative.get_payload(0) - self.assertEqual(plaintext['Content-Type'], 'text/plain; charset="utf-8"') + self.assertEqual(plaintext["Content-Type"], 'text/plain; charset="utf-8"') self.assertEqual(plaintext.get_content_text(), "Plaintext body") html = alternative.get_payload(1) - self.assertEqual(html['Content-Type'], 'text/html; charset="utf-8"') + self.assertEqual(html["Content-Type"], 'text/html; charset="utf-8"') self.assertEqual(html.get_content_text(), "HTML body") def test_construct_attachments(self): att1 = AnymailInboundMessage.construct_attachment( - 'text/csv', "One,Two\n1,2".encode('iso-8859-1'), charset="iso-8859-1", filename="test.csv") + "text/csv", + "One,Two\n1,2".encode("iso-8859-1"), + charset="iso-8859-1", + filename="test.csv", + ) att2 = AnymailInboundMessage.construct_attachment( - 'image/png', SAMPLE_IMAGE_CONTENT, filename=SAMPLE_IMAGE_FILENAME, content_id="abc123") + "image/png", + SAMPLE_IMAGE_CONTENT, + filename=SAMPLE_IMAGE_FILENAME, + content_id="abc123", + ) msg = AnymailInboundMessage.construct(attachments=[att1, att2]) - self.assertEqual(msg['Content-Type'], "multipart/mixed") + self.assertEqual(msg["Content-Type"], "multipart/mixed") self.assertEqual(len(msg.get_payload()), 2) # bodies (related), att1 att1_part = msg.get_payload(1) - self.assertEqual(att1_part['Content-Type'], 'text/csv; name="test.csv"; charset="iso-8859-1"') - self.assertEqual(att1_part['Content-Disposition'], 'attachment; filename="test.csv"') - self.assertNotIn('Content-ID', att1_part) + self.assertEqual( + att1_part["Content-Type"], 'text/csv; name="test.csv"; charset="iso-8859-1"' + ) + self.assertEqual( + att1_part["Content-Disposition"], 'attachment; filename="test.csv"' + ) + self.assertNotIn("Content-ID", att1_part) self.assertEqual(att1_part.get_content_text(), "One,Two\n1,2") related = msg.get_payload(0) - self.assertEqual(len(related.get_payload()), 2) # alternatives (with no bodies in this test); att2 + # alternatives (with no bodies in this test); att2: + self.assertEqual(len(related.get_payload()), 2) att2_part = related.get_payload(1) - self.assertEqual(att2_part['Content-Type'], 'image/png; name="sample_image.png"') - self.assertEqual(att2_part['Content-Disposition'], 'inline; filename="sample_image.png"') - self.assertEqual(att2_part['Content-ID'], '') + self.assertEqual( + att2_part["Content-Type"], 'image/png; name="sample_image.png"' + ) + self.assertEqual( + att2_part["Content-Disposition"], 'inline; filename="sample_image.png"' + ) + self.assertEqual(att2_part["Content-ID"], "") self.assertEqual(att2_part.get_content_bytes(), SAMPLE_IMAGE_CONTENT) def test_construct_attachments_from_uploaded_files(self): from django.core.files.uploadedfile import SimpleUploadedFile - file = SimpleUploadedFile(SAMPLE_IMAGE_FILENAME, SAMPLE_IMAGE_CONTENT, 'image/png') - att = AnymailInboundMessage.construct_attachment_from_uploaded_file(file, content_id="abc123") - self.assertEqual(att['Content-Type'], 'image/png; name="sample_image.png"') - self.assertEqual(att['Content-Disposition'], 'inline; filename="sample_image.png"') - self.assertEqual(att['Content-ID'], '') + + file = SimpleUploadedFile( + SAMPLE_IMAGE_FILENAME, SAMPLE_IMAGE_CONTENT, "image/png" + ) + att = AnymailInboundMessage.construct_attachment_from_uploaded_file( + file, content_id="abc123" + ) + self.assertEqual(att["Content-Type"], 'image/png; name="sample_image.png"') + self.assertEqual( + att["Content-Disposition"], 'inline; filename="sample_image.png"' + ) + self.assertEqual(att["Content-ID"], "") self.assertEqual(att.get_content_bytes(), SAMPLE_IMAGE_CONTENT) def test_construct_attachments_from_base64_data(self): # This is a fairly common way for ESPs to provide attachment content to webhooks content = b64encode(SAMPLE_IMAGE_CONTENT) - att = AnymailInboundMessage.construct_attachment(content_type="image/png", content=content, base64=True) + att = AnymailInboundMessage.construct_attachment( + content_type="image/png", content=content, base64=True + ) self.assertEqual(att.get_content_bytes(), SAMPLE_IMAGE_CONTENT) def test_construct_attachment_unicode_filename(self): # Issue #197 att = AnymailInboundMessage.construct_attachment( - content_type="text/plain", content="Unicode ✓", charset='utf-8', base64=False, - filename="Simulácia.txt", content_id="inline-id",) + content_type="text/plain", + content="Unicode ✓", + charset="utf-8", + base64=False, + filename="Simulácia.txt", + content_id="inline-id", + ) self.assertEqual(att.get_filename(), "Simulácia.txt") self.assertTrue(att.is_inline_attachment()) self.assertEqual(att.get_content_text(), "Unicode ✓") @@ -146,35 +206,40 @@ class AnymailInboundMessageConstructionTests(SimpleTestCase): def test_parse_raw_mime(self): # (we're not trying to exhaustively test email.parser MIME handling here; # just that AnymailInboundMessage.parse_raw_mime calls it correctly) - raw = dedent("""\ + raw = dedent( + """\ Content-Type: text/plain Subject: This is a test message This is a test body. - """) + """ + ) msg = AnymailInboundMessage.parse_raw_mime(raw) - self.assertEqual(msg['Subject'], "This is a test message") + self.assertEqual(msg["Subject"], "This is a test message") self.assertEqual(msg.get_content_text(), "This is a test body.\n") self.assertEqual(msg.defects, []) - # (see test_attachment_as_uploaded_file below for parsing basic attachment from raw mime) + # (see test_attachment_as_uploaded_file below + # for parsing basic attachment from raw mime) def test_parse_raw_mime_bytes(self): raw = ( - b'Content-Type: text/plain; charset=ISO-8859-3\r\n' - b'Content-Transfer-Encoding: 8bit\r\n' - b'Subject: Test bytes\r\n' - b'\r\n' - b'\xD8i estas retpo\xFEto.\r\n') + b"Content-Type: text/plain; charset=ISO-8859-3\r\n" + b"Content-Transfer-Encoding: 8bit\r\n" + b"Subject: Test bytes\r\n" + b"\r\n" + b"\xD8i estas retpo\xFEto.\r\n" + ) msg = AnymailInboundMessage.parse_raw_mime_bytes(raw) - self.assertEqual(msg['Subject'], "Test bytes") + self.assertEqual(msg["Subject"], "Test bytes") self.assertEqual(msg.get_content_text(), "Ĝi estas retpoŝto.\r\n") - self.assertEqual(msg.get_content_bytes(), b'\xD8i estas retpo\xFEto.\r\n') + self.assertEqual(msg.get_content_bytes(), b"\xD8i estas retpo\xFEto.\r\n") self.assertEqual(msg.defects, []) def test_parse_raw_mime_8bit_utf8(self): - # In come cases, the message below ends up with 'Content-Transfer-Encoding: 8bit', - # so needs to be parsed as bytes, not text (see https://bugs.python.org/issue18271). + # In come cases, the message below ends up with + # 'Content-Transfer-Encoding: 8bit', so needs to be parsed as bytes, not text + # (see https://bugs.python.org/issue18271). # Message.as_string() returns str (text), not bytes. # (This might be a Django bug; plain old MIMEText avoids the problem by using # 'Content-Transfer-Encoding: base64', which parses fine as text or bytes.) @@ -188,16 +253,26 @@ class AnymailInboundMessageConstructionTests(SimpleTestCase): msg = AnymailInboundMessage.parse_raw_mime_file(fp) self.assertEqual(msg["Subject"], "Test email") self.assertEqual(msg.text, "Hi Bob, This is a message. Thanks!\n") - self.assertEqual(msg.get_all("Received"), [ # this is the first line in the sample email file - "by luna.mailgun.net with SMTP mgrt 8734663311733; Fri, 03 May 2013 18:26:27 +0000"]) + self.assertEqual( + msg.get_all("Received"), + [ # this is the first line in the sample email file + "by luna.mailgun.net with SMTP mgrt 8734663311733;" + " Fri, 03 May 2013 18:26:27 +0000" + ], + ) def test_parse_raw_mime_file_bytes(self): with open(sample_email_path(), mode="rb") as fp: msg = AnymailInboundMessage.parse_raw_mime_file(fp) self.assertEqual(msg["Subject"], "Test email") self.assertEqual(msg.text, "Hi Bob, This is a message. Thanks!\n") - self.assertEqual(msg.get_all("Received"), [ # this is the first line in the sample email file - "by luna.mailgun.net with SMTP mgrt 8734663311733; Fri, 03 May 2013 18:26:27 +0000"]) + self.assertEqual( + msg.get_all("Received"), + [ # this is the first line in the sample email file + "by luna.mailgun.net with SMTP mgrt 8734663311733;" + " Fri, 03 May 2013 18:26:27 +0000" + ], + ) class AnymailInboundMessageConveniencePropTests(SimpleTestCase): @@ -207,24 +282,24 @@ class AnymailInboundMessageConveniencePropTests(SimpleTestCase): def test_address_props(self): msg = AnymailInboundMessage.construct( from_email='"Sender, Inc." ', - to='First To , to2@example.com', - cc='First Cc , cc2@example.com', + to="First To , to2@example.com", + cc="First Cc , cc2@example.com", ) self.assertEqual(str(msg.from_email), '"Sender, Inc." ') - self.assertEqual(msg.from_email.addr_spec, 'sender@example.com') - self.assertEqual(msg.from_email.display_name, 'Sender, Inc.') - self.assertEqual(msg.from_email.username, 'sender') - self.assertEqual(msg.from_email.domain, 'example.com') + self.assertEqual(msg.from_email.addr_spec, "sender@example.com") + self.assertEqual(msg.from_email.display_name, "Sender, Inc.") + self.assertEqual(msg.from_email.username, "sender") + self.assertEqual(msg.from_email.domain, "example.com") self.assertEqual(len(msg.to), 2) - self.assertEqual(msg.to[0].addr_spec, 'to1@example.com') - self.assertEqual(msg.to[0].display_name, 'First To') - self.assertEqual(msg.to[1].addr_spec, 'to2@example.com') - self.assertEqual(msg.to[1].display_name, '') + self.assertEqual(msg.to[0].addr_spec, "to1@example.com") + self.assertEqual(msg.to[0].display_name, "First To") + self.assertEqual(msg.to[1].addr_spec, "to2@example.com") + self.assertEqual(msg.to[1].display_name, "") self.assertEqual(len(msg.cc), 2) - self.assertEqual(msg.cc[0].address, 'First Cc ') - self.assertEqual(msg.cc[1].address, 'cc2@example.com') + self.assertEqual(msg.cc[0].address, "First Cc ") + self.assertEqual(msg.cc[1].address, "cc2@example.com") # Default None/empty lists msg = AnymailInboundMessage() @@ -238,15 +313,24 @@ class AnymailInboundMessageConveniencePropTests(SimpleTestCase): self.assertEqual(msg.html, "Test HTML") # Make sure attachments don't confuse it - att_text = AnymailInboundMessage.construct_attachment('text/plain', "text attachment") - att_html = AnymailInboundMessage.construct_attachment('text/html', "html attachment") + att_text = AnymailInboundMessage.construct_attachment( + "text/plain", "text attachment" + ) + att_html = AnymailInboundMessage.construct_attachment( + "text/html", "html attachment" + ) - msg = AnymailInboundMessage.construct(text="Test plaintext", attachments=[att_text, att_html]) + msg = AnymailInboundMessage.construct( + text="Test plaintext", attachments=[att_text, att_html] + ) self.assertEqual(msg.text, "Test plaintext") self.assertIsNone(msg.html) # no html body (the html attachment doesn't count) - msg = AnymailInboundMessage.construct(html="Test HTML", attachments=[att_text, att_html]) - self.assertIsNone(msg.text) # no plaintext body (the text attachment doesn't count) + msg = AnymailInboundMessage.construct( + html="Test HTML", attachments=[att_text, att_html] + ) + # no plaintext body (the text attachment doesn't count): + self.assertIsNone(msg.text) self.assertEqual(msg.html, "Test HTML") # Default None @@ -257,7 +341,8 @@ class AnymailInboundMessageConveniencePropTests(SimpleTestCase): def test_body_props_charsets(self): text_8859_10 = "Detta är det vanliga innehållet".encode("ISO-8859-10") html_8859_8 = "

HTML זהו תוכן

".encode("ISO-8859-8") - raw = dedent("""\ + raw = dedent( + """\ MIME-Version: 1.0 Subject: Charset test Content-Type: multipart/alternative; boundary="this_is_a_boundary" @@ -273,10 +358,11 @@ class AnymailInboundMessageConveniencePropTests(SimpleTestCase): {html} --this_is_a_boundary-- - """).format( - text=quopri.encodestring(text_8859_10).decode("ASCII"), - html=quopri.encodestring(html_8859_8).decode("ASCII"), - ) + """ + ).format( + text=quopri.encodestring(text_8859_10).decode("ASCII"), + html=quopri.encodestring(html_8859_8).decode("ASCII"), + ) msg = AnymailInboundMessage.parse_raw_mime(raw) self.assertEqual(msg.defects, []) @@ -284,41 +370,56 @@ class AnymailInboundMessageConveniencePropTests(SimpleTestCase): self.assertEqual(msg.html, "

HTML זהו תוכן

") self.assertEqual(msg.get_payload(0).get_content_bytes(), text_8859_10) - self.assertEqual(msg.get_payload(0).get_content_text(), "Detta är det vanliga innehållet") + self.assertEqual( + msg.get_payload(0).get_content_text(), "Detta är det vanliga innehållet" + ) self.assertEqual(msg.get_payload(1).get_content_bytes(), html_8859_8) self.assertEqual(msg.get_payload(1).get_content_text(), "

HTML זהו תוכן

") def test_missing_or_invalid_charsets(self): - """get_content_text has options for handling missing/invalid charset declarations""" - raw = dedent("""\ + """ + get_content_text has options for handling missing/invalid charset declarations + """ + raw = dedent( + """\ Subject: Oops, missing charset declaration Content-Type: text/plain Content-Transfer-Encoding: quoted-printable Algunos programas de correo electr=f3nico est=e1n rotos - """) + """ + ) msg = AnymailInboundMessage.parse_raw_mime(raw) self.assertEqual(msg.defects, []) - # default is charset from Content-Type (or 'utf-8' if missing), errors='replace'; .text uses defaults - self.assertEqual(msg.get_content_text(), - "Algunos programas de correo electr�nico est�n rotos\n") - self.assertEqual(msg.text, "Algunos programas de correo electr�nico est�n rotos\n") + # default is charset from Content-Type (or 'utf-8' if missing), + # errors='replace'; .text uses defaults + self.assertEqual( + msg.get_content_text(), + "Algunos programas de correo electr�nico est�n rotos\n", + ) + self.assertEqual( + msg.text, "Algunos programas de correo electr�nico est�n rotos\n" + ) # can give specific charset if you know headers are wrong/missing - self.assertEqual(msg.get_content_text(charset='ISO-8859-1'), - "Algunos programas de correo electrónico están rotos\n") + self.assertEqual( + msg.get_content_text(charset="ISO-8859-1"), + "Algunos programas de correo electrónico están rotos\n", + ) # can change error handling with self.assertRaises(UnicodeDecodeError): - msg.get_content_text(errors='strict') - self.assertEqual(msg.get_content_text(errors='ignore'), - "Algunos programas de correo electrnico estn rotos\n") + msg.get_content_text(errors="strict") + self.assertEqual( + msg.get_content_text(errors="ignore"), + "Algunos programas de correo electrnico estn rotos\n", + ) def test_date_props(self): - msg = AnymailInboundMessage.construct(headers={ - 'Date': "Mon, 23 Oct 2017 17:50:55 -0700" - }) + msg = AnymailInboundMessage.construct( + headers={"Date": "Mon, 23 Oct 2017 17:50:55 -0700"} + ) self.assertEqual(msg.date.isoformat(), "2017-10-23T17:50:55-07:00") # Default None @@ -326,7 +427,8 @@ class AnymailInboundMessageConveniencePropTests(SimpleTestCase): def test_attachments_prop(self): att = AnymailInboundMessage.construct_attachment( - 'image/png', SAMPLE_IMAGE_CONTENT, filename=SAMPLE_IMAGE_FILENAME) + "image/png", SAMPLE_IMAGE_CONTENT, filename=SAMPLE_IMAGE_FILENAME + ) msg = AnymailInboundMessage.construct(attachments=[att]) self.assertEqual(msg.attachments, [att]) @@ -336,16 +438,21 @@ class AnymailInboundMessageConveniencePropTests(SimpleTestCase): def test_inline_attachments_prop(self): att = AnymailInboundMessage.construct_attachment( - 'image/png', SAMPLE_IMAGE_CONTENT, filename=SAMPLE_IMAGE_FILENAME, content_id="abc123") + "image/png", + SAMPLE_IMAGE_CONTENT, + filename=SAMPLE_IMAGE_FILENAME, + content_id="abc123", + ) msg = AnymailInboundMessage.construct(attachments=[att]) - self.assertEqual(msg.inline_attachments, {'abc123': att}) + self.assertEqual(msg.inline_attachments, {"abc123": att}) # Default empty dict self.assertEqual(AnymailInboundMessage().inline_attachments, {}) def test_attachment_as_uploaded_file(self): - raw = dedent("""\ + raw = dedent( + """\ MIME-Version: 1.0 Subject: Attachment test Content-Type: multipart/mixed; boundary="this_is_a_boundary" @@ -372,7 +479,8 @@ class AnymailInboundMessageConveniencePropTests(SimpleTestCase): 4j9/yYxupaQbXPJLNqsGFgeZ6qwpLP1b4AV4AV5AoKfjpR5OwR6VKwULCAC+AQV4W9Ps4uZQAAAA AElFTkSuQmCC --this_is_a_boundary-- - """) + """ + ) msg = AnymailInboundMessage.parse_raw_mime(raw) attachment = msg.attachments[0] @@ -385,7 +493,8 @@ class AnymailInboundMessageConveniencePropTests(SimpleTestCase): def test_attachment_as_uploaded_file_security(self): # Raw attachment filenames can be malicious; we want to make sure that # our Django file converter sanitizes them (as much as any uploaded filename) - raw = dedent("""\ + raw = dedent( + """\ MIME-Version: 1.0 Subject: Attachment test Content-Type: multipart/mixed; boundary="this_is_a_boundary" @@ -407,21 +516,26 @@ class AnymailInboundMessageConveniencePropTests(SimpleTestCase): Hey, did I overwrite your site? --this_is_a_boundary-- - """) + """ + ) msg = AnymailInboundMessage.parse_raw_mime(raw) attachments = msg.attachments - self.assertEqual(attachments[0].get_filename(), "/etc/passwd") # you wouldn't want to actually write here - self.assertEqual(attachments[0].as_uploaded_file().name, "passwd") # path removed - good! + # you wouldn't want to actually write here: + self.assertEqual(attachments[0].get_filename(), "/etc/passwd") + # path removed - good!: + self.assertEqual(attachments[0].as_uploaded_file().name, "passwd") + # ditto for relative paths: self.assertEqual(attachments[1].get_filename(), "../static/index.html") - self.assertEqual(attachments[1].as_uploaded_file().name, "index.html") # ditto for relative paths + self.assertEqual(attachments[1].as_uploaded_file().name, "index.html") class AnymailInboundMessageAttachedMessageTests(SimpleTestCase): # message/rfc822 attachments should get parsed recursively - original_raw_message = dedent("""\ + original_raw_message = dedent( + """\ MIME-Version: 1.0 From: sender@example.com Subject: Original message @@ -441,11 +555,13 @@ class AnymailInboundMessageAttachedMessageTests(SimpleTestCase): {image_content_base64} --boundary-orig-- - """).format(image_content_base64=b64encode(SAMPLE_IMAGE_CONTENT).decode('ascii')) + """ + ).format(image_content_base64=b64encode(SAMPLE_IMAGE_CONTENT).decode("ascii")) def test_parse_rfc822_attachment_from_raw_mime(self): # message/rfc822 attachments should be parsed recursively - raw = dedent("""\ + raw = dedent( + """\ MIME-Version: 1.0 From: mailer-demon@example.org Subject: Undeliverable @@ -464,7 +580,8 @@ class AnymailInboundMessageAttachedMessageTests(SimpleTestCase): {original_raw_message} --boundary-bounce-- - """).format(original_raw_message=self.original_raw_message) + """ + ).format(original_raw_message=self.original_raw_message) msg = AnymailInboundMessage.parse_raw_mime(raw) self.assertIsInstance(msg, AnymailInboundMessage) @@ -476,7 +593,7 @@ class AnymailInboundMessageAttachedMessageTests(SimpleTestCase): orig_msg = att.get_payload(0) self.assertIsInstance(orig_msg, AnymailInboundMessage) - self.assertEqual(orig_msg['Subject'], "Original message") + self.assertEqual(orig_msg["Subject"], "Original message") self.assertEqual(orig_msg.get_content_type(), "multipart/related") self.assertEqual(att.get_content_text(), self.original_raw_message) @@ -488,7 +605,9 @@ class AnymailInboundMessageAttachedMessageTests(SimpleTestCase): def test_construct_rfc822_attachment_from_data(self): # constructed message/rfc822 attachment should end up as parsed message # (same as if attachment was parsed from raw mime, as in previous test) - att = AnymailInboundMessage.construct_attachment('message/rfc822', self.original_raw_message) + att = AnymailInboundMessage.construct_attachment( + "message/rfc822", self.original_raw_message + ) self.assertIsInstance(att, AnymailInboundMessage) self.assertEqual(att.get_content_type(), "message/rfc822") self.assertTrue(att.is_attachment()) @@ -496,7 +615,7 @@ class AnymailInboundMessageAttachedMessageTests(SimpleTestCase): orig_msg = att.get_payload(0) self.assertIsInstance(orig_msg, AnymailInboundMessage) - self.assertEqual(orig_msg['Subject'], "Original message") + self.assertEqual(orig_msg["Subject"], "Original message") self.assertEqual(orig_msg.get_content_type(), "multipart/related") @@ -507,7 +626,8 @@ class EmailParserBehaviorTests(SimpleTestCase): # in older, broken versions of the EmailParser.) def test_parse_folded_headers(self): - raw = dedent("""\ + raw = dedent( + """\ Content-Type: text/plain Subject: This subject uses header folding @@ -517,19 +637,27 @@ class EmailParserBehaviorTests(SimpleTestCase): Not-A-Header: This is the body. It is not folded. - """) - for end in ('\n', '\r', '\r\n'): # check NL, CR, and CRNL line-endings - msg = AnymailInboundMessage.parse_raw_mime(raw.replace('\n', end)) - self.assertEqual(msg['Subject'], "This subject uses header folding") - self.assertEqual(msg["X-Json"], - '{"problematic": ["encoded newline\\n", "comma,semi;no space"]}') - self.assertEqual(msg.get_content_text(), - "Not-A-Header: This is the body.{end} It is not folded.{end}".format(end=end)) + """ + ) + for end in ("\n", "\r", "\r\n"): # check NL, CR, and CRNL line-endings + msg = AnymailInboundMessage.parse_raw_mime(raw.replace("\n", end)) + self.assertEqual(msg["Subject"], "This subject uses header folding") + self.assertEqual( + msg["X-Json"], + '{"problematic": ["encoded newline\\n", "comma,semi;no space"]}', + ) + self.assertEqual( + msg.get_content_text(), + "Not-A-Header: This is the body.{end} It is not folded.{end}".format( + end=end + ), + ) self.assertEqual(msg.defects, []) def test_parse_encoded_headers(self): # RFC2047 header encoding - raw = dedent("""\ + raw = dedent( + """\ Content-Type: text/plain From: =?US-ASCII?Q?Keith_Moore?= To: =?ISO-8859-1?Q?Keld_J=F8rn_Simonsen?= , @@ -540,35 +668,43 @@ class EmailParserBehaviorTests(SimpleTestCase): X-Broken: =?utf-8?q?Not_a_char:_=88.?= Some examples adapted from http://dogmamix.com/MimeHeadersDecoder/ - """) + """ + ) msg = AnymailInboundMessage.parse_raw_mime(raw) self.assertEqual(msg["From"], "Keith Moore ") self.assertEqual(msg.from_email.display_name, "Keith Moore") self.assertEqual(msg.from_email.addr_spec, "moore@example.com") - self.assertEqual(msg["To"], - 'Keld Jørn Simonsen , ' - '"André Pirard, Jr." ') + self.assertEqual( + msg["To"], + "Keld Jørn Simonsen , " + '"André Pirard, Jr." ', + ) self.assertEqual(msg.to[0].display_name, "Keld Jørn Simonsen") self.assertEqual(msg.to[1].display_name, "André Pirard, Jr.") - # Note: Like email.headerregistry.Address, Anymail decodes an RFC2047-encoded display_name, - # but does not decode a punycode domain. (Use `idna.decode(domain)` if you need that.) + # Note: Like email.headerregistry.Address, Anymail decodes an RFC2047-encoded + # display_name, but does not decode a punycode domain. + # (Use `idna.decode(domain)` if you need that.) self.assertEqual(msg["Cc"], "Người nhận ") self.assertEqual(msg.cc[0].display_name, "Người nhận") self.assertEqual(msg.cc[0].addr_spec, "cc@xn--th-e0a.example.com") self.assertEqual(msg.cc[0].domain, "xn--th-e0a.example.com") - # Subject breaks between 'o' and 'u' in the word "you", must be re-joined without space. - # Also tests joining encoded words with different charsets: - self.assertEqual(msg["Subject"], "If you can read this you understand the example\N{CHECK MARK}") + # Subject breaks between 'o' and 'u' in the word "you", must be re-joined + # without space. Also tests joining encoded words with different charsets: + self.assertEqual( + msg["Subject"], + "If you can read this you understand the example\N{CHECK MARK}", + ) # Replace illegal encodings (rather than causing error): self.assertEqual(msg["X-Broken"], "Not a char: \N{REPLACEMENT CHARACTER}.") def test_parse_encoded_params(self): - raw = dedent("""\ + raw = dedent( + """\ MIME-Version: 1.0 Content-Type: multipart/mixed; boundary="this_is_a_boundary" @@ -584,10 +720,14 @@ class EmailParserBehaviorTests(SimpleTestCase): This is an attachment --this_is_a_boundary-- - """) + """ + ) msg = AnymailInboundMessage.parse_raw_mime(raw) att = msg.attachments[0] self.assertTrue(att.is_attachment()) self.assertEqual(att.get_content_disposition(), "attachment") - self.assertEqual(collapse_rfc2231_value(att.get_param("Name", header="Content-Type")), "TPS Report") + self.assertEqual( + collapse_rfc2231_value(att.get_param("Name", header="Content-Type")), + "TPS Report", + ) self.assertEqual(att.get_filename(), "Une pièce jointe.txt") diff --git a/tests/test_mailgun_backend.py b/tests/test_mailgun_backend.py index 408007e..234b09b 100644 --- a/tests/test_mailgun_backend.py +++ b/tests/test_mailgun_backend.py @@ -1,28 +1,44 @@ from datetime import date, datetime -from textwrap import dedent - from email import message_from_bytes from email.mime.base import MIMEBase from email.mime.image import MIMEImage +from textwrap import dedent from django.core import mail from django.core.exceptions import ImproperlyConfigured from django.test import SimpleTestCase, override_settings, tag -from django.utils.timezone import get_fixed_timezone, override as override_current_timezone +from django.utils.timezone import ( + get_fixed_timezone, + override as override_current_timezone, +) from anymail.exceptions import ( - AnymailError, AnymailAPIError, AnymailInvalidAddress, - AnymailRequestsAPIError, AnymailUnsupportedFeature) + AnymailAPIError, + AnymailError, + AnymailInvalidAddress, + AnymailRequestsAPIError, + AnymailUnsupportedFeature, +) from anymail.message import attach_inline_image_file -from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCases -from .utils import (AnymailTestMixin, sample_email_content, - sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME) +from .mock_requests_backend import ( + RequestsBackendMockAPITestCase, + SessionSharingTestCases, +) +from .utils import ( + SAMPLE_IMAGE_FILENAME, + AnymailTestMixin, + sample_email_content, + sample_image_content, + sample_image_path, +) -@tag('mailgun') -@override_settings(EMAIL_BACKEND='anymail.backends.mailgun.EmailBackend', - ANYMAIL={'MAILGUN_API_KEY': 'test_api_key'}) +@tag("mailgun") +@override_settings( + EMAIL_BACKEND="anymail.backends.mailgun.EmailBackend", + ANYMAIL={"MAILGUN_API_KEY": "test_api_key"}, +) class MailgunBackendMockAPITestCase(RequestsBackendMockAPITestCase): DEFAULT_RAW_RESPONSE = b"""{ "id": "<20160306015544.116301.25145@example.com>", @@ -32,25 +48,32 @@ class MailgunBackendMockAPITestCase(RequestsBackendMockAPITestCase): def setUp(self): super().setUp() # Simple message useful for many tests - self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com']) + self.message = mail.EmailMultiAlternatives( + "Subject", "Text Body", "from@example.com", ["to@example.com"] + ) -@tag('mailgun') +@tag("mailgun") class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase): """Test backend support for Django standard email features""" def test_send_mail(self): """Test basic API for simple send""" - mail.send_mail('Subject here', 'Here is the message.', - 'from@example.com', ['to@example.com'], fail_silently=False) - self.assert_esp_called('/example.com/messages') + mail.send_mail( + "Subject here", + "Here is the message.", + "from@example.com", + ["to@example.com"], + fail_silently=False, + ) + self.assert_esp_called("/example.com/messages") auth = self.get_api_call_auth() - self.assertEqual(auth, ('api', 'test_api_key')) + self.assertEqual(auth, ("api", "test_api_key")) data = self.get_api_call_data() - self.assertEqual(data['subject'], "Subject here") - self.assertEqual(data['text'], "Here is the message.") - self.assertEqual(data['from'], ["from@example.com"]) - self.assertEqual(data['to'], ["to@example.com"]) + self.assertEqual(data["subject"], "Subject here") + self.assertEqual(data["text"], "Here is the message.") + self.assertEqual(data["from"], ["from@example.com"]) + self.assertEqual(data["to"], ["to@example.com"]) def test_name_addr(self): """Make sure RFC2822 name-addr format (with display-name) is allowed @@ -58,74 +81,103 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase): (Test both sender and recipient addresses) """ msg = mail.EmailMessage( - 'Subject', 'Message', 'From Name ', - ['Recipient #1 ', 'to2@example.com'], - cc=['Carbon Copy ', 'cc2@example.com'], - bcc=['Blind Copy ', 'bcc2@example.com']) + "Subject", + "Message", + "From Name ", + ["Recipient #1 ", "to2@example.com"], + cc=["Carbon Copy ", "cc2@example.com"], + bcc=["Blind Copy ", "bcc2@example.com"], + ) msg.send() data = self.get_api_call_data() - self.assertEqual(data['from'], ["From Name "]) - self.assertEqual(data['to'], ['Recipient #1 ', 'to2@example.com']) - self.assertEqual(data['cc'], ['Carbon Copy ', 'cc2@example.com']) - self.assertEqual(data['bcc'], ['Blind Copy ', 'bcc2@example.com']) + self.assertEqual(data["from"], ["From Name "]) + self.assertEqual( + data["to"], ["Recipient #1 ", "to2@example.com"] + ) + self.assertEqual( + data["cc"], ["Carbon Copy ", "cc2@example.com"] + ) + self.assertEqual( + data["bcc"], ["Blind Copy ", "bcc2@example.com"] + ) def test_email_message(self): email = mail.EmailMessage( - 'Subject', 'Body goes here', 'from@example.com', - ['to1@example.com', 'Also To '], - bcc=['bcc1@example.com', 'Also BCC '], - cc=['cc1@example.com', 'Also CC '], - headers={'Reply-To': 'another@example.com', - 'X-MyHeader': 'my value', - 'Message-ID': 'mycustommsgid@example.com'}) + "Subject", + "Body goes here", + "from@example.com", + ["to1@example.com", "Also To "], + bcc=["bcc1@example.com", "Also BCC "], + cc=["cc1@example.com", "Also CC "], + headers={ + "Reply-To": "another@example.com", + "X-MyHeader": "my value", + "Message-ID": "mycustommsgid@example.com", + }, + ) email.send() data = self.get_api_call_data() - self.assertEqual(data['subject'], "Subject") - self.assertEqual(data['text'], "Body goes here") - self.assertEqual(data['from'], ["from@example.com"]) - self.assertEqual(data['to'], ['to1@example.com', 'Also To ']) - self.assertEqual(data['bcc'], ['bcc1@example.com', 'Also BCC ']) - self.assertEqual(data['cc'], ['cc1@example.com', 'Also CC ']) - self.assertEqual(data['h:Reply-To'], "another@example.com") - self.assertEqual(data['h:X-MyHeader'], 'my value') - self.assertEqual(data['h:Message-ID'], 'mycustommsgid@example.com') - self.assertNotIn('recipient-variables', data) # multiple recipients, but not a batch send + self.assertEqual(data["subject"], "Subject") + self.assertEqual(data["text"], "Body goes here") + self.assertEqual(data["from"], ["from@example.com"]) + self.assertEqual(data["to"], ["to1@example.com", "Also To "]) + self.assertEqual( + data["bcc"], ["bcc1@example.com", "Also BCC "] + ) + self.assertEqual(data["cc"], ["cc1@example.com", "Also CC "]) + self.assertEqual(data["h:Reply-To"], "another@example.com") + self.assertEqual(data["h:X-MyHeader"], "my value") + self.assertEqual(data["h:Message-ID"], "mycustommsgid@example.com") + # multiple recipients, but not a batch send: + self.assertNotIn("recipient-variables", data) def test_html_message(self): - text_content = 'This is an important message.' - html_content = '

This is an important message.

' - email = mail.EmailMultiAlternatives('Subject', text_content, - 'from@example.com', ['to@example.com']) + text_content = "This is an important message." + html_content = "

This is an important message.

" + email = mail.EmailMultiAlternatives( + "Subject", text_content, "from@example.com", ["to@example.com"] + ) email.attach_alternative(html_content, "text/html") email.send() data = self.get_api_call_data() - self.assertEqual(data['text'], text_content) - self.assertEqual(data['html'], html_content) + self.assertEqual(data["text"], text_content) + self.assertEqual(data["html"], html_content) # Don't accidentally send the html part as an attachment: files = self.get_api_call_files(required=False) self.assertFalse(files) def test_html_only_message(self): - html_content = '

This is an important message.

' - email = mail.EmailMessage('Subject', html_content, 'from@example.com', ['to@example.com']) + html_content = "

This is an important message.

" + email = mail.EmailMessage( + "Subject", html_content, "from@example.com", ["to@example.com"] + ) email.content_subtype = "html" # Main content is now text/html email.send() data = self.get_api_call_data() - self.assertNotIn('text', data) - self.assertEqual(data['html'], html_content) + self.assertNotIn("text", data) + self.assertEqual(data["html"], html_content) def test_reply_to(self): - email = mail.EmailMessage('Subject', 'Body goes here', 'from@example.com', ['to1@example.com'], - reply_to=['reply@example.com', 'Other '], - headers={'X-Other': 'Keep'}) + email = mail.EmailMessage( + "Subject", + "Body goes here", + "from@example.com", + ["to1@example.com"], + reply_to=["reply@example.com", "Other "], + headers={"X-Other": "Keep"}, + ) email.send() data = self.get_api_call_data() - self.assertEqual(data['h:Reply-To'], 'reply@example.com, Other ') - self.assertEqual(data['h:X-Other'], 'Keep') # don't lose other headers + self.assertEqual( + data["h:Reply-To"], "reply@example.com, Other " + ) + self.assertEqual(data["h:X-Other"], "Keep") # don't lose other headers def test_attachments(self): text_content = "* Item one\n* Item two\n* Item three" - self.message.attach(filename="test.txt", content=text_content, mimetype="text/plain") + self.message.attach( + filename="test.txt", content=text_content, mimetype="text/plain" + ) # Should guess mimetype if not provided... png_content = b"PNG\xb4 pretend this is the contents of a png file" @@ -133,48 +185,62 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase): # Should work with a MIMEBase object... pdf_content = b"PDF\xb4 pretend this is valid pdf data" - mimeattachment = MIMEBase('application', 'pdf') + mimeattachment = MIMEBase("application", "pdf") mimeattachment.set_payload(pdf_content) - mimeattachment["Content-Disposition"] = 'attachment; filename="custom filename"' # Mailgun requires filename + # Mailgun requires filename: + mimeattachment["Content-Disposition"] = 'attachment; filename="custom filename"' self.message.attach(mimeattachment) # And also with an message/rfc822 attachment forwarded_email_content = sample_email_content() forwarded_email = message_from_bytes(forwarded_email_content) rfcmessage = MIMEBase("message", "rfc822") - rfcmessage.add_header("Content-Disposition", "attachment", - filename="forwarded message") # Mailgun requires filename + # Mailgun requires filename: + rfcmessage.add_header( + "Content-Disposition", "attachment", filename="forwarded message" + ) rfcmessage.attach(forwarded_email) self.message.attach(rfcmessage) self.message.send() files = self.get_api_call_files() - attachments = [value for (field, value) in files if field == 'attachment'] + attachments = [value for (field, value) in files if field == "attachment"] self.assertEqual(len(attachments), 4) - self.assertEqual(attachments[0], ('test.txt', text_content, 'text/plain')) - self.assertEqual(attachments[1], ('test.png', png_content, 'image/png')) # type inferred from filename - self.assertEqual(attachments[2], ("custom filename", pdf_content, 'application/pdf')) + self.assertEqual(attachments[0], ("test.txt", text_content, "text/plain")) + self.assertEqual( + # (type inferred from filename) + attachments[1], + ("test.png", png_content, "image/png"), + ) + self.assertEqual( + attachments[2], ("custom filename", pdf_content, "application/pdf") + ) # Email messages can get a bit changed with respect to whitespace characters # in headers, without breaking the message, so we tolerate that: self.assertEqual(attachments[3][0], "forwarded message") self.assertEqualIgnoringHeaderFolding( attachments[3][1], - b'Content-Type: message/rfc822\nMIME-Version: 1.0\n' + - b'Content-Disposition: attachment; filename="forwarded message"\n' + - b'\n' + forwarded_email_content) - self.assertEqual(attachments[3][2], 'message/rfc822') + b"Content-Type: message/rfc822\nMIME-Version: 1.0\n" + + b'Content-Disposition: attachment; filename="forwarded message"\n' + + b"\n" + + forwarded_email_content, + ) + self.assertEqual(attachments[3][2], "message/rfc822") # Make sure the image attachment is not treated as embedded: - inlines = [value for (field, value) in files if field == 'inline'] + inlines = [value for (field, value) in files if field == "inline"] self.assertEqual(len(inlines), 0) def test_unicode_attachment_correctly_decoded(self): - self.message.attach("Une pièce jointe.html", '

\u2019

', mimetype='text/html') + self.message.attach( + "Une pièce jointe.html", "

\u2019

", mimetype="text/html" + ) self.message.send() # Verify the RFC 7578 compliance workaround has kicked in: data = self.get_api_call_data() - if isinstance(data, dict): # workaround not needed or used (but let's double check actual request) + if isinstance(data, dict): + # workaround not needed or used (but let's double check actual request) workaround = False prepared = self.get_api_prepared_request() data = prepared.body @@ -182,18 +248,26 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase): workaround = True data = data.decode("utf-8").replace("\r\n", "\n") self.assertNotIn("filename*=", data) # No RFC 2231 encoding - self.assertIn('Content-Disposition: form-data; name="attachment"; filename="Une pièce jointe.html"', data) + self.assertIn( + 'Content-Disposition: form-data; name="attachment";' + ' filename="Une pièce jointe.html"', + data, + ) if workaround: files = self.get_api_call_files(required=False) self.assertFalse(files) # files should have been moved to formdata body def test_rfc_7578_compliance(self): - # Check some corner cases in the workaround that undoes RFC 2231 multipart/form-data encoding... + # Check some corner cases in the workaround + # that undoes RFC 2231 multipart/form-data encoding... self.message.subject = "Testing for filename*=utf-8''problems" - self.message.body = "The attached message should have an attachment named 'vedhæftet fil.txt'" + self.message.body = ( + "The attached message should have an attachment named 'vedhæftet fil.txt'" + ) # A forwarded message with its own attachment: - forwarded_message = dedent("""\ + forwarded_message = dedent( + """\ MIME-Version: 1.0 From: sender@example.com Subject: This is a test message @@ -209,41 +283,63 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase): This is an attachment. --boundary-- - """) - self.message.attach("besked med vedhæftede filer", forwarded_message, "message/rfc822") + """ + ) + self.message.attach( + "besked med vedhæftede filer", forwarded_message, "message/rfc822" + ) self.message.send() data = self.get_api_call_data() - if isinstance(data, dict): # workaround not needed or used (but let's double check actual request) + if isinstance(data, dict): + # workaround not needed or used (but let's double check actual request) prepared = self.get_api_prepared_request() data = prepared.body data = data.decode("utf-8").replace("\r\n", "\n") - # Top-level attachment (in form-data) should have RFC 7578 filename (raw Unicode): + # Top-level attachment (in form-data) + # should have RFC 7578 filename (raw Unicode): self.assertIn( - 'Content-Disposition: form-data; name="attachment"; filename="besked med vedhæftede filer"', data) - # Embedded message/rfc822 attachment should retain its RFC 2231 encoded filename: - self.assertIn("Content-Type: text/plain; name*=utf-8''vedh%C3%A6ftet%20fil.txt", data) - self.assertIn("Content-Disposition: attachment; filename*=utf-8''vedh%C3%A6ftet%20fil.txt", data) + 'Content-Disposition: form-data; name="attachment";' + ' filename="besked med vedhæftede filer"', + data, + ) + # Embedded message/rfc822 attachment should retain its RFC 2231 encoded + # filename: + self.assertIn( + "Content-Type: text/plain; name*=utf-8''vedh%C3%A6ftet%20fil.txt", data + ) + self.assertIn( + "Content-Disposition: attachment;" + " filename*=utf-8''vedh%C3%A6ftet%20fil.txt", + data, + ) # References to RFC 2231 in message text should remain intact: self.assertIn("Testing for filename*=utf-8''problems", data) - self.assertIn("The attached message should have an attachment named 'vedhæftet fil.txt'", data) + self.assertIn( + "The attached message should have an attachment named 'vedhæftet fil.txt'", + data, + ) def test_attachment_missing_filename(self): """Mailgun silently drops attachments without filenames, so warn the caller""" - mimeattachment = MIMEBase('application', 'pdf') + mimeattachment = MIMEBase("application", "pdf") mimeattachment.set_payload(b"PDF\xb4 pretend this is valid pdf data") - mimeattachment["Content-Disposition"] = 'attachment' + mimeattachment["Content-Disposition"] = "attachment" self.message.attach(mimeattachment) - with self.assertRaisesMessage(AnymailUnsupportedFeature, "attachments without filenames"): + with self.assertRaisesMessage( + AnymailUnsupportedFeature, "attachments without filenames" + ): self.message.send() def test_inline_missing_contnet_id(self): mimeattachment = MIMEImage(b"imagedata", "x-fakeimage") mimeattachment["Content-Disposition"] = 'inline; filename="fakeimage.txt"' self.message.attach(mimeattachment) - with self.assertRaisesMessage(AnymailUnsupportedFeature, "inline attachments without Content-ID"): + with self.assertRaisesMessage( + AnymailUnsupportedFeature, "inline attachments without Content-ID" + ): self.message.send() def test_embedded_images(self): @@ -252,19 +348,22 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase): image_data = sample_image_content(image_filename) cid = attach_inline_image_file(self.message, image_path) - html_content = '

This has an inline image.

' % cid + html_content = ( + '

This has an inline image.

' % cid + ) self.message.attach_alternative(html_content, "text/html") self.message.send() data = self.get_api_call_data() - self.assertEqual(data['html'], html_content) + self.assertEqual(data["html"], html_content) files = self.get_api_call_files() - inlines = [value for (field, value) in files if field == 'inline'] + inlines = [value for (field, value) in files if field == "inline"] self.assertEqual(len(inlines), 1) - self.assertEqual(inlines[0], (cid, image_data, "image/png")) # filename is cid; type is guessed + # filename is cid; type is guessed: + self.assertEqual(inlines[0], (cid, image_data, "image/png")) # Make sure neither the html nor the inline image is treated as an attachment: - attachments = [value for (field, value) in files if field == 'attachment'] + attachments = [value for (field, value) in files if field == "attachment"] self.assertEqual(len(attachments), 0) def test_attached_images(self): @@ -272,20 +371,23 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase): image_path = sample_image_path(image_filename) image_data = sample_image_content(image_filename) - self.message.attach_file(image_path) # option 1: attach as a file + # option 1: attach as a file: + self.message.attach_file(image_path) - image = MIMEImage(image_data) # option 2: construct the MIMEImage and attach it directly - image.set_param("filename", "custom-filename", "Content-Disposition") # Mailgun requires filenames + # option 2: construct the MIMEImage and attach it directly: + image = MIMEImage(image_data) + # Mailgun requires filenames: + image.set_param("filename", "custom-filename", "Content-Disposition") self.message.attach(image) self.message.send() files = self.get_api_call_files() - attachments = [value for (field, value) in files if field == 'attachment'] + attachments = [value for (field, value) in files if field == "attachment"] self.assertEqual(len(attachments), 2) - self.assertEqual(attachments[0], (image_filename, image_data, 'image/png')) - self.assertEqual(attachments[1], ("custom-filename", image_data, 'image/png')) + self.assertEqual(attachments[0], (image_filename, image_data, "image/png")) + self.assertEqual(attachments[1], ("custom-filename", image_data, "image/png")) # Make sure the image attachments are not treated as inline: - inlines = [value for (field, value) in files if field == 'inline'] + inlines = [value for (field, value) in files if field == "inline"] self.assertEqual(len(inlines), 0) def test_multiple_html_alternatives(self): @@ -321,39 +423,46 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase): """Empty to, cc, bcc, and reply_to shouldn't generate empty headers""" self.message.send() data = self.get_api_call_data() - self.assertNotIn('cc', data) - self.assertNotIn('bcc', data) - self.assertNotIn('h:Reply-To', data) + self.assertNotIn("cc", data) + self.assertNotIn("bcc", data) + self.assertNotIn("h:Reply-To", data) - # Test empty `to` -- but send requires at least one recipient somewhere (like cc) + # Test empty `to`--but send requires at least one recipient somewhere (like cc) self.message.to = [] - self.message.cc = ['cc@example.com'] + self.message.cc = ["cc@example.com"] self.message.send() data = self.get_api_call_data() - self.assertNotIn('to', data) + self.assertNotIn("to", data) def test_multiple_from_emails(self): """Mailgun supports multiple addresses in from_email""" self.message.from_email = 'first@example.com, "From, also" ' self.message.send() data = self.get_api_call_data() - self.assertEqual(data['from'], ['first@example.com', - '"From, also" ']) + self.assertEqual( + data["from"], ["first@example.com", '"From, also" '] + ) # Make sure the far-more-likely scenario of a single from_email # with an unquoted display-name issues a reasonable error: - self.message.from_email = 'Unquoted, display-name ' + self.message.from_email = "Unquoted, display-name " with self.assertRaises(AnymailInvalidAddress): self.message.send() def test_api_failure(self): self.set_mock_response(status_code=400) with self.assertRaisesMessage(AnymailAPIError, "Mailgun API response 400"): - mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com']) + mail.send_mail("Subject", "Body", "from@example.com", ["to@example.com"]) # Make sure fail_silently is respected self.set_mock_response(status_code=400) - sent = mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com'], fail_silently=True) + sent = mail.send_mail( + "Subject", + "Body", + "from@example.com", + ["to@example.com"], + fail_silently=True, + ) self.assertEqual(sent, 0) def test_api_error_includes_details(self): @@ -361,7 +470,9 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase): # JSON error response: error_response = b"""{"message": "Helpful explanation from your ESP"}""" self.set_mock_response(status_code=400, raw=error_response) - with self.assertRaisesMessage(AnymailAPIError, "Helpful explanation from your ESP"): + with self.assertRaisesMessage( + AnymailAPIError, "Helpful explanation from your ESP" + ): self.message.send() # Non-JSON error response: @@ -376,32 +487,41 @@ class MailgunBackendStandardEmailTests(MailgunBackendMockAPITestCase): def test_requests_exception(self): """Exception during API call should be AnymailAPIError""" - # (The post itself raises an error -- different from returning a failure response) + # (The post itself raises an error--different from returning a failure response) from requests.exceptions import SSLError # a low-level requests exception + self.mock_request.side_effect = SSLError("Something bad") with self.assertRaisesMessage(AnymailRequestsAPIError, "Something bad") as cm: self.message.send() - self.assertIsInstance(cm.exception, SSLError) # also retains specific requests exception class + # also retains specific requests exception class: + self.assertIsInstance(cm.exception, SSLError) # Make sure fail_silently is respected self.mock_request.side_effect = SSLError("Something bad") - sent = mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com'], fail_silently=True) + sent = mail.send_mail( + "Subject", + "Body", + "from@example.com", + ["to@example.com"], + fail_silently=True, + ) self.assertEqual(sent, 0) -@tag('mailgun') +@tag("mailgun") class MailgunBackendAnymailFeatureTests(MailgunBackendMockAPITestCase): """Test backend support for Anymail added features""" def test_metadata(self): - # Each metadata value is just a string; you can serialize your own JSON if you'd like. - # (The Mailgun docs are a little confusing on this point.) - self.message.metadata = {'user_id': "12345", 'items': '["mail","gun"]'} + # Each metadata value is just a string; you can serialize your own JSON + # if you'd like. (The Mailgun docs are a little confusing on this point.) + self.message.metadata = {"user_id": "12345", "items": '["mail","gun"]'} self.message.send() data = self.get_api_call_data() - self.assertEqual(data['v:user_id'], '12345') - self.assertEqual(data['v:items'], '["mail","gun"]') - self.assertNotIn('recipient-variables', data) # shouldn't be needed for non-batch + self.assertEqual(data["v:user_id"], "12345") + self.assertEqual(data["v:items"], '["mail","gun"]') + # shouldn't be needed for non-batch: + self.assertNotIn("recipient-variables", data) def test_send_at(self): utc_plus_6 = get_fixed_timezone(6 * 60) @@ -412,37 +532,43 @@ class MailgunBackendAnymailFeatureTests(MailgunBackendMockAPITestCase): self.message.send_at = datetime(2016, 3, 4, 5, 6, 7, tzinfo=utc_minus_8) self.message.send() data = self.get_api_call_data() - self.assertEqual(data['o:deliverytime'], "Fri, 04 Mar 2016 13:06:07 GMT") # 05:06 UTC-8 == 13:06 UTC + self.assertEqual( + data["o:deliverytime"], "Fri, 04 Mar 2016 13:06:07 GMT" + ) # 05:06 UTC-8 == 13:06 UTC # Timezone-naive datetime assumed to be Django current_timezone self.message.send_at = datetime(2022, 10, 11, 12, 13, 14, 567) self.message.send() data = self.get_api_call_data() - self.assertEqual(data['o:deliverytime'], "Tue, 11 Oct 2022 06:13:14 GMT") # 12:13 UTC+6 == 06:13 UTC + self.assertEqual( + data["o:deliverytime"], "Tue, 11 Oct 2022 06:13:14 GMT" + ) # 12:13 UTC+6 == 06:13 UTC # Date-only treated as midnight in current timezone self.message.send_at = date(2022, 10, 22) self.message.send() data = self.get_api_call_data() - self.assertEqual(data['o:deliverytime'], "Fri, 21 Oct 2022 18:00:00 GMT") # 00:00 UTC+6 == 18:00-1d UTC + self.assertEqual( + data["o:deliverytime"], "Fri, 21 Oct 2022 18:00:00 GMT" + ) # 00:00 UTC+6 == 18:00-1d UTC # POSIX timestamp self.message.send_at = 1651820889 # 2022-05-06 07:08:09 UTC self.message.send() data = self.get_api_call_data() - self.assertEqual(data['o:deliverytime'], "Fri, 06 May 2022 07:08:09 GMT") + self.assertEqual(data["o:deliverytime"], "Fri, 06 May 2022 07:08:09 GMT") # String passed unchanged (this is *not* portable between ESPs) self.message.send_at = "Thu, 13 Oct 2022 18:02:00 GMT" self.message.send() data = self.get_api_call_data() - self.assertEqual(data['o:deliverytime'], "Thu, 13 Oct 2022 18:02:00 GMT") + self.assertEqual(data["o:deliverytime"], "Thu, 13 Oct 2022 18:02:00 GMT") def test_tags(self): self.message.tags = ["receipt", "repeat-user"] self.message.send() data = self.get_api_call_data() - self.assertEqual(data['o:tag'], ["receipt", "repeat-user"]) + self.assertEqual(data["o:tag"], ["receipt", "repeat-user"]) def test_tracking(self): # Test one way... @@ -450,58 +576,79 @@ class MailgunBackendAnymailFeatureTests(MailgunBackendMockAPITestCase): self.message.track_clicks = False self.message.send() data = self.get_api_call_data() - self.assertEqual(data['o:tracking-opens'], 'yes') - self.assertEqual(data['o:tracking-clicks'], 'no') + self.assertEqual(data["o:tracking-opens"], "yes") + self.assertEqual(data["o:tracking-clicks"], "no") # ...and the opposite way self.message.track_opens = False self.message.track_clicks = True self.message.send() data = self.get_api_call_data() - self.assertEqual(data['o:tracking-opens'], 'no') - self.assertEqual(data['o:tracking-clicks'], 'yes') + self.assertEqual(data["o:tracking-opens"], "no") + self.assertEqual(data["o:tracking-clicks"], "yes") def test_template_id(self): self.message.template_id = "welcome_template" self.message.send() data = self.get_api_call_data() - self.assertEqual(data['template'], "welcome_template") + self.assertEqual(data["template"], "welcome_template") def test_merge_data(self): - self.message.to = ['alice@example.com', 'Bob '] - self.message.body = "Hi %recipient.name%. Welcome to %recipient.group% at %recipient.site%." + self.message.to = ["alice@example.com", "Bob "] + self.message.body = ( + "Hi %recipient.name%. Welcome to %recipient.group% at %recipient.site%." + ) self.message.merge_data = { - 'alice@example.com': {'name': "Alice", 'group': "Developers"}, - 'bob@example.com': {'name': "Bob"}, # and leave group undefined + "alice@example.com": {"name": "Alice", "group": "Developers"}, + "bob@example.com": {"name": "Bob"}, # and leave group undefined } self.message.merge_global_data = { - 'group': "Users", # default - 'site': "ExampleCo", + "group": "Users", # default + "site": "ExampleCo", } self.message.send() data = self.get_api_call_data() - self.assertJSONEqual(data['recipient-variables'], { - 'alice@example.com': {'name': "Alice", 'group': "Developers", 'site': "ExampleCo"}, - 'bob@example.com': {'name': "Bob", 'group': "Users", 'site': "ExampleCo"}, - }) + self.assertJSONEqual( + data["recipient-variables"], + { + "alice@example.com": { + "name": "Alice", + "group": "Developers", + "site": "ExampleCo", + }, + "bob@example.com": { + "name": "Bob", + "group": "Users", + "site": "ExampleCo", + }, + }, + ) # Make sure we didn't modify original dicts on message: - self.assertEqual(self.message.merge_data, { - 'alice@example.com': {'name': "Alice", 'group': "Developers"}, - 'bob@example.com': {'name': "Bob"}, - }) - self.assertEqual(self.message.merge_global_data, {'group': "Users", 'site': "ExampleCo"}) + self.assertEqual( + self.message.merge_data, + { + "alice@example.com": {"name": "Alice", "group": "Developers"}, + "bob@example.com": {"name": "Bob"}, + }, + ) + self.assertEqual( + self.message.merge_global_data, {"group": "Users", "site": "ExampleCo"} + ) def test_only_merge_global_data(self): # Make sure merge_global_data distributed to recipient-variables # even when merge_data not set - self.message.to = ['alice@example.com', 'Bob '] - self.message.merge_global_data = {'test': "value"} + self.message.to = ["alice@example.com", "Bob "] + self.message.merge_global_data = {"test": "value"} self.message.send() data = self.get_api_call_data() - self.assertJSONEqual(data['recipient-variables'], { - 'alice@example.com': {'test': "value"}, - 'bob@example.com': {'test': "value"}, - }) + self.assertJSONEqual( + data["recipient-variables"], + { + "alice@example.com": {"test": "value"}, + "bob@example.com": {"test": "value"}, + }, + ) def test_merge_data_with_template(self): # Mailgun *stored* (handlebars) templates get their variable substitutions @@ -509,195 +656,257 @@ class MailgunBackendAnymailFeatureTests(MailgunBackendMockAPITestCase): # with stored templates, Anymail sets up custom-data to pull values from # recipient-variables. (Note this same Mailgun custom-data is also used for # webhook metadata tracking.) - self.message.to = ['alice@example.com', 'Bob '] - self.message.template_id = 'welcome_template' + self.message.to = ["alice@example.com", "Bob "] + self.message.template_id = "welcome_template" self.message.merge_data = { - 'alice@example.com': {'name': "Alice", 'group': "Developers"}, - 'bob@example.com': {'name': "Bob"}, # and leave group undefined + "alice@example.com": {"name": "Alice", "group": "Developers"}, + "bob@example.com": {"name": "Bob"}, # and leave group undefined } self.message.merge_global_data = { - 'group': "Users", # default - 'site': "ExampleCo", + "group": "Users", # default + "site": "ExampleCo", } self.message.send() data = self.get_api_call_data() # custom-data variables for merge_data refer to recipient-variables: - self.assertEqual(data['v:name'], '%recipient.name%') - self.assertEqual(data['v:group'], '%recipient.group%') - self.assertEqual(data['v:site'], '%recipient.site%') + self.assertEqual(data["v:name"], "%recipient.name%") + self.assertEqual(data["v:group"], "%recipient.group%") + self.assertEqual(data["v:site"], "%recipient.site%") # recipient-variables populates them: - self.assertJSONEqual(data['recipient-variables'], { - 'alice@example.com': {'name': "Alice", 'group': "Developers", 'site': "ExampleCo"}, - 'bob@example.com': {'name': "Bob", 'group': "Users", 'site': "ExampleCo"}, - }) + self.assertJSONEqual( + data["recipient-variables"], + { + "alice@example.com": { + "name": "Alice", + "group": "Developers", + "site": "ExampleCo", + }, + "bob@example.com": { + "name": "Bob", + "group": "Users", + "site": "ExampleCo", + }, + }, + ) def test_merge_metadata(self): # Per-recipient custom-data uses the same recipient-variables mechanism # as above, but prepends 'v:' to the recipient-data keys for metadata to # keep them separate. # (For on-the-fly templates -- not stored handlebars templates.) - self.message.to = ['alice@example.com', 'Bob '] + self.message.to = ["alice@example.com", "Bob "] self.message.merge_metadata = { - 'alice@example.com': {'order_id': 123, 'tier': 'premium'}, - 'bob@example.com': {'order_id': 678}, + "alice@example.com": {"order_id": 123, "tier": "premium"}, + "bob@example.com": {"order_id": 678}, } - self.message.metadata = {'tier': 'basic', 'notification_batch': 'zx912'} + self.message.metadata = {"tier": "basic", "notification_batch": "zx912"} self.message.send() data = self.get_api_call_data() # custom-data variables for merge_metadata refer to recipient-variables: - self.assertEqual(data['v:order_id'], '%recipient.v:order_id%') - self.assertEqual(data['v:tier'], '%recipient.v:tier%') - self.assertEqual(data['v:notification_batch'], 'zx912') # metadata constant doesn't need var + self.assertEqual(data["v:order_id"], "%recipient.v:order_id%") + self.assertEqual(data["v:tier"], "%recipient.v:tier%") + # metadata constant doesn't need var: + self.assertEqual(data["v:notification_batch"], "zx912") # recipient-variables populates them: - self.assertJSONEqual(data['recipient-variables'], { - 'alice@example.com': {'v:order_id': 123, 'v:tier': 'premium'}, - 'bob@example.com': {'v:order_id': 678, 'v:tier': 'basic'}, # tier merged from metadata default - }) + self.assertJSONEqual( + data["recipient-variables"], + { + "alice@example.com": {"v:order_id": 123, "v:tier": "premium"}, + "bob@example.com": { + "v:order_id": 678, + "v:tier": "basic", # merged from metadata default + }, + }, + ) def test_merge_data_with_merge_metadata(self): # merge_data and merge_metadata both use recipient-variables - self.message.to = ['alice@example.com', 'Bob '] - self.message.body = "Hi %recipient.name%. Welcome to %recipient.group% at %recipient.site%." + self.message.to = ["alice@example.com", "Bob "] + self.message.body = ( + "Hi %recipient.name%. Welcome to %recipient.group% at %recipient.site%." + ) self.message.merge_data = { - 'alice@example.com': {'name': "Alice", 'group': "Developers"}, - 'bob@example.com': {'name': "Bob"}, # and leave group undefined + "alice@example.com": {"name": "Alice", "group": "Developers"}, + "bob@example.com": {"name": "Bob"}, # and leave group undefined } self.message.merge_metadata = { - 'alice@example.com': {'order_id': 123, 'tier': 'premium'}, - 'bob@example.com': {'order_id': 678}, # and leave tier undefined + "alice@example.com": {"order_id": 123, "tier": "premium"}, + "bob@example.com": {"order_id": 678}, # and leave tier undefined } self.message.send() data = self.get_api_call_data() - self.assertJSONEqual(data['recipient-variables'], { - 'alice@example.com': {'name': "Alice", 'group': "Developers", - 'v:order_id': 123, 'v:tier': 'premium'}, - 'bob@example.com': {'name': "Bob", 'group': '', # undefined merge_data --> empty string - 'v:order_id': 678, 'v:tier': ''}, # undefined metadata --> empty string - }) + self.assertJSONEqual( + data["recipient-variables"], + { + "alice@example.com": { + "name": "Alice", + "group": "Developers", + "v:order_id": 123, + "v:tier": "premium", + }, + "bob@example.com": { + "name": "Bob", + "group": "", # undefined merge_data --> empty string + "v:order_id": 678, + "v:tier": "", # undefined metadata --> empty string + }, + }, + ) def test_merge_data_with_merge_metadata_and_template(self): - # This case gets tricky, because when a stored template is used, the per-recipient - # merge_metadata and merge_data both end up in the same Mailgun custom-data keys. - self.message.to = ['alice@example.com', 'Bob '] - self.message.template_id = 'order_notification' + # This case gets tricky, because when a stored template is used, + # the per-recipient merge_metadata and merge_data both end up + # in the same Mailgun custom-data keys. + self.message.to = ["alice@example.com", "Bob "] + self.message.template_id = "order_notification" self.message.merge_data = { - 'alice@example.com': {'name': "Alice", 'group': "Developers"}, - 'bob@example.com': {'name': "Bob"}, # and leave group undefined + "alice@example.com": {"name": "Alice", "group": "Developers"}, + "bob@example.com": {"name": "Bob"}, # and leave group undefined } self.message.merge_metadata = { - 'alice@example.com': {'order_id': 123, 'tier': 'premium'}, - 'bob@example.com': {'order_id': 678}, # and leave tier undefined + "alice@example.com": {"order_id": 123, "tier": "premium"}, + "bob@example.com": {"order_id": 678}, # and leave tier undefined } self.message.send() data = self.get_api_call_data() - # custom-data covers both merge_data and merge_metadata: - self.assertEqual(data['v:name'], '%recipient.name%') # from merge_data - self.assertEqual(data['v:group'], '%recipient.group%') # from merge_data - self.assertEqual(data['v:order_id'], '%recipient.v:order_id%') # from merge_metadata - self.assertEqual(data['v:tier'], '%recipient.v:tier%') # from merge_metadata - self.assertJSONEqual(data['recipient-variables'], { - 'alice@example.com': {'name': "Alice", 'group': "Developers", - 'v:order_id': 123, 'v:tier': 'premium'}, - 'bob@example.com': {'name': "Bob", 'group': '', # undefined merge_data --> empty string - 'v:order_id': 678, 'v:tier': ''}, # undefined metadata --> empty string - }) + # custom-data covers both merge_data and merge_metadata... + + # from merge_data: + self.assertEqual(data["v:name"], "%recipient.name%") + self.assertEqual(data["v:group"], "%recipient.group%") + + # from merge_metadata: + self.assertEqual(data["v:order_id"], "%recipient.v:order_id%") + self.assertEqual(data["v:tier"], "%recipient.v:tier%") + + self.assertJSONEqual( + data["recipient-variables"], + { + "alice@example.com": { + "name": "Alice", + "group": "Developers", + "v:order_id": 123, + "v:tier": "premium", + }, + "bob@example.com": { + "name": "Bob", + "group": "", # undefined merge_data --> empty string + "v:order_id": 678, + "v:tier": "", # undefined metadata --> empty string + }, + }, + ) def test_conflicting_merge_data_with_merge_metadata_and_template(self): # When a stored template is used, the same Mailgun custom-data must hold both # per-recipient merge_data and metadata, so there's potential for conflict. - self.message.to = ['alice@example.com', 'Bob '] - self.message.template_id = 'order_notification' + self.message.to = ["alice@example.com", "Bob "] + self.message.template_id = "order_notification" self.message.merge_data = { - 'alice@example.com': {'name': "Alice", 'group': "Developers"}, - 'bob@example.com': {'name': "Bob"}, + "alice@example.com": {"name": "Alice", "group": "Developers"}, + "bob@example.com": {"name": "Bob"}, } - self.message.metadata = {'group': "Order processing subsystem"} + self.message.metadata = {"group": "Order processing subsystem"} with self.assertRaisesMessage( AnymailUnsupportedFeature, - "conflicting merge_data and metadata keys ('group') when using template_id" + "conflicting merge_data and metadata keys ('group') when using template_id", ): self.message.send() def test_force_batch(self): # Mailgun uses presence of recipient-variables to indicate batch send - self.message.to = ['alice@example.com', 'Bob '] + self.message.to = ["alice@example.com", "Bob "] self.message.merge_data = {} self.message.send() data = self.get_api_call_data() - self.assertJSONEqual(data['recipient-variables'], {}) + self.assertJSONEqual(data["recipient-variables"], {}) def test_sender_domain(self): - """Mailgun send domain can come from from_email, envelope_sender, or esp_extra""" - # You could also use MAILGUN_SENDER_DOMAIN in your ANYMAIL settings, as in the next test. - # (The mailgun_integration_tests also do that.) + """ + Mailgun send domain can come from from_email, envelope_sender, or esp_extra + """ + # You could also use MAILGUN_SENDER_DOMAIN in your ANYMAIL settings, + # as in the next test. (The mailgun_integration_tests also do that.) self.message.from_email = "Test From " self.message.send() - self.assert_esp_called('/from-email.example.com/messages') # API url includes the sender-domain + # API url includes the sender-domain: + self.assert_esp_called("/from-email.example.com/messages") + # envelope_sender (only the domain part) overrides from_email: self.message.from_email = "Test From " - self.message.envelope_sender = "anything@bounces.example.com" # only the domain part is used + self.message.envelope_sender = "anything@bounces.example.com" self.message.send() - self.assert_esp_called('/bounces.example.com/messages') # overrides from_email + self.assert_esp_called("/bounces.example.com/messages") + # esp_extra sender_domain overrides from_email: self.message.from_email = "Test From " - self.message.esp_extra = {'sender_domain': 'esp-extra.example.com'} + self.message.esp_extra = {"sender_domain": "esp-extra.example.com"} self.message.send() - self.assert_esp_called('/esp-extra.example.com/messages') # overrides from_email + self.assert_esp_called("/esp-extra.example.com/messages") - @override_settings(ANYMAIL_MAILGUN_SENDER_DOMAIN='mg.example.com') + @override_settings(ANYMAIL_MAILGUN_SENDER_DOMAIN="mg.example.com") def test_sender_domain_setting(self): + # setting overrides from_email self.message.send() - self.assert_esp_called('/mg.example.com/messages') # setting overrides from_email + self.assert_esp_called("/mg.example.com/messages") def test_invalid_sender_domain(self): # Make sure we won't construct an invalid API endpoint like # `https://api.mailgun.net/v3/example.com/INVALID/messages` # (which returns a cryptic 200-OK "Mailgun Magnificent API" response). self.message.from_email = "" - with self.assertRaisesMessage(AnymailError, - "Invalid '/' in sender domain 'example.com/invalid'"): + with self.assertRaisesMessage( + AnymailError, "Invalid '/' in sender domain 'example.com/invalid'" + ): self.message.send() - @override_settings(ANYMAIL_MAILGUN_SENDER_DOMAIN='example.com%2Finvalid') + @override_settings(ANYMAIL_MAILGUN_SENDER_DOMAIN="example.com%2Finvalid") def test_invalid_sender_domain_setting(self): # See previous test. Also, note that Mailgun unquotes % encoding *before* # extracting the sender domain (so %2f is just as bad as '/') - with self.assertRaisesMessage(AnymailError, - "Invalid '/' in sender domain 'example.com%2Finvalid'"): + with self.assertRaisesMessage( + AnymailError, "Invalid '/' in sender domain 'example.com%2Finvalid'" + ): self.message.send() - @override_settings(ANYMAIL_MAILGUN_SENDER_DOMAIN='example.com # oops') + @override_settings(ANYMAIL_MAILGUN_SENDER_DOMAIN="example.com # oops") def test_encode_sender_domain(self): # See previous tests. For anything other than slashes, we let Mailgun detect # the problem (but must properly encode the domain in the API URL) self.message.send() - self.assert_esp_called('/example.com%20%23%20oops/messages') + self.assert_esp_called("/example.com%20%23%20oops/messages") def test_unknown_sender_domain(self): - self.set_mock_response(raw=b"""{ + self.set_mock_response( + raw=b"""{ "message": "Domain not found: example.com" - }""", status_code=404) + }""", + status_code=404, + ) with self.assertRaisesMessage( AnymailAPIError, "Unknown sender domain 'example.com'.\n" - "Check the domain is verified with Mailgun, and that the ANYMAIL MAILGUN_API_URL" - " setting 'https://api.mailgun.net/v3/' is the correct region." + "Check the domain is verified with Mailgun, and that the" + " ANYMAIL MAILGUN_API_URL setting 'https://api.mailgun.net/v3/'" + " is the correct region.", ): self.message.send() @override_settings( # This is *not* a valid MAILGUN_API_URL setting (it should end at "...v3/"): - ANYMAIL_MAILGUN_API_URL='https://api.mailgun.net/v3/example.com/messages') + ANYMAIL_MAILGUN_API_URL="https://api.mailgun.net/v3/example.com/messages" + ) def test_magnificent_api(self): # (Wouldn't a truly "magnificent API" just provide a helpful error message?) self.set_mock_response(raw=b"Mailgun Magnificent API", status_code=200) with self.assertRaisesMessage( AnymailAPIError, - "Invalid Mailgun API endpoint 'https://api.mailgun.net/v3/example.com/messages/example.com/messages'.\n" - "Check your ANYMAIL MAILGUN_SENDER_DOMAIN and MAILGUN_API_URL settings." + "Invalid Mailgun API endpoint 'https://api.mailgun.net/v3/example.com" + "/messages/example.com/messages'.\n" + "Check your ANYMAIL MAILGUN_SENDER_DOMAIN and MAILGUN_API_URL settings.", ): self.message.send() @@ -709,32 +918,45 @@ class MailgunBackendAnymailFeatureTests(MailgunBackendMockAPITestCase): that your ESP account settings apply by default. """ self.message.send() - self.assert_esp_called('/example.com/messages') + self.assert_esp_called("/example.com/messages") data = self.get_api_call_data() - mailgun_fields = {key: value for key, value in data.items() - if key.startswith('o:') or key.startswith('v:')} + mailgun_fields = { + key: value + for key, value in data.items() + if key.startswith("o:") or key.startswith("v:") + } self.assertEqual(mailgun_fields, {}) # noinspection PyUnresolvedReferences def test_send_attaches_anymail_status(self): - """ The anymail_status should be attached to the message when it is sent """ + """The anymail_status should be attached to the message when it is sent""" response_content = b"""{ "id": "<12345.67890@example.com>", "message": "Queued. Thank you." }""" self.set_mock_response(raw=response_content) - msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],) + msg = mail.EmailMessage( + "Subject", + "Message", + "from@example.com", + ["to1@example.com"], + ) sent = msg.send() self.assertEqual(sent, 1) - self.assertEqual(msg.anymail_status.status, {'queued'}) - self.assertEqual(msg.anymail_status.message_id, '<12345.67890@example.com>') - self.assertEqual(msg.anymail_status.recipients['to1@example.com'].status, 'queued') - self.assertEqual(msg.anymail_status.recipients['to1@example.com'].message_id, '<12345.67890@example.com>') + self.assertEqual(msg.anymail_status.status, {"queued"}) + self.assertEqual(msg.anymail_status.message_id, "<12345.67890@example.com>") + self.assertEqual( + msg.anymail_status.recipients["to1@example.com"].status, "queued" + ) + self.assertEqual( + msg.anymail_status.recipients["to1@example.com"].message_id, + "<12345.67890@example.com>", + ) 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""" + """If the send fails, anymail_status should contain initial values""" self.set_mock_response(status_code=500) sent = self.message.send(fail_silently=True) self.assertEqual(sent, 0) @@ -745,9 +967,12 @@ class MailgunBackendAnymailFeatureTests(MailgunBackendMockAPITestCase): # noinspection PyUnresolvedReferences def test_send_unparsable_response(self): - """If the send succeeds, but a non-JSON API response, should raise an API exception""" - mock_response = self.set_mock_response(status_code=200, - raw=b"yikes, this isn't a real response") + """ + If the send succeeds, but a non-JSON API response, should raise an API exception + """ + mock_response = self.set_mock_response( + status_code=200, raw=b"yikes, this isn't a real response" + ) with self.assertRaises(AnymailAPIError): self.message.send() self.assertIsNone(self.message.anymail_status.status) @@ -759,9 +984,11 @@ class MailgunBackendAnymailFeatureTests(MailgunBackendMockAPITestCase): # (Anything that requests can serialize as a form field will work with Mailgun) -@tag('mailgun') +@tag("mailgun") class MailgunBackendRecipientsRefusedTests(MailgunBackendMockAPITestCase): - """Should raise AnymailRecipientsRefused when *all* recipients are rejected or invalid""" + """ + Should raise AnymailRecipientsRefused when *all* recipients are rejected or invalid + """ # Mailgun doesn't check email bounce or complaint lists at time of send -- # it always just queues the message. You'll need to listen for the "rejected" @@ -773,39 +1000,49 @@ class MailgunBackendRecipientsRefusedTests(MailgunBackendMockAPITestCase): "message": "'to' parameter is not a valid address. please check documentation" }""" - # NOTE: As of Anymail 0.10, Anymail catches actually-invalid recipient emails - # before attempting to pass them along to the ESP, so the tests below use technically + # NOTE: As of Anymail 0.10, Anymail catches actually-invalid recipient emails before + # attempting to pass them along to the ESP, so the tests below use technically # valid emails that would actually be accepted by Mailgun. (We're just making sure # the backend would correctly handle the 400 response if something slipped through.) def test_invalid_email(self): self.set_mock_response(status_code=400, raw=self.INVALID_TO_RESPONSE) - msg = mail.EmailMessage('Subject', 'Body', 'from@example.com', to=['not-really@invalid']) + msg = mail.EmailMessage( + "Subject", "Body", "from@example.com", to=["not-really@invalid"] + ) with self.assertRaises(AnymailAPIError): msg.send() def test_fail_silently(self): self.set_mock_response(status_code=400, raw=self.INVALID_TO_RESPONSE) - sent = mail.send_mail('Subject', 'Body', 'from@example.com', ['not-really@invalid'], - fail_silently=True) + sent = mail.send_mail( + "Subject", + "Body", + "from@example.com", + ["not-really@invalid"], + fail_silently=True, + ) self.assertEqual(sent, 0) -@tag('mailgun') -class MailgunBackendSessionSharingTestCase(SessionSharingTestCases, MailgunBackendMockAPITestCase): +@tag("mailgun") +class MailgunBackendSessionSharingTestCase( + SessionSharingTestCases, MailgunBackendMockAPITestCase +): """Requests session sharing tests""" + pass # tests are defined in SessionSharingTestCases -@tag('mailgun') +@tag("mailgun") @override_settings(EMAIL_BACKEND="anymail.backends.mailgun.EmailBackend") class MailgunBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase): """Test ESP backend without required settings in place""" def test_missing_api_key(self): with self.assertRaises(ImproperlyConfigured) as cm: - mail.send_mail('Subject', 'Message', 'from@example.com', ['to@example.com']) + mail.send_mail("Subject", "Message", "from@example.com", ["to@example.com"]) errmsg = str(cm.exception) # Make sure the error mentions MAILGUN_API_KEY and ANYMAIL_MAILGUN_API_KEY - self.assertRegex(errmsg, r'\bMAILGUN_API_KEY\b') - self.assertRegex(errmsg, r'\bANYMAIL_MAILGUN_API_KEY\b') + self.assertRegex(errmsg, r"\bMAILGUN_API_KEY\b") + self.assertRegex(errmsg, r"\bANYMAIL_MAILGUN_API_KEY\b") diff --git a/tests/test_mailgun_inbound.py b/tests/test_mailgun_inbound.py index a52d48e..958b49d 100644 --- a/tests/test_mailgun_inbound.py +++ b/tests/test_mailgun_inbound.py @@ -12,128 +12,181 @@ from anymail.signals import AnymailInboundEvent from anymail.webhooks.mailgun import MailgunInboundWebhookView from .test_mailgun_webhooks import ( - TEST_WEBHOOK_SIGNING_KEY, mailgun_sign_payload, - mailgun_sign_legacy_payload, querydict_to_postdict) -from .utils import sample_image_content, sample_email_content, encode_multipart, make_fileobj + TEST_WEBHOOK_SIGNING_KEY, + mailgun_sign_legacy_payload, + mailgun_sign_payload, + querydict_to_postdict, +) +from .utils import ( + encode_multipart, + make_fileobj, + sample_email_content, + sample_image_content, +) from .webhook_cases import WebhookTestCase -@tag('mailgun') +@tag("mailgun") @override_settings(ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY=TEST_WEBHOOK_SIGNING_KEY) class MailgunInboundTestCase(WebhookTestCase): def test_inbound_basics(self): - raw_event = mailgun_sign_legacy_payload({ - 'token': '06c96bafc3f42a66b9edd546347a2fe18dc23461fe80dc52f0', - 'timestamp': '1461261330', - 'recipient': 'test@inbound.example.com', - 'sender': 'envelope-from@example.org', - 'message-headers': json.dumps([ - ["X-Mailgun-Spam-Rules", "DKIM_SIGNED, DKIM_VALID, DKIM_VALID_AU, ..."], - ["X-Mailgun-Dkim-Check-Result", "Pass"], - ["X-Mailgun-Spf", "Pass"], - ["X-Mailgun-Sscore", "1.7"], - ["X-Mailgun-Sflag", "No"], - ["X-Mailgun-Incoming", "Yes"], - ["X-Envelope-From", ""], - ["Received", "from mail.example.org by mxa.mailgun.org ..."], - ["Received", "by mail.example.org for ..."], - ["Dkim-Signature", "v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.org; ..."], - ["Mime-Version", "1.0"], - ["Received", "by 10.10.1.71 with HTTP; Wed, 11 Oct 2017 18:31:04 -0700 (PDT)"], - ["From", "\"Displayed From\" "], - ["Date", "Wed, 11 Oct 2017 18:31:04 -0700"], - ["Message-Id", ""], - ["Subject", "Test subject"], - ["To", "\"Test Inbound\" , other@example.com"], - ["Cc", "cc@example.com"], - ["Content-Type", "multipart/mixed; boundary=\"089e0825ccf874a0bb055b4f7e23\""], - ]), - 'body-plain': 'Test body plain', - 'body-html': '
Test body html
', - 'stripped-html': 'stripped html body', - 'stripped-text': 'stripped plaintext body', - }) - response = self.client.post('/anymail/mailgun/inbound/', data=raw_event) + raw_event = mailgun_sign_legacy_payload( + { + "token": "06c96bafc3f42a66b9edd546347a2fe18dc23461fe80dc52f0", + "timestamp": "1461261330", + "recipient": "test@inbound.example.com", + "sender": "envelope-from@example.org", + "message-headers": json.dumps( + [ + [ + "X-Mailgun-Spam-Rules", + "DKIM_SIGNED, DKIM_VALID, DKIM_VALID_AU, ...", + ], + ["X-Mailgun-Dkim-Check-Result", "Pass"], + ["X-Mailgun-Spf", "Pass"], + ["X-Mailgun-Sscore", "1.7"], + ["X-Mailgun-Sflag", "No"], + ["X-Mailgun-Incoming", "Yes"], + ["X-Envelope-From", ""], + ["Received", "from mail.example.org by mxa.mailgun.org ..."], + [ + "Received", + "by mail.example.org for ...", + ], + [ + "Dkim-Signature", + "v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.org; ...", + ], + ["Mime-Version", "1.0"], + [ + "Received", + "by 10.10.1.71 with HTTP;" + " Wed, 11 Oct 2017 18:31:04 -0700 (PDT)", + ], + ["From", '"Displayed From" '], + ["Date", "Wed, 11 Oct 2017 18:31:04 -0700"], + ["Message-Id", ""], + ["Subject", "Test subject"], + [ + "To", + '"Test Inbound" ,' + " other@example.com", + ], + ["Cc", "cc@example.com"], + [ + "Content-Type", + 'multipart/mixed; boundary="089e0825ccf874a0bb055b4f7e23"', + ], + ] + ), + "body-plain": "Test body plain", + "body-html": "
Test body html
", + "stripped-html": "stripped html body", + "stripped-text": "stripped plaintext body", + } + ) + response = self.client.post("/anymail/mailgun/inbound/", data=raw_event) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.inbound_handler, sender=MailgunInboundWebhookView, - event=ANY, esp_name='Mailgun') + kwargs = self.assert_handler_called_once_with( + self.inbound_handler, + sender=MailgunInboundWebhookView, + event=ANY, + esp_name="Mailgun", + ) # AnymailInboundEvent - event = kwargs['event'] + event = kwargs["event"] self.assertIsInstance(event, AnymailInboundEvent) - self.assertEqual(event.event_type, 'inbound') - self.assertEqual(event.timestamp, datetime(2016, 4, 21, 17, 55, 30, tzinfo=timezone.utc)) - self.assertEqual(event.event_id, "06c96bafc3f42a66b9edd546347a2fe18dc23461fe80dc52f0") + self.assertEqual(event.event_type, "inbound") + self.assertEqual( + event.timestamp, datetime(2016, 4, 21, 17, 55, 30, tzinfo=timezone.utc) + ) + self.assertEqual( + event.event_id, "06c96bafc3f42a66b9edd546347a2fe18dc23461fe80dc52f0" + ) self.assertIsInstance(event.message, AnymailInboundMessage) self.assertEqual(querydict_to_postdict(event.esp_event.POST), raw_event) # AnymailInboundMessage - convenience properties message = event.message - self.assertEqual(message.from_email.display_name, 'Displayed From') - self.assertEqual(message.from_email.addr_spec, 'from+test@example.org') - self.assertEqual([str(e) for e in message.to], - ['Test Inbound ', 'other@example.com']) - self.assertEqual([str(e) for e in message.cc], - ['cc@example.com']) - self.assertEqual(message.subject, 'Test subject') + self.assertEqual(message.from_email.display_name, "Displayed From") + self.assertEqual(message.from_email.addr_spec, "from+test@example.org") + self.assertEqual( + [str(e) for e in message.to], + ["Test Inbound ", "other@example.com"], + ) + self.assertEqual([str(e) for e in message.cc], ["cc@example.com"]) + self.assertEqual(message.subject, "Test subject") self.assertEqual(message.date.isoformat(" "), "2017-10-11 18:31:04-07:00") - self.assertEqual(message.text, 'Test body plain') - self.assertEqual(message.html, '
Test body html
') + self.assertEqual(message.text, "Test body plain") + self.assertEqual(message.html, "
Test body html
") - self.assertEqual(message.envelope_sender, 'envelope-from@example.org') - self.assertEqual(message.envelope_recipient, 'test@inbound.example.com') - self.assertEqual(message.stripped_text, 'stripped plaintext body') - self.assertEqual(message.stripped_html, 'stripped html body') + self.assertEqual(message.envelope_sender, "envelope-from@example.org") + self.assertEqual(message.envelope_recipient, "test@inbound.example.com") + self.assertEqual(message.stripped_text, "stripped plaintext body") + self.assertEqual(message.stripped_html, "stripped html body") self.assertIs(message.spam_detected, False) self.assertEqual(message.spam_score, 1.7) # AnymailInboundMessage - other headers - self.assertEqual(message['Message-ID'], "") - self.assertEqual(message.get_all('Received'), [ - "from mail.example.org by mxa.mailgun.org ...", - "by mail.example.org for ...", - "by 10.10.1.71 with HTTP; Wed, 11 Oct 2017 18:31:04 -0700 (PDT)", - ]) + self.assertEqual(message["Message-ID"], "") + self.assertEqual( + message.get_all("Received"), + [ + "from mail.example.org by mxa.mailgun.org ...", + "by mail.example.org for ...", + "by 10.10.1.71 with HTTP; Wed, 11 Oct 2017 18:31:04 -0700 (PDT)", + ], + ) def test_attachments(self): - att1 = BytesIO('test attachment'.encode('utf-8')) - att1.name = 'test.txt' + att1 = BytesIO("test attachment".encode("utf-8")) + att1.name = "test.txt" image_content = sample_image_content() att2 = BytesIO(image_content) - att2.name = 'image.png' + att2.name = "image.png" email_content = sample_email_content() att3 = BytesIO(email_content) - att3.name = '\\share\\mail\\forwarded.msg' + att3.name = "\\share\\mail\\forwarded.msg" att3.content_type = 'message/rfc822; charset="us-ascii"' - raw_event = mailgun_sign_legacy_payload({ - 'message-headers': '[]', - 'attachment-count': '3', - 'content-id-map': """{"": "attachment-2"}""", - 'attachment-1': att1, - 'attachment-2': att2, # inline - 'attachment-3': att3, - }) + raw_event = mailgun_sign_legacy_payload( + { + "message-headers": "[]", + "attachment-count": "3", + "content-id-map": """{"": "attachment-2"}""", + "attachment-1": att1, + "attachment-2": att2, # inline + "attachment-3": att3, + } + ) - response = self.client.post('/anymail/mailgun/inbound/', data=raw_event) + response = self.client.post("/anymail/mailgun/inbound/", data=raw_event) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.inbound_handler, sender=MailgunInboundWebhookView, - event=ANY, esp_name='Mailgun') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.inbound_handler, + sender=MailgunInboundWebhookView, + event=ANY, + esp_name="Mailgun", + ) + event = kwargs["event"] message = event.message attachments = message.attachments # AnymailInboundMessage convenience accessor self.assertEqual(len(attachments), 2) - self.assertEqual(attachments[0].get_filename(), 'test.txt') - self.assertEqual(attachments[0].get_content_type(), 'text/plain') - self.assertEqual(attachments[0].get_content_text(), 'test attachment') - self.assertEqual(attachments[1].get_filename(), 'forwarded.msg') # Django strips paths - self.assertEqual(attachments[1].get_content_type(), 'message/rfc822') - self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content) + self.assertEqual(attachments[0].get_filename(), "test.txt") + self.assertEqual(attachments[0].get_content_type(), "text/plain") + self.assertEqual(attachments[0].get_content_text(), "test attachment") + # Django strips paths: + self.assertEqual(attachments[1].get_filename(), "forwarded.msg") + self.assertEqual(attachments[1].get_content_type(), "message/rfc822") + self.assertEqualIgnoringHeaderFolding( + attachments[1].get_content_bytes(), email_content + ) inlines = message.inline_attachments self.assertEqual(len(inlines), 1) - inline = inlines['abc123'] - self.assertEqual(inline.get_filename(), 'image.png') - self.assertEqual(inline.get_content_type(), 'image/png') + inline = inlines["abc123"] + self.assertEqual(inline.get_filename(), "image.png") + self.assertEqual(inline.get_content_type(), "image/png") self.assertEqual(inline.get_content_bytes(), image_content) def test_filtered_attachment_filenames(self): @@ -141,54 +194,76 @@ class MailgunInboundTestCase(WebhookTestCase): # Django's multipart/form-data filename filtering. (The attachments are lost, # but shouldn't cause errors in the inbound webhook.) filenames = [ - "", "path\\", "path/" - ".", "path\\.", "path/.", - "..", "path\\..", "path/..", + "", + "path\\", + "path/" ".", + "path\\.", + "path/.", + "..", + "path\\..", + "path/..", ] num_attachments = len(filenames) payload = { - "attachment-%d" % (i+1): make_fileobj("content", filename=filenames[i], content_type="text/pdf") + "attachment-%d" + % (i + 1): make_fileobj( + "content", filename=filenames[i], content_type="text/pdf" + ) for i in range(num_attachments) } - payload.update({ - 'message-headers': '[]', - 'attachment-count': str(num_attachments), - }) + payload.update( + { + "message-headers": "[]", + "attachment-count": str(num_attachments), + } + ) # Must do our own multipart/form-data encoding for empty filenames: - response = self.client.post('/anymail/mailgun/inbound/', - data=encode_multipart("BoUnDaRy", mailgun_sign_legacy_payload(payload)), - content_type="multipart/form-data; boundary=BoUnDaRy") + response = self.client.post( + "/anymail/mailgun/inbound/", + data=encode_multipart("BoUnDaRy", mailgun_sign_legacy_payload(payload)), + content_type="multipart/form-data; boundary=BoUnDaRy", + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.inbound_handler, sender=MailgunInboundWebhookView, - event=ANY, esp_name='Mailgun') + kwargs = self.assert_handler_called_once_with( + self.inbound_handler, + sender=MailgunInboundWebhookView, + event=ANY, + esp_name="Mailgun", + ) # Different Django releases strip different filename patterns. # Just verify that at least some attachments got dropped (so the test is valid) # without causing an error in the inbound webhook: - attachments = kwargs['event'].message.attachments + attachments = kwargs["event"].message.attachments self.assertLess(len(attachments), num_attachments) def test_unusual_content_id_map(self): - # Under unknown conditions, Mailgun appears to generate a content-id-map with multiple - # empty keys (and possibly other duplicate keys). We still want to correctly identify - # inline attachments from it. - raw_event = mailgun_sign_legacy_payload({ - 'message-headers': '[]', - 'attachment-count': '4', - 'content-id-map': '{"": "attachment-1", "": "attachment-2",' - ' "": "attachment-3", "": "attachment-4"}', - 'attachment-1': make_fileobj("att1"), - 'attachment-2': make_fileobj("att2"), - 'attachment-3': make_fileobj("att3"), - 'attachment-4': make_fileobj("att4"), - }) + # Under unknown conditions, Mailgun appears to generate a content-id-map with + # multiple empty keys (and possibly other duplicate keys). We still want to + # correctly identify inline attachments from it. + raw_event = mailgun_sign_legacy_payload( + { + "message-headers": "[]", + "attachment-count": "4", + "content-id-map": '{"": "attachment-1", "": "attachment-2",' + ' "": "attachment-3", "": "attachment-4"}', + "attachment-1": make_fileobj("att1"), + "attachment-2": make_fileobj("att2"), + "attachment-3": make_fileobj("att3"), + "attachment-4": make_fileobj("att4"), + } + ) - response = self.client.post('/anymail/mailgun/inbound/', data=raw_event) + response = self.client.post("/anymail/mailgun/inbound/", data=raw_event) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.inbound_handler, sender=MailgunInboundWebhookView, - event=ANY, esp_name='Mailgun') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.inbound_handler, + sender=MailgunInboundWebhookView, + event=ANY, + esp_name="Mailgun", + ) + event = kwargs["event"] message = event.message self.assertEqual(len(message.attachments), 0) # all inlines inlines = [part for part in message.walk() if part.is_inline_attachment()] @@ -200,12 +275,14 @@ class MailgunInboundTestCase(WebhookTestCase): def test_inbound_mime(self): # Mailgun provides the full, raw MIME message if the webhook url ends in 'mime' - raw_event = mailgun_sign_legacy_payload({ - 'token': '06c96bafc3f42a66b9edd546347a2fe18dc23461fe80dc52f0', - 'timestamp': '1461261330', - 'recipient': 'test@inbound.example.com', - 'sender': 'envelope-from@example.org', - 'body-mime': dedent("""\ + raw_event = mailgun_sign_legacy_payload( + { + "token": "06c96bafc3f42a66b9edd546347a2fe18dc23461fe80dc52f0", + "timestamp": "1461261330", + "recipient": "test@inbound.example.com", + "sender": "envelope-from@example.org", + "body-mime": dedent( + """\ From: A tester Date: Thu, 12 Oct 2017 18:03:30 -0700 Message-ID: @@ -227,71 +304,95 @@ class MailgunInboundTestCase(WebhookTestCase):
It's a body=E2=80=A6
--94eb2c05e174adb140055b6339c5-- - """), - }) + """ # NOQA: E501 + ), + } + ) - response = self.client.post('/anymail/mailgun/inbound_mime/', data=raw_event) + response = self.client.post("/anymail/mailgun/inbound_mime/", data=raw_event) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.inbound_handler, sender=MailgunInboundWebhookView, - event=ANY, esp_name='Mailgun') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.inbound_handler, + sender=MailgunInboundWebhookView, + event=ANY, + esp_name="Mailgun", + ) + event = kwargs["event"] message = event.message - self.assertEqual(message.envelope_sender, 'envelope-from@example.org') - self.assertEqual(message.envelope_recipient, 'test@inbound.example.com') - self.assertEqual(message.subject, 'Raw MIME test') + self.assertEqual(message.envelope_sender, "envelope-from@example.org") + self.assertEqual(message.envelope_recipient, "test@inbound.example.com") + self.assertEqual(message.subject, "Raw MIME test") self.assertEqual(message.text, "It's a body\N{HORIZONTAL ELLIPSIS}\n") - self.assertEqual(message.html, """
It's a body\N{HORIZONTAL ELLIPSIS}
\n""") + self.assertEqual( + message.html, + """
It's a body\N{HORIZONTAL ELLIPSIS}
\n""", + ) def test_misconfigured_tracking(self): - raw_event = mailgun_sign_payload({ - "event-data": { - "event": "clicked", - "timestamp": 1534109600.089676, - "recipient": "recipient@example.com", - "url": "https://example.com/test" + raw_event = mailgun_sign_payload( + { + "event-data": { + "event": "clicked", + "timestamp": 1534109600.089676, + "recipient": "recipient@example.com", + "url": "https://example.com/test", + } } - }) + ) with self.assertRaisesMessage( AnymailConfigurationError, "You seem to have set Mailgun's *clicked tracking* webhook" - " to Anymail's Mailgun *inbound* webhook URL." + " to Anymail's Mailgun *inbound* webhook URL.", ): - self.client.post('/anymail/mailgun/inbound/', - data=json.dumps(raw_event), content_type='application/json') + self.client.post( + "/anymail/mailgun/inbound/", + data=json.dumps(raw_event), + content_type="application/json", + ) def test_misconfigured_store_action(self): - # store() notification includes "attachments" json; forward() includes "attachment-count" - raw_event = mailgun_sign_legacy_payload({ - 'token': '06c96bafc3f42a66b9edd546347a2fe18dc23461fe80dc52f0', - 'timestamp': '1461261330', - 'recipient': 'test@inbound.example.com', - 'sender': 'envelope-from@example.org', - 'body-plain': 'Test body plain', - 'body-html': '
Test body html
', - 'attachments': json.dumps([{ - "url": "https://storage.mailgun.net/v3/domains/example.com/messages/MESSAGE_KEY/attachments/0", - "content-type": "application/pdf", - "name": "attachment.pdf", - "size": 20202 - }]), - }) + # store() notification includes "attachments" json; + # forward() includes "attachment-count" + raw_event = mailgun_sign_legacy_payload( + { + "token": "06c96bafc3f42a66b9edd546347a2fe18dc23461fe80dc52f0", + "timestamp": "1461261330", + "recipient": "test@inbound.example.com", + "sender": "envelope-from@example.org", + "body-plain": "Test body plain", + "body-html": "
Test body html
", + "attachments": json.dumps( + [ + { + "url": "https://storage.mailgun.net/v3/domains/example.com" + "/messages/MESSAGE_KEY/attachments/0", + "content-type": "application/pdf", + "name": "attachment.pdf", + "size": 20202, + } + ] + ), + } + ) with self.assertRaisesMessage( AnymailConfigurationError, - "You seem to have configured Mailgun's receiving route using the store() action." - " Anymail's inbound webhook requires the forward() action." + "You seem to have configured Mailgun's receiving route using the store()" + " action. Anymail's inbound webhook requires the forward() action.", ): - self.client.post('/anymail/mailgun/inbound/', data=raw_event) + self.client.post("/anymail/mailgun/inbound/", data=raw_event) def test_misconfigured_tracking_legacy(self): - raw_event = mailgun_sign_legacy_payload({ - 'domain': 'example.com', - 'message-headers': '[]', - 'recipient': 'recipient@example.com', - 'event': 'delivered', - }) + raw_event = mailgun_sign_legacy_payload( + { + "domain": "example.com", + "message-headers": "[]", + "recipient": "recipient@example.com", + "event": "delivered", + } + ) with self.assertRaisesMessage( AnymailConfigurationError, "You seem to have set Mailgun's *delivered tracking* webhook" - " to Anymail's Mailgun *inbound* webhook URL." + " to Anymail's Mailgun *inbound* webhook URL.", ): - self.client.post('/anymail/mailgun/inbound/', data=raw_event) + self.client.post("/anymail/mailgun/inbound/", data=raw_event) diff --git a/tests/test_mailgun_integration.py b/tests/test_mailgun_integration.py index 811a43e..fdfbe02 100644 --- a/tests/test_mailgun_integration.py +++ b/tests/test_mailgun_integration.py @@ -10,21 +10,27 @@ from django.test import SimpleTestCase, override_settings, tag from anymail.exceptions import AnymailAPIError from anymail.message import AnymailMessage + from .utils import AnymailTestMixin, sample_image_path - -ANYMAIL_TEST_MAILGUN_API_KEY = os.getenv('ANYMAIL_TEST_MAILGUN_API_KEY') -ANYMAIL_TEST_MAILGUN_DOMAIN = os.getenv('ANYMAIL_TEST_MAILGUN_DOMAIN') +ANYMAIL_TEST_MAILGUN_API_KEY = os.getenv("ANYMAIL_TEST_MAILGUN_API_KEY") +ANYMAIL_TEST_MAILGUN_DOMAIN = os.getenv("ANYMAIL_TEST_MAILGUN_DOMAIN") -@tag('mailgun', 'live') -@unittest.skipUnless(ANYMAIL_TEST_MAILGUN_API_KEY and ANYMAIL_TEST_MAILGUN_DOMAIN, - "Set ANYMAIL_TEST_MAILGUN_API_KEY and ANYMAIL_TEST_MAILGUN_DOMAIN environment variables " - "to run Mailgun integration tests") -@override_settings(ANYMAIL={'MAILGUN_API_KEY': ANYMAIL_TEST_MAILGUN_API_KEY, - 'MAILGUN_SENDER_DOMAIN': ANYMAIL_TEST_MAILGUN_DOMAIN, - 'MAILGUN_SEND_DEFAULTS': {'esp_extra': {'o:testmode': 'yes'}}}, - EMAIL_BACKEND="anymail.backends.mailgun.EmailBackend") +@tag("mailgun", "live") +@unittest.skipUnless( + ANYMAIL_TEST_MAILGUN_API_KEY and ANYMAIL_TEST_MAILGUN_DOMAIN, + "Set ANYMAIL_TEST_MAILGUN_API_KEY and ANYMAIL_TEST_MAILGUN_DOMAIN environment" + " variables to run Mailgun integration tests", +) +@override_settings( + ANYMAIL={ + "MAILGUN_API_KEY": ANYMAIL_TEST_MAILGUN_API_KEY, + "MAILGUN_SENDER_DOMAIN": ANYMAIL_TEST_MAILGUN_DOMAIN, + "MAILGUN_SEND_DEFAULTS": {"esp_extra": {"o:testmode": "yes"}}, + }, + EMAIL_BACKEND="anymail.backends.mailgun.EmailBackend", +) class MailgunBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): """Mailgun API integration tests @@ -37,13 +43,18 @@ class MailgunBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): def setUp(self): super().setUp() - self.from_email = 'from@%s' % ANYMAIL_TEST_MAILGUN_DOMAIN - self.message = AnymailMessage('Anymail Mailgun integration test', 'Text content', - self.from_email, ['test+to1@anymail.dev']) - self.message.attach_alternative('

HTML content

', "text/html") + self.from_email = "from@%s" % ANYMAIL_TEST_MAILGUN_DOMAIN + self.message = AnymailMessage( + "Anymail Mailgun integration test", + "Text content", + self.from_email, + ["test+to1@anymail.dev"], + ) + self.message.attach_alternative("

HTML content

", "text/html") - def fetch_mailgun_events(self, message_id, event=None, - initial_delay=2, retry_delay=2, max_retries=5): + def fetch_mailgun_events( + self, message_id, event=None, initial_delay=2, retry_delay=2, max_retries=5 + ): """Return list of Mailgun events related to message_id""" url = "https://api.mailgun.net/v3/%s/events" % ANYMAIL_TEST_MAILGUN_DOMAIN auth = ("api", ANYMAIL_TEST_MAILGUN_API_KEY) @@ -51,9 +62,9 @@ class MailgunBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): # Despite the docs, Mailgun's events API actually expects the message-id # without the <...> brackets (so, not exactly "as returned by the messages API") # https://documentation.mailgun.com/api-events.html#filter-field - params = {'message-id': message_id[1:-1]} # strip <...> + params = {"message-id": message_id[1:-1]} # strip <...> if event is not None: - params['event'] = event + params["event"] = event # It can take a few seconds for the events to show up # in Mailgun's logs, so retry a few times if necessary: @@ -72,14 +83,17 @@ class MailgunBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): # server error (hopefully transient); try again after delay pass elif 403 == response.status_code: - # "forbidden": this may be related to API throttling; try again after delay + # "forbidden": this may be related to API throttling; + # try again after delay pass else: response.raise_for_status() # Max retries exceeded: if response is not None and 200 != response.status_code: - logging.warning("Ignoring Mailgun events API error %d:\n%s" - % (response.status_code, response.text)) + logging.warning( + "Ignoring Mailgun events API error %d:\n%s" + % (response.status_code, response.text) + ) return None def test_simple_send(self): @@ -88,13 +102,15 @@ class MailgunBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): self.assertEqual(sent_count, 1) anymail_status = self.message.anymail_status - sent_status = anymail_status.recipients['test+to1@anymail.dev'].status - message_id = anymail_status.recipients['test+to1@anymail.dev'].message_id + sent_status = anymail_status.recipients["test+to1@anymail.dev"].status + message_id = anymail_status.recipients["test+to1@anymail.dev"].message_id - self.assertEqual(sent_status, 'queued') # Mailgun always queues - self.assertGreater(len(message_id), 0) # don't know what it'll be, but it should exist + self.assertEqual(sent_status, "queued") # Mailgun always queues + # don't know what it'll be, but it should exist: + self.assertGreater(len(message_id), 0) - self.assertEqual(anymail_status.status, {sent_status}) # set of all recipient statuses + # set of all recipient statuses: + self.assertEqual(anymail_status.status, {sent_status}) self.assertEqual(anymail_status.message_id, message_id) def test_all_options(self): @@ -110,7 +126,6 @@ class MailgunBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): bcc=["test+bcc1@anymail.dev", "Blind Copy 2 "], reply_to=["reply1@example.com", "Reply 2 "], headers={"X-Anymail-Test": "value"}, - metadata={"meta1": "simple string", "meta2": 2}, send_at=send_at, tags=["tag 1", "tag 2"], @@ -119,35 +134,55 @@ class MailgunBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): ) message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain") message.attach("vedhæftet fil.csv", "ID,Name\n1,3", "text/csv") - cid = message.attach_inline_image_file(sample_image_path(), domain=ANYMAIL_TEST_MAILGUN_DOMAIN) + cid = message.attach_inline_image_file( + sample_image_path(), domain=ANYMAIL_TEST_MAILGUN_DOMAIN + ) message.attach_alternative( "
This is the html body
" % cid, - "text/html") + "text/html", + ) message.send() - self.assertEqual(message.anymail_status.status, {'queued'}) # Mailgun always queues + # Mailgun always queues: + self.assertEqual(message.anymail_status.status, {"queued"}) message_id = message.anymail_status.message_id events = self.fetch_mailgun_events(message_id, event="accepted") if events is None: - self.skipTest("No Mailgun 'accepted' event after 30sec -- can't complete this test") + self.skipTest( + "No Mailgun 'accepted' event after 30sec -- can't complete this test" + ) return event = events.pop() - self.assertCountEqual(event["tags"], ["tag 1", "tag 2"]) # don't care about order - self.assertEqual(event["user-variables"], - {"meta1": "simple string", "meta2": "2"}) # all metadata values become strings + # don't care about order: + self.assertCountEqual(event["tags"], ["tag 1", "tag 2"]) + # all metadata values become strings: + self.assertEqual( + event["user-variables"], {"meta1": "simple string", "meta2": "2"} + ) self.assertEqual(event["message"]["scheduled-for"], send_at_timestamp) - self.assertIn(event["recipient"], ['test+to1@anymail.dev', 'test+to2@anymail.dev', - 'test+cc1@anymail.dev', 'test+cc2@anymail.dev', - 'test+bcc1@anymail.dev', 'test+bcc2@anymail.dev']) + self.assertIn( + event["recipient"], + [ + "test+to1@anymail.dev", + "test+to2@anymail.dev", + "test+cc1@anymail.dev", + "test+cc2@anymail.dev", + "test+bcc1@anymail.dev", + "test+bcc2@anymail.dev", + ], + ) headers = event["message"]["headers"] self.assertEqual(headers["from"], from_email) - self.assertEqual(headers["to"], - "test+to1@anymail.dev, Recipient 2 ") - self.assertEqual(headers["subject"], "Anymail Mailgun all-options integration test") + self.assertEqual( + headers["to"], "test+to1@anymail.dev, Recipient 2 " + ) + self.assertEqual( + headers["subject"], "Anymail Mailgun all-options integration test" + ) attachments = event["message"]["attachments"] if len(attachments) == 3: @@ -168,24 +203,28 @@ class MailgunBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): def test_stored_template(self): message = AnymailMessage( - template_id='test-template', # name of a real template named in Anymail's Mailgun test account - subject='Your order %recipient.order%', # Mailgun templates don't define subject - from_email=formataddr(('Test From>', self.from_email)), # Mailgun templates don't define sender + # name of a real template named in Anymail's Mailgun test account: + template_id="test-template", + # Mailgun templates don't define subject: + subject="Your order %recipient.order%", + # Mailgun templates don't define sender: + from_email=formataddr(("Test From>", self.from_email)), to=["test+to1@anymail.dev"], - # metadata and merge_data must not have any conflicting keys when using template_id + # metadata and merge_data must not have any conflicting keys + # when using template_id: metadata={"meta1": "simple string", "meta2": 2}, merge_data={ - 'test+to1@anymail.dev': { - 'name': "Test Recipient", + "test+to1@anymail.dev": { + "name": "Test Recipient", } }, merge_global_data={ - 'order': '12345', + "order": "12345", }, ) message.send() recipient_status = message.anymail_status.recipients - self.assertEqual(recipient_status['test+to1@anymail.dev'].status, 'queued') + self.assertEqual(recipient_status["test+to1@anymail.dev"].status, "queued") # As of Anymail 0.10, this test is no longer possible, because # Anymail now raises AnymailInvalidAddress without even calling Mailgun @@ -197,9 +236,13 @@ class MailgunBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): # self.assertEqual(err.status_code, 400) # self.assertIn("'from' parameter is not a valid address", str(err)) - @override_settings(ANYMAIL={'MAILGUN_API_KEY': "Hey, that's not an API key", - 'MAILGUN_SENDER_DOMAIN': ANYMAIL_TEST_MAILGUN_DOMAIN, - 'MAILGUN_SEND_DEFAULTS': {'esp_extra': {'o:testmode': 'yes'}}}) + @override_settings( + ANYMAIL={ + "MAILGUN_API_KEY": "Hey, that's not an API key", + "MAILGUN_SENDER_DOMAIN": ANYMAIL_TEST_MAILGUN_DOMAIN, + "MAILGUN_SEND_DEFAULTS": {"esp_extra": {"o:testmode": "yes"}}, + } + ) def test_invalid_api_key(self): with self.assertRaises(AnymailAPIError) as cm: self.message.send() diff --git a/tests/test_mailgun_webhooks.py b/tests/test_mailgun_webhooks.py index cd076cc..946b0b5 100644 --- a/tests/test_mailgun_webhooks.py +++ b/tests/test_mailgun_webhooks.py @@ -1,9 +1,9 @@ +import hashlib +import hmac import json from datetime import datetime, timezone from unittest.mock import ANY -import hashlib -import hmac from django.core.exceptions import ImproperlyConfigured from django.test import override_settings, tag @@ -13,36 +13,44 @@ from anymail.webhooks.mailgun import MailgunTrackingWebhookView from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase -TEST_WEBHOOK_SIGNING_KEY = 'TEST_WEBHOOK_SIGNING_KEY' +TEST_WEBHOOK_SIGNING_KEY = "TEST_WEBHOOK_SIGNING_KEY" def mailgun_signature(timestamp, token, webhook_signing_key): """Generates a Mailgun webhook signature""" # https://documentation.mailgun.com/en/latest/user_manual.html#securing-webhooks return hmac.new( - key=webhook_signing_key.encode('ascii'), - msg='{timestamp}{token}'.format(timestamp=timestamp, token=token).encode('ascii'), - digestmod=hashlib.sha256).hexdigest() + key=webhook_signing_key.encode("ascii"), + msg="{timestamp}{token}".format(timestamp=timestamp, token=token).encode( + "ascii" + ), + digestmod=hashlib.sha256, + ).hexdigest() def mailgun_sign_payload(data, webhook_signing_key=TEST_WEBHOOK_SIGNING_KEY): """Add or complete Mailgun webhook signature block in data dict""" # Modifies the dict in place - event_data = data.get('event-data', {}) - signature = data.setdefault('signature', {}) - token = signature.setdefault('token', '1234567890abcdef1234567890abcdef') - timestamp = signature.setdefault('timestamp', - str(int(float(event_data.get('timestamp', '1234567890.123'))))) - signature['signature'] = mailgun_signature(timestamp, token, webhook_signing_key=webhook_signing_key) + event_data = data.get("event-data", {}) + signature = data.setdefault("signature", {}) + token = signature.setdefault("token", "1234567890abcdef1234567890abcdef") + timestamp = signature.setdefault( + "timestamp", str(int(float(event_data.get("timestamp", "1234567890.123")))) + ) + signature["signature"] = mailgun_signature( + timestamp, token, webhook_signing_key=webhook_signing_key + ) return data def mailgun_sign_legacy_payload(data, webhook_signing_key=TEST_WEBHOOK_SIGNING_KEY): """Add a Mailgun webhook signature to data dict""" # Modifies the dict in place - data.setdefault('timestamp', '1234567890') - data.setdefault('token', '1234567890abcdef1234567890abcdef') - data['signature'] = mailgun_signature(data['timestamp'], data['token'], webhook_signing_key=webhook_signing_key) + data.setdefault("timestamp", "1234567890") + data.setdefault("token", "1234567890abcdef1234567890abcdef") + data["signature"] = mailgun_signature( + data["timestamp"], data["token"], webhook_signing_key=webhook_signing_key + ) return data @@ -52,155 +60,218 @@ def querydict_to_postdict(qd): Single-value fields appear as normal Multi-value fields appear as a list (differs from QueryDict.dict) """ - return { - key: values if len(values) > 1 else values[0] - for key, values in qd.lists() - } + return {key: values if len(values) > 1 else values[0] for key, values in qd.lists()} -@tag('mailgun') +@tag("mailgun") class MailgunWebhookSettingsTestCase(WebhookTestCase): def test_requires_webhook_signing_key(self): - with self.assertRaisesMessage(ImproperlyConfigured, "MAILGUN_WEBHOOK_SIGNING_KEY"): - self.client.post('/anymail/mailgun/tracking/', content_type="application/json", - data=json.dumps(mailgun_sign_payload({'event-data': {'event': 'delivered'}}))) + with self.assertRaisesMessage( + ImproperlyConfigured, "MAILGUN_WEBHOOK_SIGNING_KEY" + ): + self.client.post( + "/anymail/mailgun/tracking/", + content_type="application/json", + data=json.dumps( + mailgun_sign_payload({"event-data": {"event": "delivered"}}) + ), + ) @override_settings( - ANYMAIL_MAILGUN_API_KEY='TEST_API_KEY', - ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY='TEST_WEBHOOK_SIGNING_KEY', + ANYMAIL_MAILGUN_API_KEY="TEST_API_KEY", + ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY="TEST_WEBHOOK_SIGNING_KEY", ) def test_webhook_signing_is_different_from_api_key(self): - """Webhooks should use MAILGUN_WEBHOOK_SIGNING_KEY, not MAILGUN_API_KEY, if both provided""" - payload = json.dumps(mailgun_sign_payload({'event-data': {'event': 'delivered'}}, - webhook_signing_key='TEST_WEBHOOK_SIGNING_KEY')) - response = self.client.post('/anymail/mailgun/tracking/', content_type="application/json", data=payload) + """ + Webhooks should use MAILGUN_WEBHOOK_SIGNING_KEY, + not MAILGUN_API_KEY, if both provided + """ + payload = json.dumps( + mailgun_sign_payload( + {"event-data": {"event": "delivered"}}, + webhook_signing_key="TEST_WEBHOOK_SIGNING_KEY", + ) + ) + response = self.client.post( + "/anymail/mailgun/tracking/", content_type="application/json", data=payload + ) self.assertEqual(response.status_code, 200) - @override_settings(ANYMAIL_MAILGUN_API_KEY='TEST_API_KEY') + @override_settings(ANYMAIL_MAILGUN_API_KEY="TEST_API_KEY") def test_defaults_webhook_signing_to_api_key(self): - """Webhooks should default to MAILGUN_API_KEY if MAILGUN_WEBHOOK_SIGNING_KEY not provided""" - payload = json.dumps(mailgun_sign_payload({'event-data': {'event': 'delivered'}}, - webhook_signing_key='TEST_API_KEY')) - response = self.client.post('/anymail/mailgun/tracking/', content_type="application/json", data=payload) + """ + Webhooks should default to MAILGUN_API_KEY + if MAILGUN_WEBHOOK_SIGNING_KEY not provided + """ + payload = json.dumps( + mailgun_sign_payload( + {"event-data": {"event": "delivered"}}, + webhook_signing_key="TEST_API_KEY", + ) + ) + response = self.client.post( + "/anymail/mailgun/tracking/", content_type="application/json", data=payload + ) self.assertEqual(response.status_code, 200) def test_webhook_signing_key_view_params(self): """Webhook signing key can be provided as a view param""" - view = MailgunTrackingWebhookView.as_view(webhook_signing_key='VIEW_SIGNING_KEY') + view = MailgunTrackingWebhookView.as_view( + webhook_signing_key="VIEW_SIGNING_KEY" + ) view_instance = view.view_class(**view.view_initkwargs) - self.assertEqual(view_instance.webhook_signing_key, b'VIEW_SIGNING_KEY') + self.assertEqual(view_instance.webhook_signing_key, b"VIEW_SIGNING_KEY") - # Can also use `api_key` param for backwards compatiblity with earlier Anymail versions - view = MailgunTrackingWebhookView.as_view(api_key='VIEW_API_KEY') + # Can also use `api_key` param for backwards compatibility + # with earlier Anymail versions + view = MailgunTrackingWebhookView.as_view(api_key="VIEW_API_KEY") view_instance = view.view_class(**view.view_initkwargs) - self.assertEqual(view_instance.webhook_signing_key, b'VIEW_API_KEY') + self.assertEqual(view_instance.webhook_signing_key, b"VIEW_API_KEY") -@tag('mailgun') +@tag("mailgun") @override_settings(ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY=TEST_WEBHOOK_SIGNING_KEY) class MailgunWebhookSecurityTestCase(WebhookBasicAuthTestCase): should_warn_if_no_auth = False # because we check webhook signature def call_webhook(self): - return self.client.post('/anymail/mailgun/tracking/', content_type="application/json", - data=json.dumps(mailgun_sign_payload({'event-data': {'event': 'delivered'}}))) + return self.client.post( + "/anymail/mailgun/tracking/", + content_type="application/json", + data=json.dumps( + mailgun_sign_payload({"event-data": {"event": "delivered"}}) + ), + ) # Additional tests are in WebhookBasicAuthTestCase def test_verifies_correct_signature(self): - response = self.client.post('/anymail/mailgun/tracking/', content_type="application/json", - data=json.dumps(mailgun_sign_payload({'event-data': {'event': 'delivered'}}))) + response = self.client.post( + "/anymail/mailgun/tracking/", + content_type="application/json", + data=json.dumps( + mailgun_sign_payload({"event-data": {"event": "delivered"}}) + ), + ) self.assertEqual(response.status_code, 200) def test_verifies_missing_signature(self): - response = self.client.post('/anymail/mailgun/tracking/', content_type="application/json", - data=json.dumps({'event-data': {'event': 'delivered'}})) + response = self.client.post( + "/anymail/mailgun/tracking/", + content_type="application/json", + data=json.dumps({"event-data": {"event": "delivered"}}), + ) self.assertEqual(response.status_code, 400) def test_verifies_bad_signature(self): - data = mailgun_sign_payload({'event-data': {'event': 'delivered'}}, - webhook_signing_key="wrong signing key") - response = self.client.post('/anymail/mailgun/tracking/', content_type="application/json", - data=json.dumps(data)) + data = mailgun_sign_payload( + {"event-data": {"event": "delivered"}}, + webhook_signing_key="wrong signing key", + ) + response = self.client.post( + "/anymail/mailgun/tracking/", + content_type="application/json", + data=json.dumps(data), + ) self.assertEqual(response.status_code, 400) -@tag('mailgun') +@tag("mailgun") @override_settings(ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY=TEST_WEBHOOK_SIGNING_KEY) class MailgunTestCase(WebhookTestCase): # Tests for Mailgun's new webhooks (announced 2018-06-29) def test_delivered_event(self): - # This is an actual, complete (sanitized) "delivered" event as received from Mailgun. - # (For brevity, later tests omit several payload fields that aren't used by Anymail.) - raw_event = mailgun_sign_payload({ - "signature": { - "timestamp": "1534108637", - "token": "651869375b9df3c98fc15c4889b102119add1235c38fc92824", - "signature": "...", - }, - "event-data": { - "tags": [], - "timestamp": 1534108637.153125, - "storage": { - "url": "https://sw.api.mailgun.net/v3/domains/example.org/messages/eyJwI...", - "key": "eyJwI...", + # This is an actual, complete (sanitized) "delivered" event as received from + # Mailgun. (For brevity, later tests omit several payload fields that aren't + # used by Anymail.) + raw_event = mailgun_sign_payload( + { + "signature": { + "timestamp": "1534108637", + "token": "651869375b9df3c98fc15c4889b102119add1235c38fc92824", + "signature": "...", }, - "recipient-domain": "example.com", - "id": "hTWCTD81RtiDN-...", - "campaigns": [], - "user-variables": {}, - "flags": { - "is-routed": False, - "is-authenticated": True, - "is-system-test": False, - "is-test-mode": False, - }, - "log-level": "info", - "envelope": { - "sending-ip": "333.123.123.200", - "sender": "test@example.org", - "transport": "smtp", - "targets": "recipient@example.com", - }, - "message": { - "headers": { - "to": "recipient@example.com", - "message-id": "20180812211713.1.DF5966851B4BAA99@example.org", - "from": "test@example.org", - "subject": "Testing", + "event-data": { + "tags": [], + "timestamp": 1534108637.153125, + "storage": { + "url": "https://sw.api.mailgun.net/v3/domains/" + "example.org/messages/eyJwI...", + "key": "eyJwI...", + }, + "recipient-domain": "example.com", + "id": "hTWCTD81RtiDN-...", + "campaigns": [], + "user-variables": {}, + "flags": { + "is-routed": False, + "is-authenticated": True, + "is-system-test": False, + "is-test-mode": False, + }, + "log-level": "info", + "envelope": { + "sending-ip": "333.123.123.200", + "sender": "test@example.org", + "transport": "smtp", + "targets": "recipient@example.com", + }, + "message": { + "headers": { + "to": "recipient@example.com", + "message-id": "20180812211713.1.DF5966851B4BAA99" + "@example.org", + "from": "test@example.org", + "subject": "Testing", + }, + "attachments": [], + "size": 809, + }, + "recipient": "recipient@example.com", + "event": "delivered", + "delivery-status": { + "tls": True, + "mx-host": "smtp-in.example.com", + "attempt-no": 1, + "description": "", + "session-seconds": 3.5700838565826416, + "utf8": True, + "code": 250, + "message": "OK", + "certificate-verified": True, }, - "attachments": [], - "size": 809, }, - "recipient": "recipient@example.com", - "event": "delivered", - "delivery-status": { - "tls": True, - "mx-host": "smtp-in.example.com", - "attempt-no": 1, - "description": "", - "session-seconds": 3.5700838565826416, - "utf8": True, - "code": 250, - "message": "OK", - "certificate-verified": True, - }, - }, - }) - response = self.client.post('/anymail/mailgun/tracking/', - data=json.dumps(raw_event), content_type='application/json') + } + ) + response = self.client.post( + "/anymail/mailgun/tracking/", + data=json.dumps(raw_event), + content_type="application/json", + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailgunTrackingWebhookView, - event=ANY, esp_name='Mailgun') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailgunTrackingWebhookView, + event=ANY, + esp_name="Mailgun", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "delivered") - self.assertEqual(event.timestamp, datetime(2018, 8, 12, 21, 17, 17, microsecond=153125, tzinfo=timezone.utc)) - self.assertEqual(event.message_id, "<20180812211713.1.DF5966851B4BAA99@example.org>") + self.assertEqual( + event.timestamp, + datetime(2018, 8, 12, 21, 17, 17, microsecond=153125, tzinfo=timezone.utc), + ) + self.assertEqual( + event.message_id, "<20180812211713.1.DF5966851B4BAA99@example.org>" + ) # Note that Anymail uses the "token" as its normalized event_id: - self.assertEqual(event.event_id, "651869375b9df3c98fc15c4889b102119add1235c38fc92824") - # ... if you want the Mailgun "event id", that's available through the raw esp_event: + self.assertEqual( + event.event_id, "651869375b9df3c98fc15c4889b102119add1235c38fc92824" + ) + # ... if you want the Mailgun "event id", + # that's available through the raw esp_event: self.assertEqual(event.esp_event["event-data"]["id"], "hTWCTD81RtiDN-...") self.assertEqual(event.recipient, "recipient@example.com") self.assertEqual(event.esp_event, raw_event) @@ -208,82 +279,106 @@ class MailgunTestCase(WebhookTestCase): self.assertEqual(event.metadata, {}) def test_failed_permanent_event(self): - raw_event = mailgun_sign_payload({ - "event-data": { - "event": "failed", - "severity": "permanent", - "reason": "bounce", - "recipient": "invalid@example.com", - "timestamp": 1534110422.389832, - "log-level": "error", - "message": { - "headers": { - "to": "invalid@example.com", - "message-id": "20180812214658.1.0DF563D0B3597700@example.org", - "from": "Test Sender ", + raw_event = mailgun_sign_payload( + { + "event-data": { + "event": "failed", + "severity": "permanent", + "reason": "bounce", + "recipient": "invalid@example.com", + "timestamp": 1534110422.389832, + "log-level": "error", + "message": { + "headers": { + "to": "invalid@example.com", + "message-id": "20180812214658.1.0DF563D0B3597700" + "@example.org", + "from": "Test Sender ", + }, + }, + "delivery-status": { + "tls": True, + "mx-host": "aspmx.l.example.org", + "attempt-no": 1, + "description": "", + "session-seconds": 2.952177047729492, + "utf8": True, + "code": 550, + "message": "5.1.1 The email account that you tried to reach" + " does not exist. Please try\n" + "5.1.1 double-checking the recipient's email address for typos", + "certificate-verified": True, }, }, - "delivery-status": { - "tls": True, - "mx-host": "aspmx.l.example.org", - "attempt-no": 1, - "description": "", - "session-seconds": 2.952177047729492, - "utf8": True, - "code": 550, - "message": "5.1.1 The email account that you tried to reach does not exist. Please try\n" - "5.1.1 double-checking the recipient's email address for typos", - "certificate-verified": True - } - }, - }) - response = self.client.post('/anymail/mailgun/tracking/', - data=json.dumps(raw_event), content_type='application/json') + } + ) + response = self.client.post( + "/anymail/mailgun/tracking/", + data=json.dumps(raw_event), + content_type="application/json", + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailgunTrackingWebhookView, - event=ANY, esp_name='Mailgun') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailgunTrackingWebhookView, + event=ANY, + esp_name="Mailgun", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "bounced") self.assertEqual(event.recipient, "invalid@example.com") self.assertEqual(event.reject_reason, "bounced") self.assertEqual(event.description, "") - self.assertEqual(event.mta_response, - "5.1.1 The email account that you tried to reach does not exist. Please try\n" - "5.1.1 double-checking the recipient's email address for typos") + self.assertEqual( + event.mta_response, + "5.1.1 The email account that you tried to reach does not exist." + " Please try\n" + "5.1.1 double-checking the recipient's email address for typos", + ) def test_failed_temporary_event(self): - raw_event = mailgun_sign_payload({ - "event-data": { - "event": "failed", - "severity": "temporary", - "reason": "generic", - "timestamp": 1534111899.659519, - "log-level": "warn", - "message": { - "headers": { - "to": "undeliverable@nomx.example.com", - "message-id": "20180812214638.1.4A7D468E9BC18C5D@example.org", - "from": "Test Sender ", - "subject": "Testing" + raw_event = mailgun_sign_payload( + { + "event-data": { + "event": "failed", + "severity": "temporary", + "reason": "generic", + "timestamp": 1534111899.659519, + "log-level": "warn", + "message": { + "headers": { + "to": "undeliverable@nomx.example.com", + "message-id": "20180812214638.1.4A7D468E9BC18C5D" + "@example.org", + "from": "Test Sender ", + "subject": "Testing", + }, + }, + "recipient": "undeliverable@nomx.example.com", + "delivery-status": { + "attempt-no": 3, + "description": "No MX for nomx.example.com", + "session-seconds": 0.0, + "retry-seconds": 1800, + "code": 498, + "message": "No MX for nomx.example.com", }, }, - "recipient": "undeliverable@nomx.example.com", - "delivery-status": { - "attempt-no": 3, - "description": "No MX for nomx.example.com", - "session-seconds": 0.0, - "retry-seconds": 1800, - "code": 498, - "message": "No MX for nomx.example.com" - } - }, - }) - response = self.client.post('/anymail/mailgun/tracking/', - data=json.dumps(raw_event), content_type='application/json') + } + ) + response = self.client.post( + "/anymail/mailgun/tracking/", + data=json.dumps(raw_event), + content_type="application/json", + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailgunTrackingWebhookView, - event=ANY, esp_name='Mailgun') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailgunTrackingWebhookView, + event=ANY, + esp_name="Mailgun", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "deferred") self.assertEqual(event.recipient, "undeliverable@nomx.example.com") self.assertEqual(event.reject_reason, "other") @@ -291,39 +386,49 @@ class MailgunTestCase(WebhookTestCase): self.assertEqual(event.mta_response, "No MX for nomx.example.com") def test_failed_greylisted_event(self): - raw_event = mailgun_sign_payload({ - "event-data": { - "event": "failed", - "severity": "temporary", - "reason": "greylisted", - "timestamp": 1534111899.659519, - "log-level": "warn", - "message": { - "headers": { - "to": "undeliverable@nomx.example.com", - "message-id": "20180812214638.1.4A7D468E9BC18C5D@example.org", - "from": "Test Sender ", - "subject": "Testing" + raw_event = mailgun_sign_payload( + { + "event-data": { + "event": "failed", + "severity": "temporary", + "reason": "greylisted", + "timestamp": 1534111899.659519, + "log-level": "warn", + "message": { + "headers": { + "to": "undeliverable@nomx.example.com", + "message-id": "20180812214638.1.4A7D468E9BC18C5D" + "@example.org", + "from": "Test Sender ", + "subject": "Testing", + }, + }, + "recipient": "undeliverable@mx.example.com", + "delivery-status": { + "mx-host": "mx.example.com", + "attempt-no": 1, + "description": "Recipient address rejected: Greylisted", + "session-seconds": 0.0, + "retry-seconds": 300, + "code": 450, + "message": "Recipient address rejected: Greylisted", }, }, - "recipient": "undeliverable@mx.example.com", - "delivery-status": { - "mx-host": "mx.example.com", - "attempt-no": 1, - "description": "Recipient address rejected: Greylisted", - "session-seconds": 0.0, - "retry-seconds": 300, - "code": 450, - "message": "Recipient address rejected: Greylisted" - } - }, - }) - response = self.client.post('/anymail/mailgun/tracking/', - data=json.dumps(raw_event), content_type='application/json') + } + ) + response = self.client.post( + "/anymail/mailgun/tracking/", + data=json.dumps(raw_event), + content_type="application/json", + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailgunTrackingWebhookView, - event=ANY, esp_name='Mailgun') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailgunTrackingWebhookView, + event=ANY, + esp_name="Mailgun", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "deferred") self.assertEqual(event.recipient, "undeliverable@mx.example.com") self.assertEqual(event.reject_reason, "other") @@ -334,301 +439,448 @@ class MailgunTestCase(WebhookTestCase): # (The "rejected" event is documented and appears in Mailgun dashboard logs, # but it doesn't appear to be delivered through webhooks as of 8/2018.) # Note that this payload lacks the recipient field present in all other events. - raw_event = mailgun_sign_payload({ - "event-data": { - "event": "rejected", - "timestamp": 1529704976.104692, - "log-level": "warn", - "reject": { - "reason": "Sandbox subdomains are for test purposes only.", - "description": "", - }, - "message": { - "headers": { - "to": "Recipient Name ", - "message-id": "20180622220256.1.B31A451A2E5422BB@sandbox55887.mailgun.org", - "from": "test@sandbox55887.mailgun.org", - "subject": "Test Subject" + raw_event = mailgun_sign_payload( + { + "event-data": { + "event": "rejected", + "timestamp": 1529704976.104692, + "log-level": "warn", + "reject": { + "reason": "Sandbox subdomains are for test purposes only.", + "description": "", + }, + "message": { + "headers": { + "to": "Recipient Name ", + "message-id": "20180622220256.1.B31A451A2E5422BB" + "@sandbox55887.mailgun.org", + "from": "test@sandbox55887.mailgun.org", + "subject": "Test Subject", + }, }, }, - }, - }) - response = self.client.post('/anymail/mailgun/tracking/', - data=json.dumps(raw_event), content_type='application/json') + } + ) + response = self.client.post( + "/anymail/mailgun/tracking/", + data=json.dumps(raw_event), + content_type="application/json", + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailgunTrackingWebhookView, - event=ANY, esp_name='Mailgun') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailgunTrackingWebhookView, + event=ANY, + esp_name="Mailgun", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "rejected") self.assertEqual(event.reject_reason, "other") - self.assertEqual(event.description, "Sandbox subdomains are for test purposes only.") + self.assertEqual( + event.description, "Sandbox subdomains are for test purposes only." + ) self.assertEqual(event.recipient, "recipient@example.org") def test_complained_event(self): - raw_event = mailgun_sign_payload({ - "event-data": { - "event": "complained", - "id": "ncV2XwymRUKbPek_MIM-Gw", - "timestamp": 1377214260.049634, - "log-level": "warn", - "recipient": "recipient@example.com", - "message": { - "headers": { - "to": "foo@recipient.com", - "message-id": "20130718032413.263EE2E0926@example.org", - "from": "Sender Name ", - "subject": "We are not spammer", + raw_event = mailgun_sign_payload( + { + "event-data": { + "event": "complained", + "id": "ncV2XwymRUKbPek_MIM-Gw", + "timestamp": 1377214260.049634, + "log-level": "warn", + "recipient": "recipient@example.com", + "message": { + "headers": { + "to": "foo@recipient.com", + "message-id": "20130718032413.263EE2E0926@example.org", + "from": "Sender Name ", + "subject": "We are not spammer", + }, }, }, - }, - }) - response = self.client.post('/anymail/mailgun/tracking/', - data=json.dumps(raw_event), content_type='application/json') + } + ) + response = self.client.post( + "/anymail/mailgun/tracking/", + data=json.dumps(raw_event), + content_type="application/json", + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailgunTrackingWebhookView, - event=ANY, esp_name='Mailgun') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailgunTrackingWebhookView, + event=ANY, + esp_name="Mailgun", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "complained") self.assertEqual(event.recipient, "recipient@example.com") def test_unsubscribed_event(self): - raw_event = mailgun_sign_payload({ - "event-data": { - "event": "unsubscribed", - "id": "W3X4JOhFT-OZidZGKKr9iA", - "timestamp": 1377213791.421473, - "log-level": "info", - "recipient": "recipient@example.com", - "message": { - "headers": { - "message-id": "20130822232216.13966.79700@samples.mailgun.org" - } + raw_event = mailgun_sign_payload( + { + "event-data": { + "event": "unsubscribed", + "id": "W3X4JOhFT-OZidZGKKr9iA", + "timestamp": 1377213791.421473, + "log-level": "info", + "recipient": "recipient@example.com", + "message": { + "headers": { + "message-id": "20130822232216.13966.79700" + "@samples.mailgun.org" + } + }, }, - }, - }) - response = self.client.post('/anymail/mailgun/tracking/', - data=json.dumps(raw_event), content_type='application/json') + } + ) + response = self.client.post( + "/anymail/mailgun/tracking/", + data=json.dumps(raw_event), + content_type="application/json", + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailgunTrackingWebhookView, - event=ANY, esp_name='Mailgun') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailgunTrackingWebhookView, + event=ANY, + esp_name="Mailgun", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "unsubscribed") self.assertEqual(event.recipient, "recipient@example.com") def test_opened_event(self): - raw_event = mailgun_sign_payload({ - "event-data": { - "event": "opened", - "timestamp": 1534109600.089676, - "recipient": "recipient@example.com", - "tags": ["welcome", "variation-A"], - "user-variables": { - "cohort": "2018-08-B", - "user_id": "123456" - }, - "message": { - # Mailgun *only* includes the message-id header for opened, clicked events... - "headers": { - "message-id": "20180812213139.1.BC6694A917BB7E6A@example.org" - } - }, - "geolocation": { - "country": "US", - "region": "CA", - "city": "San Francisco" - }, - "ip": "888.222.444.111", - "client-info": { - "client-type": "browser", - "client-os": "OS X", - "device-type": "desktop", - "client-name": "Chrome", - "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6)..." - }, + raw_event = mailgun_sign_payload( + { + "event-data": { + "event": "opened", + "timestamp": 1534109600.089676, + "recipient": "recipient@example.com", + "tags": ["welcome", "variation-A"], + "user-variables": {"cohort": "2018-08-B", "user_id": "123456"}, + "message": { + # Mailgun *only* includes the message-id header + # for opened, clicked events... + "headers": { + "message-id": "20180812213139.1" + ".BC6694A917BB7E6A@example.org" + } + }, + "geolocation": { + "country": "US", + "region": "CA", + "city": "San Francisco", + }, + "ip": "888.222.444.111", + "client-info": { + "client-type": "browser", + "client-os": "OS X", + "device-type": "desktop", + "client-name": "Chrome", + "user-agent": "Mozilla/5.0" + " (Macintosh; Intel Mac OS X 10_13_6)...", + }, + } } - }) - response = self.client.post('/anymail/mailgun/tracking/', - data=json.dumps(raw_event), content_type='application/json') + ) + response = self.client.post( + "/anymail/mailgun/tracking/", + data=json.dumps(raw_event), + content_type="application/json", + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailgunTrackingWebhookView, - event=ANY, esp_name='Mailgun') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailgunTrackingWebhookView, + event=ANY, + esp_name="Mailgun", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "opened") self.assertEqual(event.recipient, "recipient@example.com") self.assertEqual(event.tags, ["welcome", "variation-A"]) self.assertEqual(event.metadata, {"cohort": "2018-08-B", "user_id": "123456"}) def test_clicked_event(self): - raw_event = mailgun_sign_payload({ - "event-data": { - "event": "clicked", - "timestamp": 1534109600.089676, - "recipient": "recipient@example.com", - "url": "https://example.com/test" + raw_event = mailgun_sign_payload( + { + "event-data": { + "event": "clicked", + "timestamp": 1534109600.089676, + "recipient": "recipient@example.com", + "url": "https://example.com/test", + } } - }) - response = self.client.post('/anymail/mailgun/tracking/', - data=json.dumps(raw_event), content_type='application/json') + ) + response = self.client.post( + "/anymail/mailgun/tracking/", + data=json.dumps(raw_event), + content_type="application/json", + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailgunTrackingWebhookView, - event=ANY, esp_name='Mailgun') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailgunTrackingWebhookView, + event=ANY, + esp_name="Mailgun", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "clicked") self.assertEqual(event.click_url, "https://example.com/test") -@tag('mailgun') +@tag("mailgun") @override_settings(ANYMAIL_MAILGUN_WEBHOOK_SIGNING_KEY=TEST_WEBHOOK_SIGNING_KEY) class MailgunLegacyTestCase(WebhookTestCase): # Tests for Mailgun's "legacy" webhooks # (which were the only webhooks available prior to Anymail 4.0) def test_delivered_event(self): - raw_event = mailgun_sign_legacy_payload({ - 'domain': 'example.com', - 'message-headers': json.dumps([ - ["Sender", "from=example.com"], - ["Date", "Thu, 21 Apr 2016 17:55:29 +0000"], - ["X-Mailgun-Sid", "WyIxZmY4ZSIsICJtZWRtdW5kc0BnbWFpbC5jb20iLCAiZjFjNzgyIl0="], - ["Received", "by luna.mailgun.net with HTTP; Thu, 21 Apr 2016 17:55:29 +0000"], - ["Message-Id", "<20160421175529.19495.89030.B3AE3728@example.com>"], - ["To", "recipient@example.com"], - ["From", "from@example.com"], - ["Subject", "Webhook testing"], - ["Mime-Version", "1.0"], - ["Content-Type", ["multipart/alternative", {"boundary": "74fb561763da440d8e6a034054974251"}]] - ]), - 'X-Mailgun-Sid': 'WyIxZmY4ZSIsICJtZWRtdW5kc0BnbWFpbC5jb20iLCAiZjFjNzgyIl0=', - 'token': '06c96bafc3f42a66b9edd546347a2fe18dc23461fe80dc52f0', - 'timestamp': '1461261330', - 'Message-Id': '<20160421175529.19495.89030.B3AE3728@example.com>', - 'recipient': 'recipient@example.com', - 'event': 'delivered', - }) - response = self.client.post('/anymail/mailgun/tracking/', data=raw_event) + raw_event = mailgun_sign_legacy_payload( + { + "domain": "example.com", + "message-headers": json.dumps( + [ + ["Sender", "from=example.com"], + ["Date", "Thu, 21 Apr 2016 17:55:29 +0000"], + [ + "X-Mailgun-Sid", + "WyIxZmY4ZSIsICJtZWRtdW5kc0BnbWFpbC5jb20iLCAiZjFjNzgyIl0=", + ], + [ + "Received", + "by luna.mailgun.net with HTTP;" + " Thu, 21 Apr 2016 17:55:29 +0000", + ], + [ + "Message-Id", + "<20160421175529.19495.89030.B3AE3728@example.com>", + ], + ["To", "recipient@example.com"], + ["From", "from@example.com"], + ["Subject", "Webhook testing"], + ["Mime-Version", "1.0"], + [ + "Content-Type", + [ + "multipart/alternative", + {"boundary": "74fb561763da440d8e6a034054974251"}, + ], + ], + ] + ), + "X-Mailgun-Sid": "WyIxZmY4ZSIsICJtZWRtdW5kc" + "0BnbWFpbC5jb20iLCAiZjFjNzgyIl0=", + "token": "06c96bafc3f42a66b9edd546347a2fe18dc23461fe80dc52f0", + "timestamp": "1461261330", + "Message-Id": "<20160421175529.19495.89030.B3AE3728@example.com>", + "recipient": "recipient@example.com", + "event": "delivered", + } + ) + response = self.client.post("/anymail/mailgun/tracking/", data=raw_event) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailgunTrackingWebhookView, - event=ANY, esp_name='Mailgun') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailgunTrackingWebhookView, + event=ANY, + esp_name="Mailgun", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "delivered") - self.assertEqual(event.timestamp, datetime(2016, 4, 21, 17, 55, 30, tzinfo=timezone.utc)) - self.assertEqual(event.message_id, "<20160421175529.19495.89030.B3AE3728@example.com>") - self.assertEqual(event.event_id, "06c96bafc3f42a66b9edd546347a2fe18dc23461fe80dc52f0") + self.assertEqual( + event.timestamp, datetime(2016, 4, 21, 17, 55, 30, tzinfo=timezone.utc) + ) + self.assertEqual( + event.message_id, "<20160421175529.19495.89030.B3AE3728@example.com>" + ) + self.assertEqual( + event.event_id, "06c96bafc3f42a66b9edd546347a2fe18dc23461fe80dc52f0" + ) self.assertEqual(event.recipient, "recipient@example.com") self.assertEqual(querydict_to_postdict(event.esp_event), raw_event) self.assertEqual(event.tags, []) self.assertEqual(event.metadata, {}) def test_dropped_bounce(self): - raw_event = mailgun_sign_legacy_payload({ - 'code': '605', - 'domain': 'example.com', - 'description': 'Not delivering to previously bounced address', - 'attachment-count': '1', - 'Message-Id': '<20160421180324.70521.79375.96884DDB@example.com>', - 'reason': 'hardfail', - 'event': 'dropped', - 'message-headers': json.dumps([ - ["X-Mailgun-Sid", "WyI3Y2VjMyIsICJib3VuY2VAZXhhbXBsZS5jb20iLCAiZjFjNzgyIl0="], - ["Received", "by luna.mailgun.net with HTTP; Thu, 21 Apr 2016 18:03:24 +0000"], - ["Message-Id", "<20160421180324.70521.79375.96884DDB@example.com>"], - ["To", "bounce@example.com"], - ["From", "from@example.com"], - ["Subject", "Webhook testing"], - ["Mime-Version", "1.0"], - ["Content-Type", ["multipart/alternative", {"boundary": "a5b51388a4e3455d8feb8510bb8c9fa2"}]] - ]), - 'recipient': 'bounce@example.com', - 'timestamp': '1461261330', - 'X-Mailgun-Sid': 'WyI3Y2VjMyIsICJib3VuY2VAZXhhbXBsZS5jb20iLCAiZjFjNzgyIl0=', - 'token': 'a3fe1fa1640349ac552b84ddde373014b4c41645830c8dd3fc', - }) - response = self.client.post('/anymail/mailgun/tracking/', data=raw_event) + raw_event = mailgun_sign_legacy_payload( + { + "code": "605", + "domain": "example.com", + "description": "Not delivering to previously bounced address", + "attachment-count": "1", + "Message-Id": "<20160421180324.70521.79375.96884DDB@example.com>", + "reason": "hardfail", + "event": "dropped", + "message-headers": json.dumps( + [ + [ + "X-Mailgun-Sid", + "WyI3Y2VjMyIsICJib3VuY2VAZXhhbXBsZS5jb20iLCAiZjFjNzgyIl0=", + ], + [ + "Received", + "by luna.mailgun.net with HTTP;" + " Thu, 21 Apr 2016 18:03:24 +0000", + ], + [ + "Message-Id", + "<20160421180324.70521.79375.96884DDB@example.com>", + ], + ["To", "bounce@example.com"], + ["From", "from@example.com"], + ["Subject", "Webhook testing"], + ["Mime-Version", "1.0"], + [ + "Content-Type", + [ + "multipart/alternative", + {"boundary": "a5b51388a4e3455d8feb8510bb8c9fa2"}, + ], + ], + ] + ), + "recipient": "bounce@example.com", + "timestamp": "1461261330", + "X-Mailgun-Sid": "WyI3Y2VjMyIsICJib3VuY2VAZ" + "XhhbXBsZS5jb20iLCAiZjFjNzgyIl0=", + "token": "a3fe1fa1640349ac552b84ddde373014b4c41645830c8dd3fc", + } + ) + response = self.client.post("/anymail/mailgun/tracking/", data=raw_event) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailgunTrackingWebhookView, - event=ANY, esp_name='Mailgun') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailgunTrackingWebhookView, + event=ANY, + esp_name="Mailgun", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "rejected") - self.assertEqual(event.timestamp, datetime(2016, 4, 21, 17, 55, 30, tzinfo=timezone.utc)) - self.assertEqual(event.message_id, "<20160421180324.70521.79375.96884DDB@example.com>") - self.assertEqual(event.event_id, "a3fe1fa1640349ac552b84ddde373014b4c41645830c8dd3fc") + self.assertEqual( + event.timestamp, datetime(2016, 4, 21, 17, 55, 30, tzinfo=timezone.utc) + ) + self.assertEqual( + event.message_id, "<20160421180324.70521.79375.96884DDB@example.com>" + ) + self.assertEqual( + event.event_id, "a3fe1fa1640349ac552b84ddde373014b4c41645830c8dd3fc" + ) self.assertEqual(event.recipient, "bounce@example.com") self.assertEqual(event.reject_reason, "bounced") - self.assertEqual(event.description, 'Not delivering to previously bounced address') + self.assertEqual( + event.description, "Not delivering to previously bounced address" + ) self.assertEqual(querydict_to_postdict(event.esp_event), raw_event) def test_dropped_spam(self): - raw_event = mailgun_sign_legacy_payload({ - 'code': '607', - 'description': 'Not delivering to a user who marked your messages as spam', - 'reason': 'hardfail', - 'event': 'dropped', - 'recipient': 'complaint@example.com', - # (omitting some fields that aren't relevant to the test) - }) - response = self.client.post('/anymail/mailgun/tracking/', data=raw_event) + raw_event = mailgun_sign_legacy_payload( + { + "code": "607", + "description": "Not delivering to a user who marked" + " your messages as spam", + "reason": "hardfail", + "event": "dropped", + "recipient": "complaint@example.com", + # (omitting some fields that aren't relevant to the test) + } + ) + response = self.client.post("/anymail/mailgun/tracking/", data=raw_event) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailgunTrackingWebhookView, - event=ANY, esp_name='Mailgun') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailgunTrackingWebhookView, + event=ANY, + esp_name="Mailgun", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "rejected") self.assertEqual(event.reject_reason, "spam") - self.assertEqual(event.description, 'Not delivering to a user who marked your messages as spam') + self.assertEqual( + event.description, + "Not delivering to a user who marked your messages as spam", + ) def test_dropped_timed_out(self): - raw_event = mailgun_sign_legacy_payload({ - 'code': '499', - 'description': 'Unable to connect to MX servers: [example.com]', - 'reason': 'old', - 'event': 'dropped', - 'recipient': 'complaint@example.com', - # (omitting some fields that aren't relevant to the test) - }) - response = self.client.post('/anymail/mailgun/tracking/', data=raw_event) + raw_event = mailgun_sign_legacy_payload( + { + "code": "499", + "description": "Unable to connect to MX servers: [example.com]", + "reason": "old", + "event": "dropped", + "recipient": "complaint@example.com", + # (omitting some fields that aren't relevant to the test) + } + ) + response = self.client.post("/anymail/mailgun/tracking/", data=raw_event) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailgunTrackingWebhookView, - event=ANY, esp_name='Mailgun') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailgunTrackingWebhookView, + event=ANY, + esp_name="Mailgun", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "rejected") self.assertEqual(event.reject_reason, "timed_out") - self.assertEqual(event.description, 'Unable to connect to MX servers: [example.com]') + self.assertEqual( + event.description, "Unable to connect to MX servers: [example.com]" + ) def test_invalid_mailbox(self): - raw_event = mailgun_sign_legacy_payload({ - 'code': '550', - 'error': "550 5.1.1 The email account that you tried to reach does not exist. Please try " - " 5.1.1 double-checking the recipient's email address for typos or " - " 5.1.1 unnecessary spaces.", - 'event': 'bounced', - 'recipient': 'noreply@example.com', - # (omitting some fields that aren't relevant to the test) - }) - response = self.client.post('/anymail/mailgun/tracking/', data=raw_event) + raw_event = mailgun_sign_legacy_payload( + { + "code": "550", + "error": "550 5.1.1 The email account that you tried to reach does" + " not exist. Please try " + " 5.1.1 double-checking the recipient's email address for typos or " + " 5.1.1 unnecessary spaces.", + "event": "bounced", + "recipient": "noreply@example.com", + # (omitting some fields that aren't relevant to the test) + } + ) + response = self.client.post("/anymail/mailgun/tracking/", data=raw_event) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailgunTrackingWebhookView, - event=ANY, esp_name='Mailgun') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailgunTrackingWebhookView, + event=ANY, + esp_name="Mailgun", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "bounced") self.assertEqual(event.reject_reason, "bounced") - self.assertIn("The email account that you tried to reach does not exist", event.mta_response) + self.assertIn( + "The email account that you tried to reach does not exist", + event.mta_response, + ) def test_alt_smtp_code(self): - # In some cases, Mailgun uses RFC-3463 extended SMTP status codes (x.y.z, rather than nnn). - # See issue #62. - raw_event = mailgun_sign_legacy_payload({ - 'code': '5.1.1', - 'error': 'smtp;550 5.1.1 RESOLVER.ADR.RecipNotFound; not found', - 'event': 'bounced', - 'recipient': 'noreply@example.com', - # (omitting some fields that aren't relevant to the test) - }) - response = self.client.post('/anymail/mailgun/tracking/', data=raw_event) + # In some cases, Mailgun uses RFC-3463 extended SMTP status codes + # (x.y.z, rather than nnn). See issue #62. + raw_event = mailgun_sign_legacy_payload( + { + "code": "5.1.1", + "error": "smtp;550 5.1.1 RESOLVER.ADR.RecipNotFound; not found", + "event": "bounced", + "recipient": "noreply@example.com", + # (omitting some fields that aren't relevant to the test) + } + ) + response = self.client.post("/anymail/mailgun/tracking/", data=raw_event) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailgunTrackingWebhookView, - event=ANY, esp_name='Mailgun') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailgunTrackingWebhookView, + event=ANY, + esp_name="Mailgun", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "bounced") self.assertEqual(event.reject_reason, "bounced") self.assertIn("RecipNotFound", event.mta_response) @@ -636,31 +888,47 @@ class MailgunLegacyTestCase(WebhookTestCase): def test_metadata_message_headers(self): # Metadata fields are interspersed with other data, but also in message-headers # for delivered, bounced and dropped events - raw_event = mailgun_sign_legacy_payload({ - 'event': 'delivered', - 'message-headers': json.dumps([ - ["X-Mailgun-Variables", "{\"custom1\": \"value1\", \"custom2\": \"{\\\"key\\\":\\\"value\\\"}\"}"], - ]), - 'custom1': 'value1', - 'custom2': '{"key":"value"}', # you can store JSON, but you'll need to unpack it yourself - }) - self.client.post('/anymail/mailgun/tracking/', data=raw_event) + raw_event = mailgun_sign_legacy_payload( + { + "event": "delivered", + "message-headers": json.dumps( + [ + [ + "X-Mailgun-Variables", + '{"custom1": "value1",' + ' "custom2": "{\\"key\\":\\"value\\"}"}', + ], + ] + ), + "custom1": "value1", + # you can store JSON, but you'll need to unpack it yourself: + "custom2": '{"key":"value"}', + } + ) + self.client.post("/anymail/mailgun/tracking/", data=raw_event) kwargs = self.assert_handler_called_once_with(self.tracking_handler) - event = kwargs['event'] - self.assertEqual(event.metadata, {"custom1": "value1", "custom2": '{"key":"value"}'}) + event = kwargs["event"] + self.assertEqual( + event.metadata, {"custom1": "value1", "custom2": '{"key":"value"}'} + ) def test_metadata_post_fields(self): # Metadata fields are only interspersed with other event params # for opened, clicked, unsubscribed events - raw_event = mailgun_sign_legacy_payload({ - 'event': 'clicked', - 'custom1': 'value1', - 'custom2': '{"key":"value"}', # you can store JSON, but you'll need to unpack it yourself - }) - self.client.post('/anymail/mailgun/tracking/', data=raw_event) + raw_event = mailgun_sign_legacy_payload( + { + "event": "clicked", + "custom1": "value1", + # you can store JSON, but you'll need to unpack it yourself: + "custom2": '{"key":"value"}', + } + ) + self.client.post("/anymail/mailgun/tracking/", data=raw_event) kwargs = self.assert_handler_called_once_with(self.tracking_handler) - event = kwargs['event'] - self.assertEqual(event.metadata, {"custom1": "value1", "custom2": '{"key":"value"}'}) + event = kwargs["event"] + self.assertEqual( + event.metadata, {"custom1": "value1", "custom2": '{"key":"value"}'} + ) def test_metadata_key_conflicts(self): # If you happen to name metadata (user-variable) keys the same as Mailgun @@ -675,23 +943,25 @@ class MailgunLegacyTestCase(WebhookTestCase): "ordinary field": "ordinary metadata value", } - raw_event = mailgun_sign_legacy_payload({ - 'event': 'clicked', - 'recipient': 'actual-recipient@example.com', - 'token': 'actual-event-token', - 'timestamp': '1461261330', - 'url': 'http://clicked.example.com/actual/event/param', - 'h': "an (undocumented) Mailgun event param", - 'tag': ["actual-tag-1", "actual-tag-2"], - }) + raw_event = mailgun_sign_legacy_payload( + { + "event": "clicked", + "recipient": "actual-recipient@example.com", + "token": "actual-event-token", + "timestamp": "1461261330", + "url": "http://clicked.example.com/actual/event/param", + "h": "an (undocumented) Mailgun event param", + "tag": ["actual-tag-1", "actual-tag-2"], + } + ) # Simulate how Mailgun merges user-variables fields into event: for key in metadata.keys(): if key in raw_event: - if key in {'signature', 'timestamp', 'token'}: + if key in {"signature", "timestamp", "token"}: # For these fields, Mailgun's value appears after the metadata value raw_event[key] = [metadata[key], raw_event[key]] - elif key == 'message-headers': + elif key == "message-headers": pass # Mailgun won't merge this field into the event else: # For all other fields, the defined event value comes first @@ -699,11 +969,12 @@ class MailgunLegacyTestCase(WebhookTestCase): else: raw_event[key] = metadata[key] - response = self.client.post('/anymail/mailgun/tracking/', data=raw_event) - self.assertEqual(response.status_code, 200) # if this fails, signature checking is using metadata values + response = self.client.post("/anymail/mailgun/tracking/", data=raw_event) + # if this fails, signature checking is using metadata values: + self.assertEqual(response.status_code, 200) kwargs = self.assert_handler_called_once_with(self.tracking_handler) - event = kwargs['event'] + event = kwargs["event"] self.assertEqual(event.event_type, "clicked") self.assertEqual(event.recipient, "actual-recipient@example.com") self.assertEqual(event.timestamp.isoformat(), "2016-04-21T17:55:30+00:00") @@ -713,35 +984,45 @@ class MailgunLegacyTestCase(WebhookTestCase): def test_tags(self): # Most events include multiple 'tag' fields for message's tags - raw_event = mailgun_sign_legacy_payload({ - 'tag': ['tag1', 'tag2'], # Django TestClient encodes list as multiple field values - 'event': 'opened', - }) - self.client.post('/anymail/mailgun/tracking/', data=raw_event) + raw_event = mailgun_sign_legacy_payload( + { + # Django TestClient encodes list as multiple field values: + "tag": ["tag1", "tag2"], + "event": "opened", + } + ) + self.client.post("/anymail/mailgun/tracking/", data=raw_event) kwargs = self.assert_handler_called_once_with(self.tracking_handler) - event = kwargs['event'] + event = kwargs["event"] self.assertEqual(event.tags, ["tag1", "tag2"]) def test_x_tags(self): # Delivery events don't include 'tag', but do include 'X-Mailgun-Tag' fields - raw_event = mailgun_sign_legacy_payload({ - 'X-Mailgun-Tag': ['tag1', 'tag2'], - 'event': 'delivered', - }) - self.client.post('/anymail/mailgun/tracking/', data=raw_event) + raw_event = mailgun_sign_legacy_payload( + { + "X-Mailgun-Tag": ["tag1", "tag2"], + "event": "delivered", + } + ) + self.client.post("/anymail/mailgun/tracking/", data=raw_event) kwargs = self.assert_handler_called_once_with(self.tracking_handler) - event = kwargs['event'] + event = kwargs["event"] self.assertEqual(event.tags, ["tag1", "tag2"]) def test_misconfigured_inbound(self): - raw_event = mailgun_sign_legacy_payload({ - 'recipient': 'test@inbound.example.com', - 'sender': 'envelope-from@example.org', - 'message-headers': '[]', - 'body-plain': 'Test body plain', - 'body-html': '
Test body html
', - }) + raw_event = mailgun_sign_legacy_payload( + { + "recipient": "test@inbound.example.com", + "sender": "envelope-from@example.org", + "message-headers": "[]", + "body-plain": "Test body plain", + "body-html": "
Test body html
", + } + ) - errmsg = "You seem to have set Mailgun's *inbound* route to Anymail's Mailgun *tracking* webhook URL." - with self.assertRaisesMessage(AnymailConfigurationError, errmsg): - self.client.post('/anymail/mailgun/tracking/', data=raw_event) + with self.assertRaisesMessage( + AnymailConfigurationError, + "You seem to have set Mailgun's *inbound* route" + " to Anymail's Mailgun *tracking* webhook URL.", + ): + self.client.post("/anymail/mailgun/tracking/", data=raw_event) diff --git a/tests/test_mailjet_backend.py b/tests/test_mailjet_backend.py index dc4e8b1..25ff8d1 100644 --- a/tests/test_mailjet_backend.py +++ b/tests/test_mailjet_backend.py @@ -8,19 +8,34 @@ from django.core import mail from django.core.exceptions import ImproperlyConfigured from django.test import SimpleTestCase, override_settings, tag -from anymail.exceptions import AnymailAPIError, AnymailSerializationError, AnymailUnsupportedFeature +from anymail.exceptions import ( + AnymailAPIError, + AnymailSerializationError, + AnymailUnsupportedFeature, +) from anymail.message import attach_inline_image_file -from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCases -from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin, decode_att +from .mock_requests_backend import ( + RequestsBackendMockAPITestCase, + SessionSharingTestCases, +) +from .utils import ( + SAMPLE_IMAGE_FILENAME, + AnymailTestMixin, + decode_att, + sample_image_content, + sample_image_path, +) -@tag('mailjet') -@override_settings(EMAIL_BACKEND='anymail.backends.mailjet.EmailBackend', - ANYMAIL={ - 'MAILJET_API_KEY': 'API KEY HERE', - 'MAILJET_SECRET_KEY': 'SECRET KEY HERE' - }) +@tag("mailjet") +@override_settings( + EMAIL_BACKEND="anymail.backends.mailjet.EmailBackend", + ANYMAIL={ + "MAILJET_API_KEY": "API KEY HERE", + "MAILJET_SECRET_KEY": "SECRET KEY HERE", + }, +) class MailjetBackendMockAPITestCase(RequestsBackendMockAPITestCase): DEFAULT_RAW_RESPONSE = b"""{ "Messages": [{ @@ -37,29 +52,36 @@ class MailjetBackendMockAPITestCase(RequestsBackendMockAPITestCase): def setUp(self): super().setUp() # Simple message useful for many tests - self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com']) + self.message = mail.EmailMultiAlternatives( + "Subject", "Text Body", "from@example.com", ["to@example.com"] + ) -@tag('mailjet') +@tag("mailjet") class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase): """Test backend support for Django standard email features""" def test_send_mail(self): """Test basic API for simple send""" - mail.send_mail('Subject here', 'Here is the message.', - 'from@sender.example.com', ['to@example.com'], fail_silently=False) - self.assert_esp_called('/v3.1/send') + mail.send_mail( + "Subject here", + "Here is the message.", + "from@sender.example.com", + ["to@example.com"], + fail_silently=False, + ) + self.assert_esp_called("/v3.1/send") auth = self.get_api_call_auth() - self.assertEqual(auth, ('API KEY HERE', 'SECRET KEY HERE')) + self.assertEqual(auth, ("API KEY HERE", "SECRET KEY HERE")) data = self.get_api_call_json() - self.assertEqual(len(data['Messages']), 1) - message = data['Messages'][0] - self.assertEqual(data['Globals']['Subject'], "Subject here") - self.assertEqual(data['Globals']['TextPart'], "Here is the message.") - self.assertEqual(data['Globals']['From'], {"Email": "from@sender.example.com"}) - self.assertEqual(message['To'], [{"Email": "to@example.com"}]) + self.assertEqual(len(data["Messages"]), 1) + message = data["Messages"][0] + self.assertEqual(data["Globals"]["Subject"], "Subject here") + self.assertEqual(data["Globals"]["TextPart"], "Here is the message.") + self.assertEqual(data["Globals"]["From"], {"Email": "from@sender.example.com"}) + self.assertEqual(message["To"], [{"Email": "to@example.com"}]) def test_name_addr(self): """Make sure RFC2822 name-addr format (with display-name) is allowed @@ -67,101 +89,153 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase): (Test both sender and recipient addresses) """ msg = mail.EmailMessage( - 'Subject', 'Message', 'From Name ', - ['"Recipient, #1" ', 'to2@example.com'], - cc=['Carbon Copy ', 'cc2@example.com'], - bcc=['Blind Copy ', 'bcc2@example.com']) + "Subject", + "Message", + "From Name ", + ['"Recipient, #1" ', "to2@example.com"], + cc=["Carbon Copy ", "cc2@example.com"], + bcc=["Blind Copy ", "bcc2@example.com"], + ) msg.send() data = self.get_api_call_json() - self.assertEqual(len(data['Messages']), 1) - message = data['Messages'][0] - self.assertEqual(data['Globals']['From'], {"Email": "from@example.com", "Name": "From Name"}) - self.assertEqual(message['To'], [{"Email": "to1@example.com", "Name": "Recipient, #1"}, - {"Email": "to2@example.com"}]) - self.assertEqual(data['Globals']['Cc'], [{"Email": "cc1@example.com", "Name": "Carbon Copy"}, - {"Email": "cc2@example.com"}]) - self.assertEqual(data['Globals']['Bcc'], [{"Email": "bcc1@example.com", "Name": "Blind Copy"}, - {"Email": "bcc2@example.com"}]) + self.assertEqual(len(data["Messages"]), 1) + message = data["Messages"][0] + self.assertEqual( + data["Globals"]["From"], {"Email": "from@example.com", "Name": "From Name"} + ) + self.assertEqual( + message["To"], + [ + {"Email": "to1@example.com", "Name": "Recipient, #1"}, + {"Email": "to2@example.com"}, + ], + ) + self.assertEqual( + data["Globals"]["Cc"], + [ + {"Email": "cc1@example.com", "Name": "Carbon Copy"}, + {"Email": "cc2@example.com"}, + ], + ) + self.assertEqual( + data["Globals"]["Bcc"], + [ + {"Email": "bcc1@example.com", "Name": "Blind Copy"}, + {"Email": "bcc2@example.com"}, + ], + ) def test_email_message(self): email = mail.EmailMessage( - 'Subject', 'Body goes here', 'from@example.com', - ['to1@example.com', 'Also To '], - bcc=['bcc1@example.com', 'Also BCC '], - cc=['cc1@example.com', 'Also CC '], - headers={'Reply-To': 'another@example.com', - 'X-MyHeader': 'my value'}) + "Subject", + "Body goes here", + "from@example.com", + ["to1@example.com", "Also To "], + bcc=["bcc1@example.com", "Also BCC "], + cc=["cc1@example.com", "Also CC "], + headers={"Reply-To": "another@example.com", "X-MyHeader": "my value"}, + ) email.send() data = self.get_api_call_json() - self.assertEqual(len(data['Messages']), 1) - message = data['Messages'][0] - self.assertEqual(data['Globals']['Subject'], "Subject") - self.assertEqual(data['Globals']['TextPart'], "Body goes here") - self.assertEqual(data['Globals']['From'], {"Email": "from@example.com"}) - self.assertEqual(message['To'], [{"Email": "to1@example.com"}, - {"Email": "to2@example.com", "Name": "Also To"}]) - self.assertEqual(data['Globals']['Cc'], [{"Email": "cc1@example.com"}, - {"Email": "cc2@example.com", "Name": "Also CC"}]) - self.assertEqual(data['Globals']['Bcc'], [{"Email": "bcc1@example.com"}, - {"Email": "bcc2@example.com", "Name": "Also BCC"}]) - self.assertEqual(data['Globals']['Headers'], - {'X-MyHeader': 'my value'}) # Reply-To should be moved to own param - self.assertEqual(data['Globals']['ReplyTo'], {"Email": "another@example.com"}) + self.assertEqual(len(data["Messages"]), 1) + message = data["Messages"][0] + self.assertEqual(data["Globals"]["Subject"], "Subject") + self.assertEqual(data["Globals"]["TextPart"], "Body goes here") + self.assertEqual(data["Globals"]["From"], {"Email": "from@example.com"}) + self.assertEqual( + message["To"], + [ + {"Email": "to1@example.com"}, + {"Email": "to2@example.com", "Name": "Also To"}, + ], + ) + self.assertEqual( + data["Globals"]["Cc"], + [ + {"Email": "cc1@example.com"}, + {"Email": "cc2@example.com", "Name": "Also CC"}, + ], + ) + self.assertEqual( + data["Globals"]["Bcc"], + [ + {"Email": "bcc1@example.com"}, + {"Email": "bcc2@example.com", "Name": "Also BCC"}, + ], + ) + # Reply-To should be moved to own param: + self.assertEqual(data["Globals"]["Headers"], {"X-MyHeader": "my value"}) + self.assertEqual(data["Globals"]["ReplyTo"], {"Email": "another@example.com"}) def test_html_message(self): - text_content = 'This is an important message.' - html_content = '

This is an important message.

' - email = mail.EmailMultiAlternatives('Subject', text_content, - 'from@example.com', ['to@example.com']) + text_content = "This is an important message." + html_content = "

This is an important message.

" + email = mail.EmailMultiAlternatives( + "Subject", text_content, "from@example.com", ["to@example.com"] + ) email.attach_alternative(html_content, "text/html") email.send() data = self.get_api_call_json() - self.assertEqual(len(data['Messages']), 1) - self.assertEqual(data['Globals']['TextPart'], text_content) - self.assertEqual(data['Globals']['HTMLPart'], html_content) + self.assertEqual(len(data["Messages"]), 1) + self.assertEqual(data["Globals"]["TextPart"], text_content) + self.assertEqual(data["Globals"]["HTMLPart"], html_content) # Don't accidentally send the html part as an attachment: - self.assertNotIn('Attachments', data['Globals']) + self.assertNotIn("Attachments", data["Globals"]) def test_html_only_message(self): - html_content = '

This is an important message.

' - email = mail.EmailMessage('Subject', html_content, 'from@example.com', ['to@example.com']) + html_content = "

This is an important message.

" + email = mail.EmailMessage( + "Subject", html_content, "from@example.com", ["to@example.com"] + ) email.content_subtype = "html" # Main content is now text/html email.send() data = self.get_api_call_json() - self.assertNotIn('TextPart', data['Globals']) - self.assertEqual(data['Globals']['HTMLPart'], html_content) + self.assertNotIn("TextPart", data["Globals"]) + self.assertEqual(data["Globals"]["HTMLPart"], html_content) def test_extra_headers(self): - self.message.extra_headers = {'X-Custom': 'string', 'X-Num': 123} + self.message.extra_headers = {"X-Custom": "string", "X-Num": 123} self.message.send() data = self.get_api_call_json() - self.assertEqual(data['Globals']['Headers'], { - 'X-Custom': 'string', - 'X-Num': 123, - }) + self.assertEqual( + data["Globals"]["Headers"], + { + "X-Custom": "string", + "X-Num": 123, + }, + ) def test_extra_headers_serialization_error(self): - self.message.extra_headers = {'X-Custom': Decimal(12.5)} + self.message.extra_headers = {"X-Custom": Decimal(12.5)} with self.assertRaisesMessage(AnymailSerializationError, "Decimal"): self.message.send() - @override_settings(ANYMAIL_IGNORE_UNSUPPORTED_FEATURES=True) # Mailjet only allows single reply-to + @override_settings(ANYMAIL_IGNORE_UNSUPPORTED_FEATURES=True) def test_reply_to(self): - email = mail.EmailMessage('Subject', 'Body goes here', 'from@example.com', ['to1@example.com'], - reply_to=['reply@example.com', 'Other '], - headers={'X-Other': 'Keep'}) + # Mailjet only allows single reply-to. Verify correct handling for that + # when ignoring unsupported features. + email = mail.EmailMessage( + "Subject", + "Body goes here", + "from@example.com", + ["to1@example.com"], + reply_to=["reply@example.com", "Other "], + headers={"X-Other": "Keep"}, + ) email.send() data = self.get_api_call_json() - self.assertEqual(data['Globals']['ReplyTo'], {"Email": "reply@example.com"}) # only the first reply_to - self.assertEqual(data['Globals']['Headers'], { - 'X-Other': 'Keep' - }) # don't lose other headers + # only the first reply_to: + self.assertEqual(data["Globals"]["ReplyTo"], {"Email": "reply@example.com"}) + # don't lose other headers: + self.assertEqual(data["Globals"]["Headers"], {"X-Other": "Keep"}) def test_attachments(self): text_content = "* Item one\n* Item two\n* Item three" - self.message.attach(filename="test.txt", content=text_content, mimetype="text/plain") + self.message.attach( + filename="test.txt", content=text_content, mimetype="text/plain" + ) # Should guess mimetype if not provided... png_content = b"PNG\xb4 pretend this is the contents of a png file" @@ -169,40 +243,53 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase): # Should work with a MIMEBase object (also tests no filename)... pdf_content = b"PDF\xb4 pretend this is valid pdf data" - mimeattachment = MIMEBase('application', 'pdf') + mimeattachment = MIMEBase("application", "pdf") mimeattachment.set_payload(pdf_content) self.message.attach(mimeattachment) self.message.send() data = self.get_api_call_json() - attachments = data['Globals']['Attachments'] + attachments = data["Globals"]["Attachments"] self.assertEqual(len(attachments), 3) self.assertEqual(attachments[0]["Filename"], "test.txt") self.assertEqual(attachments[0]["ContentType"], "text/plain") - self.assertEqual(decode_att(attachments[0]["Base64Content"]).decode('ascii'), text_content) - self.assertNotIn('ContentID', attachments[0]) + self.assertEqual( + decode_att(attachments[0]["Base64Content"]).decode("ascii"), text_content + ) + self.assertNotIn("ContentID", attachments[0]) - self.assertEqual(attachments[1]["ContentType"], "image/png") # inferred from filename + # inferred from filename: + self.assertEqual(attachments[1]["ContentType"], "image/png") self.assertEqual(attachments[1]["Filename"], "test.png") self.assertEqual(decode_att(attachments[1]["Base64Content"]), png_content) - self.assertNotIn('ContentID', attachments[1]) # make sure image not treated as inline + # make sure image not treated as inline: + self.assertNotIn("ContentID", attachments[1]) self.assertEqual(attachments[2]["ContentType"], "application/pdf") self.assertEqual(attachments[2]["Filename"], "") # none self.assertEqual(decode_att(attachments[2]["Base64Content"]), pdf_content) - self.assertNotIn('ContentID', attachments[2]) + self.assertNotIn("ContentID", attachments[2]) - self.assertNotIn('InlinedAttachments', data['Globals']) + self.assertNotIn("InlinedAttachments", data["Globals"]) def test_unicode_attachment_correctly_decoded(self): - self.message.attach("Une pièce jointe.html", '

\u2019

', mimetype='text/html') + self.message.attach( + "Une pièce jointe.html", "

\u2019

", mimetype="text/html" + ) self.message.send() data = self.get_api_call_json() - self.assertEqual(data['Globals']['Attachments'], [{ - 'Filename': 'Une pièce jointe.html', - 'ContentType': 'text/html', - 'Base64Content': b64encode('

\u2019

'.encode('utf-8')).decode('ascii') - }]) + self.assertEqual( + data["Globals"]["Attachments"], + [ + { + "Filename": "Une pièce jointe.html", + "ContentType": "text/html", + "Base64Content": b64encode("

\u2019

".encode("utf-8")).decode( + "ascii" + ), + } + ], + ) def test_embedded_images(self): image_filename = SAMPLE_IMAGE_FILENAME @@ -210,48 +297,55 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase): image_data = sample_image_content(image_filename) cid = attach_inline_image_file(self.message, image_path) # Read from a png file - html_content = '

This has an inline image.

' % cid + html_content = ( + '

This has an inline image.

' % cid + ) self.message.attach_alternative(html_content, "text/html") self.message.send() data = self.get_api_call_json() - self.assertEqual(data['Globals']['HTMLPart'], html_content) + self.assertEqual(data["Globals"]["HTMLPart"], html_content) - attachments = data['Globals']['InlinedAttachments'] + attachments = data["Globals"]["InlinedAttachments"] self.assertEqual(len(attachments), 1) - self.assertEqual(attachments[0]['Filename'], image_filename) - self.assertEqual(attachments[0]['ContentID'], cid) - self.assertEqual(attachments[0]['ContentType'], 'image/png') + self.assertEqual(attachments[0]["Filename"], image_filename) + self.assertEqual(attachments[0]["ContentID"], cid) + self.assertEqual(attachments[0]["ContentType"], "image/png") self.assertEqual(decode_att(attachments[0]["Base64Content"]), image_data) - self.assertNotIn('Attachments', data['Globals']) + self.assertNotIn("Attachments", data["Globals"]) def test_attached_images(self): image_filename = SAMPLE_IMAGE_FILENAME image_path = sample_image_path(image_filename) image_data = sample_image_content(image_filename) - self.message.attach_file(image_path) # option 1: attach as a file + # option 1: attach as a file: + self.message.attach_file(image_path) - image = MIMEImage(image_data) # option 2: construct the MIMEImage and attach it directly + # option 2: construct the MIMEImage and attach it directly: + image = MIMEImage(image_data) self.message.attach(image) - image_data_b64 = b64encode(image_data).decode('ascii') + image_data_b64 = b64encode(image_data).decode("ascii") self.message.send() data = self.get_api_call_json() - self.assertEqual(data['Globals']['Attachments'], [ - { - 'Filename': image_filename, # the named one - 'ContentType': 'image/png', - 'Base64Content': image_data_b64, - }, - { - 'Filename': '', # the unnamed one - 'ContentType': 'image/png', - 'Base64Content': image_data_b64, - }, - ]) + self.assertEqual( + data["Globals"]["Attachments"], + [ + { + "Filename": image_filename, # the named one + "ContentType": "image/png", + "Base64Content": image_data_b64, + }, + { + "Filename": "", # the unnamed one + "ContentType": "image/png", + "Base64Content": image_data_b64, + }, + ], + ) def test_multiple_html_alternatives(self): # Multiple alternatives not allowed @@ -277,38 +371,50 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase): """Empty to, cc, bcc, and reply_to shouldn't generate empty fields""" self.message.send() data = self.get_api_call_json() - self.assertNotIn('Cc', data['Globals']) - self.assertNotIn('Bcc', data['Globals']) - self.assertNotIn('ReplyTo', data['Globals']) + self.assertNotIn("Cc", data["Globals"]) + self.assertNotIn("Bcc", data["Globals"]) + self.assertNotIn("ReplyTo", data["Globals"]) def test_empty_to_list(self): # Mailjet v3.1 doesn't support cc-only or bcc-only messages self.message.to = [] - self.message.cc = ['cc@example.com'] - with self.assertRaisesMessage(AnymailUnsupportedFeature, "messages without any `to` recipients"): + self.message.cc = ["cc@example.com"] + with self.assertRaisesMessage( + AnymailUnsupportedFeature, "messages without any `to` recipients" + ): self.message.send() def test_api_failure(self): self.set_mock_response(status_code=500) with self.assertRaisesMessage(AnymailAPIError, "Mailjet API response 500"): - mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com']) + mail.send_mail("Subject", "Body", "from@example.com", ["to@example.com"]) # Make sure fail_silently is respected self.set_mock_response(status_code=500) - sent = mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com'], fail_silently=True) + sent = mail.send_mail( + "Subject", + "Body", + "from@example.com", + ["to@example.com"], + fail_silently=True, + ) self.assertEqual(sent, 0) def test_api_error_includes_details(self): """AnymailAPIError should include ESP's error message""" # JSON error response - global error: - error_response = json.dumps({ - "ErrorIdentifier": "06df1144-c6f3-4ca7-8885-7ec5d4344113", - "ErrorCode": "mj-0002", - "ErrorMessage": "Helpful explanation from Mailjet.", - "StatusCode": 400 - }).encode('utf-8') + error_response = json.dumps( + { + "ErrorIdentifier": "06df1144-c6f3-4ca7-8885-7ec5d4344113", + "ErrorCode": "mj-0002", + "ErrorMessage": "Helpful explanation from Mailjet.", + "StatusCode": 400, + } + ).encode("utf-8") self.set_mock_response(status_code=400, raw=error_response) - with self.assertRaisesMessage(AnymailAPIError, "Helpful explanation from Mailjet"): + with self.assertRaisesMessage( + AnymailAPIError, "Helpful explanation from Mailjet" + ): self.message.send() # Non-JSON error response: @@ -322,7 +428,7 @@ class MailjetBackendStandardEmailTests(MailjetBackendMockAPITestCase): self.message.send() -@tag('mailjet') +@tag("mailjet") class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase): """Test backend support for Anymail added features""" @@ -330,107 +436,131 @@ class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase): self.message.envelope_sender = "bounce-handler@bounces.example.com" self.message.send() data = self.get_api_call_json() - self.assertEqual(data['Globals']['Sender'], {"Email": "bounce-handler@bounces.example.com"}) + self.assertEqual( + data["Globals"]["Sender"], {"Email": "bounce-handler@bounces.example.com"} + ) def test_metadata(self): # Mailjet expects the payload to be a single string # https://dev.mailjet.com/guides/#tagging-email-messages - self.message.metadata = {'user_id': "12345", 'items': 6} + self.message.metadata = {"user_id": "12345", "items": 6} self.message.send() data = self.get_api_call_json() - self.assertJSONEqual(data['Globals']['EventPayload'], {"user_id": "12345", "items": 6}) + self.assertJSONEqual( + data["Globals"]["EventPayload"], {"user_id": "12345", "items": 6} + ) def test_send_at(self): self.message.send_at = 1651820889 # 2022-05-06 07:08:09 UTC - with self.assertRaisesMessage(AnymailUnsupportedFeature, 'send_at'): + with self.assertRaisesMessage(AnymailUnsupportedFeature, "send_at"): self.message.send() def test_tags(self): self.message.tags = ["receipt"] self.message.send() data = self.get_api_call_json() - self.assertEqual(data['Globals']['CustomCampaign'], "receipt") + self.assertEqual(data["Globals"]["CustomCampaign"], "receipt") self.message.tags = ["receipt", "repeat-user"] - with self.assertRaisesMessage(AnymailUnsupportedFeature, 'multiple tags'): + with self.assertRaisesMessage(AnymailUnsupportedFeature, "multiple tags"): self.message.send() def test_track_opens(self): self.message.track_opens = True self.message.send() data = self.get_api_call_json() - self.assertEqual(data['Globals']['TrackOpens'], 'enabled') + self.assertEqual(data["Globals"]["TrackOpens"], "enabled") def test_track_clicks(self): self.message.track_clicks = True self.message.send() data = self.get_api_call_json() - self.assertEqual(data['Globals']['TrackClicks'], 'enabled') + self.assertEqual(data["Globals"]["TrackClicks"], "enabled") self.message.track_clicks = False self.message.send() data = self.get_api_call_json() - self.assertEqual(data['Globals']['TrackClicks'], 'disabled') + self.assertEqual(data["Globals"]["TrackClicks"], "disabled") def test_template(self): - # template_id can be str or int (but must be numeric ID -- not the template's name) - self.message.template_id = '1234567' - self.message.merge_global_data = {'name': "Alice", 'group': "Developers"} + # template_id can be str or int (but must be numeric ID-not the template's name) + self.message.template_id = "1234567" + self.message.merge_global_data = {"name": "Alice", "group": "Developers"} self.message.send() data = self.get_api_call_json() - self.assertEqual(data['Globals']['TemplateID'], 1234567) # must be integer - self.assertEqual(data['Globals']['TemplateLanguage'], True) # required to use variables - self.assertEqual(data['Globals']['Variables'], {'name': "Alice", 'group': "Developers"}) + self.assertEqual(data["Globals"]["TemplateID"], 1234567) # must be integer + # TemplateLanguage required to use variables: + self.assertEqual(data["Globals"]["TemplateLanguage"], True) + self.assertEqual( + data["Globals"]["Variables"], {"name": "Alice", "group": "Developers"} + ) def test_template_populate_from_sender(self): # v3.1 API allows omitting From param to use template's sender - self.message.template_id = '1234567' - self.message.from_email = None # must set to None after constructing EmailMessage + self.message.template_id = "1234567" + # must set from_email to None after constructing EmailMessage: + self.message.from_email = None self.message.send() data = self.get_api_call_json() - self.assertNotIn('From', data['Globals']) # use template's sender as From + self.assertNotIn("From", data["Globals"]) # use template's sender as From def test_merge_data(self): - self.message.to = ['alice@example.com', 'Bob '] + self.message.to = ["alice@example.com", "Bob "] self.message.merge_data = { - 'alice@example.com': {'name': "Alice", 'group': "Developers"}, - 'bob@example.com': {'name': "Bob"}, + "alice@example.com": {"name": "Alice", "group": "Developers"}, + "bob@example.com": {"name": "Bob"}, + } + self.message.merge_global_data = { + "group": "Default Group", + "global": "Global value", } - self.message.merge_global_data = {'group': "Default Group", 'global': "Global value"} self.message.send() data = self.get_api_call_json() - messages = data['Messages'] - self.assertEqual(len(messages), 2) # with merge_data, each 'to' gets separate message + messages = data["Messages"] + # with merge_data, each 'to' gets separate message: + self.assertEqual(len(messages), 2) - self.assertEqual(messages[0]['To'], [{"Email": "alice@example.com"}]) - self.assertEqual(messages[1]['To'], [{"Email": "bob@example.com", "Name": "Bob"}]) + self.assertEqual(messages[0]["To"], [{"Email": "alice@example.com"}]) + self.assertEqual( + messages[1]["To"], [{"Email": "bob@example.com", "Name": "Bob"}] + ) # global merge_data is sent in Globals - self.assertEqual(data['Globals']['Variables'], {'group': "Default Group", 'global': "Global value"}) + self.assertEqual( + data["Globals"]["Variables"], + {"group": "Default Group", "global": "Global value"}, + ) - # per-recipient merge_data is sent in Messages (and Mailjet will merge with Globals) - self.assertEqual(messages[0]['Variables'], {'name': "Alice", 'group': "Developers"}) - self.assertEqual(messages[1]['Variables'], {'name': "Bob"}) + # per-recipient merge_data is sent in Messages + # (and Mailjet will merge with Globals) + self.assertEqual( + messages[0]["Variables"], {"name": "Alice", "group": "Developers"} + ) + self.assertEqual(messages[1]["Variables"], {"name": "Bob"}) def test_merge_metadata(self): - self.message.to = ['alice@example.com', 'Bob '] + self.message.to = ["alice@example.com", "Bob "] self.message.merge_metadata = { - 'alice@example.com': {'order_id': 123, 'tier': 'premium'}, - 'bob@example.com': {'order_id': 678}, + "alice@example.com": {"order_id": 123, "tier": "premium"}, + "bob@example.com": {"order_id": 678}, } - self.message.metadata = {'notification_batch': 'zx912'} + self.message.metadata = {"notification_batch": "zx912"} self.message.send() data = self.get_api_call_json() - messages = data['Messages'] + messages = data["Messages"] self.assertEqual(len(messages), 2) - self.assertEqual(messages[0]['To'][0]['Email'], "alice@example.com") + self.assertEqual(messages[0]["To"][0]["Email"], "alice@example.com") # metadata and merge_metadata[recipient] are combined: - self.assertJSONEqual(messages[0]['EventPayload'], - {'order_id': 123, 'tier': 'premium', 'notification_batch': 'zx912'}) - self.assertEqual(messages[1]['To'][0]['Email'], "bob@example.com") - self.assertJSONEqual(messages[1]['EventPayload'], - {'order_id': 678, 'notification_batch': 'zx912'}) + self.assertJSONEqual( + messages[0]["EventPayload"], + {"order_id": 123, "tier": "premium", "notification_batch": "zx912"}, + ) + self.assertEqual(messages[1]["To"][0]["Email"], "bob@example.com") + self.assertJSONEqual( + messages[1]["EventPayload"], + {"order_id": 678, "notification_batch": "zx912"}, + ) def test_default_omits_options(self): """Make sure by default we don't send any ESP-specific options. @@ -441,14 +571,14 @@ class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase): """ self.message.send() data = self.get_api_call_json() - self.assertNotIn('CustomCampaign', data["Globals"]) - self.assertNotIn('EventPayload', data["Globals"]) - self.assertNotIn('HTMLPart', data["Globals"]) - self.assertNotIn('TemplateID', data["Globals"]) - self.assertNotIn('TemplateLanguage', data["Globals"]) - self.assertNotIn('Variables', data["Globals"]) - self.assertNotIn('TrackOpens', data["Globals"]) - self.assertNotIn('TrackClicks', data["Globals"]) + self.assertNotIn("CustomCampaign", data["Globals"]) + self.assertNotIn("EventPayload", data["Globals"]) + self.assertNotIn("HTMLPart", data["Globals"]) + self.assertNotIn("TemplateID", data["Globals"]) + self.assertNotIn("TemplateLanguage", data["Globals"]) + self.assertNotIn("Variables", data["Globals"]) + self.assertNotIn("TrackOpens", data["Globals"]) + self.assertNotIn("TrackClicks", data["Globals"]) def test_esp_extra(self): # Anymail deep merges Mailjet esp_extra into the v3.1 Send API payload. @@ -456,90 +586,131 @@ class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase): # at the root. Note that it's *not* possible to merge into Messages # (though you could completely replace it). self.message.esp_extra = { - 'Globals': { - 'TemplateErrorDeliver': True, - 'TemplateErrorReporting': 'bugs@example.com', + "Globals": { + "TemplateErrorDeliver": True, + "TemplateErrorReporting": "bugs@example.com", }, - 'SandboxMode': True, + "SandboxMode": True, } self.message.send() data = self.get_api_call_json() - self.assertEqual(data["Globals"]['TemplateErrorDeliver'], True) - self.assertEqual(data["Globals"]['TemplateErrorReporting'], 'bugs@example.com') - self.assertIs(data['SandboxMode'], True) + self.assertEqual(data["Globals"]["TemplateErrorDeliver"], True) + self.assertEqual(data["Globals"]["TemplateErrorReporting"], "bugs@example.com") + self.assertIs(data["SandboxMode"], True) # Make sure the backend params are also still there - self.assertEqual(data["Globals"]['Subject'], "Subject") + self.assertEqual(data["Globals"]["Subject"], "Subject") # noinspection PyUnresolvedReferences def test_send_attaches_anymail_status(self): - """ The anymail_status should be attached to the message when it is sent """ - response_content = json.dumps({ - "Messages": [{ - "Status": "success", - "To": [{ - "Email": "to1@example.com", - "MessageUUID": "cb927469-36fd-4c02-bce4-0d199929a207", - "MessageID": 12345678901234500, - "MessageHref": "https://api.mailjet.com/v3/message/12345678901234500" - }] - }] - }).encode('utf-8') + """The anymail_status should be attached to the message when it is sent""" + response_content = json.dumps( + { + "Messages": [ + { + "Status": "success", + "To": [ + { + "Email": "to1@example.com", + "MessageUUID": "cb927469-36fd-4c02-bce4-0d199929a207", + "MessageID": 12345678901234500, + "MessageHref": "https://api.mailjet.com/v3/message" + "/12345678901234500", + } + ], + } + ] + } + ).encode("utf-8") self.set_mock_response(raw=response_content) - msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com']) + msg = mail.EmailMessage( + "Subject", "Message", "from@example.com", ["to1@example.com"] + ) sent = msg.send() self.assertEqual(sent, 1) - self.assertEqual(msg.anymail_status.status, {'sent'}) + self.assertEqual(msg.anymail_status.status, {"sent"}) self.assertEqual(msg.anymail_status.message_id, "12345678901234500") - self.assertEqual(msg.anymail_status.recipients['to1@example.com'].status, 'sent') - self.assertEqual(msg.anymail_status.recipients['to1@example.com'].message_id, "12345678901234500") + self.assertEqual( + msg.anymail_status.recipients["to1@example.com"].status, "sent" + ) + self.assertEqual( + msg.anymail_status.recipients["to1@example.com"].message_id, + "12345678901234500", + ) self.assertEqual(msg.anymail_status.esp_response.content, response_content) # noinspection PyUnresolvedReferences def test_mixed_status(self): """The status should include an entry for each recipient""" - # Mailjet's v3.1 API will partially fail a batch send, allowing valid emails to go out. - # The API response doesn't identify the failed email addresses; make sure we represent - # them correctly in the anymail_status. - response_content = json.dumps({ - "Messages": [{ - "Status": "success", - "CustomID": "", - "To": [{ - "Email": "to-good@example.com", - "MessageUUID": "556e896a-e041-4836-bb35-8bb75ee308c5", - "MessageID": 12345678901234500, - "MessageHref": "https://api.mailjet.com/v3/REST/message/12345678901234500" - }], - "Cc": [], - "Bcc": [] - }, { - "Errors": [{ - "ErrorIdentifier": "f480a5a2-0334-4e08-b2b7-f372ce5669e0", - "ErrorCode": "mj-0013", - "StatusCode": 400, - "ErrorMessage": "\"invalid@123.4\" is an invalid email address.", - "ErrorRelatedTo": ["To[0].Email"] - }], - "Status": "error" - }] - }).encode('utf-8') - self.set_mock_response(raw=response_content, status_code=400) # Mailjet uses 400 for partial success - msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to-good@example.com', 'invalid@123.4']) + # Mailjet's v3.1 API will partially fail a batch send, allowing valid emails + # to go out. The API response doesn't identify the failed email addresses; + # make sure we represent them correctly in the anymail_status. + response_content = json.dumps( + { + "Messages": [ + { + "Status": "success", + "CustomID": "", + "To": [ + { + "Email": "to-good@example.com", + "MessageUUID": "556e896a-e041-4836-bb35-8bb75ee308c5", + "MessageID": 12345678901234500, + "MessageHref": "https://api.mailjet.com/v3/REST" + "/message/12345678901234500", + } + ], + "Cc": [], + "Bcc": [], + }, + { + "Errors": [ + { + "ErrorIdentifier": "f480a5a2-0334-4e08" + "-b2b7-f372ce5669e0", + "ErrorCode": "mj-0013", + "StatusCode": 400, + "ErrorMessage": '"invalid@123.4" is an invalid' + " email address.", + "ErrorRelatedTo": ["To[0].Email"], + } + ], + "Status": "error", + }, + ] + } + ).encode("utf-8") + # Mailjet uses 400 for partial success: + self.set_mock_response(raw=response_content, status_code=400) + msg = mail.EmailMessage( + "Subject", + "Message", + "from@example.com", + ["to-good@example.com", "invalid@123.4"], + ) sent = msg.send() self.assertEqual(sent, 1) - self.assertEqual(msg.anymail_status.status, {'sent', 'failed'}) - self.assertEqual(msg.anymail_status.recipients['to-good@example.com'].status, 'sent') - self.assertEqual(msg.anymail_status.recipients['to-good@example.com'].message_id, "12345678901234500") - self.assertEqual(msg.anymail_status.recipients['invalid@123.4'].status, 'failed') - self.assertEqual(msg.anymail_status.recipients['invalid@123.4'].message_id, None) + self.assertEqual(msg.anymail_status.status, {"sent", "failed"}) + self.assertEqual( + msg.anymail_status.recipients["to-good@example.com"].status, "sent" + ) + self.assertEqual( + msg.anymail_status.recipients["to-good@example.com"].message_id, + "12345678901234500", + ) + self.assertEqual( + msg.anymail_status.recipients["invalid@123.4"].status, "failed" + ) + self.assertEqual( + msg.anymail_status.recipients["invalid@123.4"].message_id, None + ) self.assertEqual(msg.anymail_status.message_id, {"12345678901234500", None}) 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""" + """If the send fails, anymail_status should contain initial values""" self.set_mock_response(status_code=500) sent = self.message.send(fail_silently=True) self.assertEqual(sent, 0) @@ -550,9 +721,12 @@ class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase): # noinspection PyUnresolvedReferences def test_send_unparsable_response(self): - """If the send succeeds, but a non-JSON API response, should raise an API exception""" - mock_response = self.set_mock_response(status_code=200, - raw=b"yikes, this isn't a real response") + """ + If the send succeeds, but a non-JSON API response, should raise an API exception + """ + mock_response = self.set_mock_response( + status_code=200, raw=b"yikes, this isn't a real response" + ) with self.assertRaises(AnymailAPIError): self.message.send() self.assertIsNone(self.message.anymail_status.status) @@ -562,44 +736,53 @@ class MailjetBackendAnymailFeatureTests(MailjetBackendMockAPITestCase): def test_json_serialization_errors(self): """Try to provide more information about non-json-serializable data""" - self.message.tags = [Decimal('19.99')] # yeah, don't do this + self.message.tags = [Decimal("19.99")] # yeah, don't do this with self.assertRaises(AnymailSerializationError) as cm: self.message.send() print(self.get_api_call_json()) err = cm.exception self.assertIsInstance(err, TypeError) # compatibility with json.dumps - self.assertIn("Don't know how to send this data to Mailjet", str(err)) # our added context - self.assertRegex(str(err), r"Decimal.*is not JSON serializable") # original message + # our added context: + self.assertIn("Don't know how to send this data to Mailjet", str(err)) + # original message: + self.assertRegex(str(err), r"Decimal.*is not JSON serializable") def test_merge_data_null_values(self): # Mailjet doesn't accept None (null) as a merge value; # returns "HTTP/1.1 500 Cannot convert data from Null value" - self.message.merge_global_data = {'Some': None} - self.set_mock_response(status_code=500, reason="Cannot convert data from Null value", raw=None) - with self.assertRaisesMessage(AnymailAPIError, "Cannot convert data from Null value"): + self.message.merge_global_data = {"Some": None} + self.set_mock_response( + status_code=500, reason="Cannot convert data from Null value", raw=None + ) + with self.assertRaisesMessage( + AnymailAPIError, "Cannot convert data from Null value" + ): self.message.send() -@tag('mailjet') -class MailjetBackendSessionSharingTestCase(SessionSharingTestCases, MailjetBackendMockAPITestCase): +@tag("mailjet") +class MailjetBackendSessionSharingTestCase( + SessionSharingTestCases, MailjetBackendMockAPITestCase +): """Requests session sharing tests""" + pass # tests are defined in SessionSharingTestCases -@tag('mailjet') +@tag("mailjet") @override_settings(EMAIL_BACKEND="anymail.backends.mailjet.EmailBackend") class MailjetBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase): """Test ESP backend without required settings in place""" def test_missing_api_key(self): with self.assertRaises(ImproperlyConfigured) as cm: - mail.send_mail('Subject', 'Message', 'from@example.com', ['to@example.com']) + mail.send_mail("Subject", "Message", "from@example.com", ["to@example.com"]) errmsg = str(cm.exception) - self.assertRegex(errmsg, r'\bMAILJET_API_KEY\b') + self.assertRegex(errmsg, r"\bMAILJET_API_KEY\b") - @override_settings(ANYMAIL={'MAILJET_API_KEY': 'dummy'}) + @override_settings(ANYMAIL={"MAILJET_API_KEY": "dummy"}) def test_missing_secret_key(self): with self.assertRaises(ImproperlyConfigured) as cm: - mail.send_mail('Subject', 'Message', 'from@example.com', ['to@example.com']) + mail.send_mail("Subject", "Message", "from@example.com", ["to@example.com"]) errmsg = str(cm.exception) - self.assertRegex(errmsg, r'\bMAILJET_SECRET_KEY\b') + self.assertRegex(errmsg, r"\bMAILJET_SECRET_KEY\b") diff --git a/tests/test_mailjet_inbound.py b/tests/test_mailjet_inbound.py index 7a583ae..00a0bda 100644 --- a/tests/test_mailjet_inbound.py +++ b/tests/test_mailjet_inbound.py @@ -8,21 +8,24 @@ from anymail.inbound import AnymailInboundMessage from anymail.signals import AnymailInboundEvent from anymail.webhooks.mailjet import MailjetInboundWebhookView -from .utils import sample_image_content, sample_email_content +from .utils import sample_email_content, sample_image_content from .webhook_cases import WebhookTestCase -@tag('mailjet') +@tag("mailjet") class MailjetInboundTestCase(WebhookTestCase): def test_inbound_basics(self): raw_event = { "Sender": "envelope-from@example.org", "Recipient": "test@inbound.example.com", - "Date": "20171012T013104", # this is just the Date header from the sender, parsed to UTC + # this is just the Date header from the sender, parsed to UTC: + "Date": "20171012T013104", "From": '"Displayed From" ', "Subject": "Test subject", "Headers": { - "Return-Path": [""], + "Return-Path": [ + "" + ], "Received": [ "from mail.example.org by parse.mailjet.com ..." "by mail.example.org for ...", @@ -36,68 +39,85 @@ class MailjetInboundTestCase(WebhookTestCase): "To": "Test Inbound , other@example.com", "Cc": "cc@example.com", "Reply-To": "from+test@milter.example.org", - "Content-Type": ["multipart/alternative; boundary=\"boundary0\""], + "Content-Type": ['multipart/alternative; boundary="boundary0"'], }, - "Parts": [{ - "Headers": { - "Content-Type": ['text/plain; charset="UTF-8"'] + "Parts": [ + { + "Headers": {"Content-Type": ['text/plain; charset="UTF-8"']}, + "ContentRef": "Text-part", }, - "ContentRef": "Text-part" - }, { - "Headers": { - "Content-Type": ['text/html; charset="UTF-8"'], - "Content-Transfer-Encoding": ["quoted-printable"] + { + "Headers": { + "Content-Type": ['text/html; charset="UTF-8"'], + "Content-Transfer-Encoding": ["quoted-printable"], + }, + "ContentRef": "Html-part", }, - "ContentRef": "Html-part" - }], + ], "Text-part": "Test body plain", "Html-part": "
Test body html
", - "SpamAssassinScore": "1.7" + "SpamAssassinScore": "1.7", } - response = self.client.post('/anymail/mailjet/inbound/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/mailjet/inbound/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.inbound_handler, sender=MailjetInboundWebhookView, - event=ANY, esp_name='Mailjet') + kwargs = self.assert_handler_called_once_with( + self.inbound_handler, + sender=MailjetInboundWebhookView, + event=ANY, + esp_name="Mailjet", + ) # AnymailInboundEvent - event = kwargs['event'] + event = kwargs["event"] self.assertIsInstance(event, AnymailInboundEvent) - self.assertEqual(event.event_type, 'inbound') - self.assertIsNone(event.timestamp) # Mailjet doesn't provide inbound event timestamp - self.assertIsNone(event.event_id) # Mailjet doesn't provide inbound event id + self.assertEqual(event.event_type, "inbound") + # Mailjet doesn't provide inbound event timestamp: + self.assertIsNone(event.timestamp) + # Mailjet doesn't provide inbound event id: + self.assertIsNone(event.event_id) self.assertIsInstance(event.message, AnymailInboundMessage) self.assertEqual(event.esp_event, raw_event) # AnymailInboundMessage - convenience properties message = event.message - self.assertEqual(message.from_email.display_name, 'Displayed From') - self.assertEqual(message.from_email.addr_spec, 'from+test@example.org') - self.assertEqual([str(e) for e in message.to], - ['Test Inbound ', 'other@example.com']) - self.assertEqual([str(e) for e in message.cc], - ['cc@example.com']) - self.assertEqual(message.subject, 'Test subject') + self.assertEqual(message.from_email.display_name, "Displayed From") + self.assertEqual(message.from_email.addr_spec, "from+test@example.org") + self.assertEqual( + [str(e) for e in message.to], + ["Test Inbound ", "other@example.com"], + ) + self.assertEqual([str(e) for e in message.cc], ["cc@example.com"]) + self.assertEqual(message.subject, "Test subject") self.assertEqual(message.date.isoformat(" "), "2017-10-11 18:31:04-07:00") - self.assertEqual(message.text, 'Test body plain') - self.assertEqual(message.html, '
Test body html
') + self.assertEqual(message.text, "Test body plain") + self.assertEqual(message.html, "
Test body html
") - self.assertEqual(message.envelope_sender, 'envelope-from@example.org') - self.assertEqual(message.envelope_recipient, 'test@inbound.example.com') - self.assertIsNone(message.stripped_text) # Mailjet doesn't provide stripped plaintext body - self.assertIsNone(message.stripped_html) # Mailjet doesn't provide stripped html - self.assertIsNone(message.spam_detected) # Mailjet doesn't provide spam boolean + self.assertEqual(message.envelope_sender, "envelope-from@example.org") + self.assertEqual(message.envelope_recipient, "test@inbound.example.com") + # Mailjet doesn't provide stripped plaintext body: + self.assertIsNone(message.stripped_text) + # Mailjet doesn't provide stripped html: + self.assertIsNone(message.stripped_html) + # Mailjet doesn't provide spam boolean: + self.assertIsNone(message.spam_detected) self.assertEqual(message.spam_score, 1.7) # AnymailInboundMessage - other headers - self.assertEqual(message['Message-ID'], "") - self.assertEqual(message['Reply-To'], "from+test@milter.example.org") - self.assertEqual(message.get_all('Received'), [ - "from mail.example.org by parse.mailjet.com ..." - "by mail.example.org for ...", - "by 10.10.1.71 with HTTP; Wed, 11 Oct 2017 18:31:04 -0700 (PDT)", - ]) + self.assertEqual(message["Message-ID"], "") + self.assertEqual(message["Reply-To"], "from+test@milter.example.org") + self.assertEqual( + message.get_all("Received"), + [ + "from mail.example.org by parse.mailjet.com ..." + "by mail.example.org for ...", + "by 10.10.1.71 with HTTP; Wed, 11 Oct 2017 18:31:04 -0700 (PDT)", + ], + ) def test_attachments(self): image_content = sample_image_content() @@ -105,68 +125,89 @@ class MailjetInboundTestCase(WebhookTestCase): raw_event = { "Headers": { "MIME-Version": ["1.0"], - "Content-Type": ["multipart/mixed; boundary=\"boundary0\""], + "Content-Type": ['multipart/mixed; boundary="boundary0"'], }, - "Parts": [{ - "Headers": {"Content-Type": ['multipart/related; boundary="boundary1"']} - }, { - "Headers": {"Content-Type": ['multipart/alternative; boundary="boundary2"']} - }, { - "Headers": {"Content-Type": ['text/plain; charset="UTF-8"']}, - "ContentRef": "Text-part" - }, { - "Headers": { - "Content-Type": ['text/html; charset="UTF-8"'], - "Content-Transfer-Encoding": ["quoted-printable"] + "Parts": [ + { + "Headers": { + "Content-Type": ['multipart/related; boundary="boundary1"'] + } }, - "ContentRef": "Html-part" - }, { - "Headers": { - "Content-Type": ['text/plain'], - "Content-Disposition": ['attachment; filename="test.txt"'], - "Content-Transfer-Encoding": ["quoted-printable"], + { + "Headers": { + "Content-Type": ['multipart/alternative; boundary="boundary2"'] + } }, - "ContentRef": "Attachment1" - }, { - "Headers": { - "Content-Type": ['image/png; name="image.png"'], - "Content-Disposition": ['inline; filename="image.png"'], - "Content-Transfer-Encoding": ["base64"], - "Content-ID": [""], + { + "Headers": {"Content-Type": ['text/plain; charset="UTF-8"']}, + "ContentRef": "Text-part", }, - "ContentRef": "InlineAttachment1" - }, { - "Headers": { - "Content-Type": ['message/rfc822; charset="US-ASCII"'], - "Content-Disposition": ['attachment'], + { + "Headers": { + "Content-Type": ['text/html; charset="UTF-8"'], + "Content-Transfer-Encoding": ["quoted-printable"], + }, + "ContentRef": "Html-part", }, - "ContentRef": "Attachment2" - }], + { + "Headers": { + "Content-Type": ["text/plain"], + "Content-Disposition": ['attachment; filename="test.txt"'], + "Content-Transfer-Encoding": ["quoted-printable"], + }, + "ContentRef": "Attachment1", + }, + { + "Headers": { + "Content-Type": ['image/png; name="image.png"'], + "Content-Disposition": ['inline; filename="image.png"'], + "Content-Transfer-Encoding": ["base64"], + "Content-ID": [""], + }, + "ContentRef": "InlineAttachment1", + }, + { + "Headers": { + "Content-Type": ['message/rfc822; charset="US-ASCII"'], + "Content-Disposition": ["attachment"], + }, + "ContentRef": "Attachment2", + }, + ], "Text-part": "Test body plain", "Html-part": "
Test body html
", - "InlineAttachment1": b64encode(image_content).decode('ascii'), - "Attachment1": b64encode('test attachment'.encode('utf-8')).decode('ascii'), - "Attachment2": b64encode(email_content).decode('ascii'), + "InlineAttachment1": b64encode(image_content).decode("ascii"), + "Attachment1": b64encode("test attachment".encode("utf-8")).decode("ascii"), + "Attachment2": b64encode(email_content).decode("ascii"), } - response = self.client.post('/anymail/mailjet/inbound/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/mailjet/inbound/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.inbound_handler, sender=MailjetInboundWebhookView, - event=ANY, esp_name='Mailjet') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.inbound_handler, + sender=MailjetInboundWebhookView, + event=ANY, + esp_name="Mailjet", + ) + event = kwargs["event"] message = event.message attachments = message.attachments # AnymailInboundMessage convenience accessor self.assertEqual(len(attachments), 2) - self.assertEqual(attachments[0].get_filename(), 'test.txt') - self.assertEqual(attachments[0].get_content_type(), 'text/plain') - self.assertEqual(attachments[0].get_content_text(), 'test attachment') - self.assertEqual(attachments[1].get_content_type(), 'message/rfc822') - self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content) + self.assertEqual(attachments[0].get_filename(), "test.txt") + self.assertEqual(attachments[0].get_content_type(), "text/plain") + self.assertEqual(attachments[0].get_content_text(), "test attachment") + self.assertEqual(attachments[1].get_content_type(), "message/rfc822") + self.assertEqualIgnoringHeaderFolding( + attachments[1].get_content_bytes(), email_content + ) inlines = message.inline_attachments self.assertEqual(len(inlines), 1) - inline = inlines['abc123'] - self.assertEqual(inline.get_filename(), 'image.png') - self.assertEqual(inline.get_content_type(), 'image/png') + inline = inlines["abc123"] + self.assertEqual(inline.get_filename(), "image.png") + self.assertEqual(inline.get_content_type(), "image/png") self.assertEqual(inline.get_content_bytes(), image_content) diff --git a/tests/test_mailjet_integration.py b/tests/test_mailjet_integration.py index 8eaf58e..3ab7a0c 100644 --- a/tests/test_mailjet_integration.py +++ b/tests/test_mailjet_integration.py @@ -9,28 +9,38 @@ from anymail.message import AnymailMessage from .utils import AnymailTestMixin, sample_image_path -ANYMAIL_TEST_MAILJET_API_KEY = os.getenv('ANYMAIL_TEST_MAILJET_API_KEY') -ANYMAIL_TEST_MAILJET_SECRET_KEY = os.getenv('ANYMAIL_TEST_MAILJET_SECRET_KEY') -ANYMAIL_TEST_MAILJET_DOMAIN = os.getenv('ANYMAIL_TEST_MAILJET_DOMAIN') +ANYMAIL_TEST_MAILJET_API_KEY = os.getenv("ANYMAIL_TEST_MAILJET_API_KEY") +ANYMAIL_TEST_MAILJET_SECRET_KEY = os.getenv("ANYMAIL_TEST_MAILJET_SECRET_KEY") +ANYMAIL_TEST_MAILJET_DOMAIN = os.getenv("ANYMAIL_TEST_MAILJET_DOMAIN") -@tag('mailjet', 'live') +@tag("mailjet", "live") @unittest.skipUnless( - ANYMAIL_TEST_MAILJET_API_KEY and ANYMAIL_TEST_MAILJET_SECRET_KEY and ANYMAIL_TEST_MAILJET_DOMAIN, - "Set ANYMAIL_TEST_MAILJET_API_KEY and ANYMAIL_TEST_MAILJET_SECRET_KEY and ANYMAIL_TEST_MAILJET_DOMAIN " - "environment variables to run Mailjet integration tests") -@override_settings(ANYMAIL={"MAILJET_API_KEY": ANYMAIL_TEST_MAILJET_API_KEY, - "MAILJET_SECRET_KEY": ANYMAIL_TEST_MAILJET_SECRET_KEY, - "MAILJET_SEND_DEFAULTS": {"esp_extra": {"SandboxMode": True}}, # don't actually send mail - }, - EMAIL_BACKEND="anymail.backends.mailjet.EmailBackend") + ANYMAIL_TEST_MAILJET_API_KEY + and ANYMAIL_TEST_MAILJET_SECRET_KEY + and ANYMAIL_TEST_MAILJET_DOMAIN, + "Set ANYMAIL_TEST_MAILJET_API_KEY and ANYMAIL_TEST_MAILJET_SECRET_KEY" + " and ANYMAIL_TEST_MAILJET_DOMAIN environment variables to run Mailjet" + " integration tests", +) +@override_settings( + ANYMAIL={ + "MAILJET_API_KEY": ANYMAIL_TEST_MAILJET_API_KEY, + "MAILJET_SECRET_KEY": ANYMAIL_TEST_MAILJET_SECRET_KEY, + "MAILJET_SEND_DEFAULTS": { + "esp_extra": {"SandboxMode": True} # don't actually send mail + }, + }, + EMAIL_BACKEND="anymail.backends.mailjet.EmailBackend", +) class MailjetBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): - """Mailjet API integration tests + """ + Mailjet API integration tests - These tests run against the **live** Mailjet API, using the - environment variables `ANYMAIL_TEST_MAILJET_API_KEY` and `ANYMAIL_TEST_MAILJET_SECRET_KEY` - as the API key and API secret key, respectively, and `ANYMAIL_TEST_MAILJET_DOMAIN` as - a validated Mailjet sending domain. If those variables are not set, these tests won't run. + These tests run against the **live** Mailjet API, using the environment variables + `ANYMAIL_TEST_MAILJET_API_KEY` and `ANYMAIL_TEST_MAILJET_SECRET_KEY` as the API key + and API secret key, respectively, and `ANYMAIL_TEST_MAILJET_DOMAIN` as a validated + Mailjet sending domain. If those variables are not set, these tests won't run. These tests enable Mailjet's SandboxMode to avoid sending any email; remove the esp_extra setting above if you are trying to actually send test messages. @@ -38,10 +48,14 @@ class MailjetBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): def setUp(self): super().setUp() - self.from_email = 'test@%s' % ANYMAIL_TEST_MAILJET_DOMAIN - self.message = AnymailMessage('Anymail Mailjet integration test', 'Text content', - self.from_email, ['test+to1@anymail.dev']) - self.message.attach_alternative('

HTML content

', "text/html") + self.from_email = "test@%s" % ANYMAIL_TEST_MAILJET_DOMAIN + self.message = AnymailMessage( + "Anymail Mailjet integration test", + "Text content", + self.from_email, + ["test+to1@anymail.dev"], + ) + self.message.attach_alternative("

HTML content

", "text/html") def test_simple_send(self): # Example of getting the Mailjet send status and message id from the message @@ -49,12 +63,13 @@ class MailjetBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): self.assertEqual(sent_count, 1) anymail_status = self.message.anymail_status - sent_status = anymail_status.recipients['test+to1@anymail.dev'].status - message_id = anymail_status.recipients['test+to1@anymail.dev'].message_id + sent_status = anymail_status.recipients["test+to1@anymail.dev"].status + message_id = anymail_status.recipients["test+to1@anymail.dev"].message_id - self.assertEqual(sent_status, 'sent') - self.assertRegex(message_id, r'.+') - self.assertEqual(anymail_status.status, {sent_status}) # set of all recipient statuses + self.assertEqual(sent_status, "sent") + self.assertRegex(message_id, r".+") + # set of all recipient statuses: + self.assertEqual(anymail_status.status, {sent_status}) self.assertEqual(anymail_status.message_id, message_id) def test_all_options(self): @@ -62,12 +77,12 @@ class MailjetBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): subject="Anymail Mailjet all-options integration test", body="This is the text body", from_email=formataddr(("Test Sender, Inc.", self.from_email)), - to=['test+to1@anymail.dev', '"Recipient, 2nd" '], - cc=['test+cc1@anymail.dev', 'Copy 2 '], - bcc=['test+bcc1@anymail.dev', 'Blind Copy 2 '], - reply_to=['"Reply, To" '], # Mailjet only supports single reply_to + to=["test+to1@anymail.dev", '"Recipient, 2nd" '], + cc=["test+cc1@anymail.dev", "Copy 2 "], + bcc=["test+bcc1@anymail.dev", "Blind Copy 2 "], + # Mailjet only supports single reply_to: + reply_to=['"Reply, To" '], headers={"X-Anymail-Test": "value"}, - metadata={"meta1": "simple string", "meta2": 2}, tags=["tag 1"], # Mailjet only allows a single tag track_clicks=True, @@ -79,51 +94,56 @@ class MailjetBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): message.attach_alternative( "

HTML: with link" "and image: " % cid, - "text/html") + "text/html", + ) message.send() - self.assertEqual(message.anymail_status.status, {'sent'}) + self.assertEqual(message.anymail_status.status, {"sent"}) def test_merge_data(self): message = AnymailMessage( - subject="Anymail Mailjet merge_data test", # Mailjet doesn't support merge fields in the subject + # Mailjet doesn't support merge fields in the subject + subject="Anymail Mailjet merge_data test", body="This body includes merge data: [[var:value]]\n" - "And global merge data: [[var:global]]", + "And global merge data: [[var:global]]", from_email=formataddr(("Test From", self.from_email)), to=["test+to1@anymail.dev", "Recipient 2 "], merge_data={ - 'test+to1@anymail.dev': {'value': 'one'}, - 'test+to2@anymail.dev': {'value': 'two'}, - }, - merge_global_data={ - 'global': 'global_value' + "test+to1@anymail.dev": {"value": "one"}, + "test+to2@anymail.dev": {"value": "two"}, }, + merge_global_data={"global": "global_value"}, ) message.send() recipient_status = message.anymail_status.recipients - self.assertEqual(recipient_status['test+to1@anymail.dev'].status, 'sent') - self.assertEqual(recipient_status['test+to2@anymail.dev'].status, 'sent') + self.assertEqual(recipient_status["test+to1@anymail.dev"].status, "sent") + self.assertEqual(recipient_status["test+to2@anymail.dev"].status, "sent") def test_stored_template(self): message = AnymailMessage( - template_id='176375', # ID of the real template named 'test-template' in our Mailjet test account + # ID of the real template named 'test-template' in our Mailjet test account: + template_id="176375", to=["test+to1@anymail.dev"], merge_data={ - 'test+to1@anymail.dev': { - 'name': "Test Recipient", + "test+to1@anymail.dev": { + "name": "Test Recipient", } }, merge_global_data={ - 'order': '12345', + "order": "12345", }, ) message.from_email = None # use the template's sender email/name message.send() recipient_status = message.anymail_status.recipients - self.assertEqual(recipient_status['test+to1@anymail.dev'].status, 'sent') + self.assertEqual(recipient_status["test+to1@anymail.dev"].status, "sent") - @override_settings(ANYMAIL={"MAILJET_API_KEY": "Hey, that's not an API key!", - "MAILJET_SECRET_KEY": "and this isn't the secret for it"}) + @override_settings( + ANYMAIL={ + "MAILJET_API_KEY": "Hey, that's not an API key!", + "MAILJET_SECRET_KEY": "and this isn't the secret for it", + } + ) def test_invalid_api_key(self): with self.assertRaises(AnymailAPIError) as cm: self.message.send() diff --git a/tests/test_mailjet_webhooks.py b/tests/test_mailjet_webhooks.py index ecf4ba2..f6c3138 100644 --- a/tests/test_mailjet_webhooks.py +++ b/tests/test_mailjet_webhooks.py @@ -6,126 +6,178 @@ from django.test import tag from anymail.signals import AnymailTrackingEvent from anymail.webhooks.mailjet import MailjetTrackingWebhookView + from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase -@tag('mailjet') +@tag("mailjet") class MailjetWebhookSecurityTestCase(WebhookBasicAuthTestCase): def call_webhook(self): - return self.client.post('/anymail/mailjet/tracking/', - content_type='application/json', data=json.dumps([])) + return self.client.post( + "/anymail/mailjet/tracking/", + content_type="application/json", + data=json.dumps([]), + ) # Actual tests are in WebhookBasicAuthTestCase -@tag('mailjet') +@tag("mailjet") class MailjetDeliveryTestCase(WebhookTestCase): - def test_sent_event(self): - # Mailjet's "sent" event indicates receiving MTA has accepted message; Anymail calls this "delivered" - raw_events = [{ - "event": "sent", - "time": 1498093527, - "MessageID": 12345678901234567, - "email": "recipient@example.com", - "mj_campaign_id": 1234567890, - "mj_contact_id": 9876543210, - "customcampaign": "tag1", - "mj_message_id": "12345678901234567", - "smtp_reply": "sent (250 2.0.0 OK 1498093527 a67bc12345def.22 - gsmtp)", - "Payload": "{\"meta1\": \"simple string\", \"meta2\": 2}", - }] - response = self.client.post('/anymail/mailjet/tracking/', - content_type='application/json', data=json.dumps(raw_events)) + # Mailjet's "sent" event indicates receiving MTA has accepted message; + # Anymail calls this "delivered" + raw_events = [ + { + "event": "sent", + "time": 1498093527, + "MessageID": 12345678901234567, + "email": "recipient@example.com", + "mj_campaign_id": 1234567890, + "mj_contact_id": 9876543210, + "customcampaign": "tag1", + "mj_message_id": "12345678901234567", + "smtp_reply": "sent (250 2.0.0 OK 1498093527 a67bc12345def.22 - gsmtp)", + "Payload": '{"meta1": "simple string", "meta2": 2}', + } + ] + response = self.client.post( + "/anymail/mailjet/tracking/", + content_type="application/json", + data=json.dumps(raw_events), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailjetTrackingWebhookView, - event=ANY, esp_name='Mailjet') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailjetTrackingWebhookView, + event=ANY, + esp_name="Mailjet", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "delivered") - self.assertEqual(event.timestamp, datetime(2017, 6, 22, 1, 5, 27, tzinfo=timezone.utc)) + self.assertEqual( + event.timestamp, datetime(2017, 6, 22, 1, 5, 27, tzinfo=timezone.utc) + ) self.assertEqual(event.esp_event, raw_events[0]) - self.assertEqual(event.mta_response, "sent (250 2.0.0 OK 1498093527 a67bc12345def.22 - gsmtp)") - self.assertEqual(event.message_id, "12345678901234567") # converted to str (matching backend status) + self.assertEqual( + event.mta_response, + "sent (250 2.0.0 OK 1498093527 a67bc12345def.22 - gsmtp)", + ) + # message_id converted to str (matching backend status): + self.assertEqual(event.message_id, "12345678901234567") self.assertEqual(event.recipient, "recipient@example.com") self.assertEqual(event.tags, ["tag1"]) self.assertEqual(event.metadata, {"meta1": "simple string", "meta2": 2}) def test_open_event(self): - raw_events = [{ - "event": "open", - "time": 1498093527, - "MessageID": 12345678901234567, - "email": "recipient@example.com", - "mj_campaign_id": 1234567890, - "mj_contact_id": 9876543210, - "customcampaign": "", - "ip": "192.168.100.100", - "geo": "US", - "agent": "Mozilla/5.0 (via ggpht.com GoogleImageProxy)", - "Payload": "", - }] - response = self.client.post('/anymail/mailjet/tracking/', - content_type='application/json', data=json.dumps(raw_events)) + raw_events = [ + { + "event": "open", + "time": 1498093527, + "MessageID": 12345678901234567, + "email": "recipient@example.com", + "mj_campaign_id": 1234567890, + "mj_contact_id": 9876543210, + "customcampaign": "", + "ip": "192.168.100.100", + "geo": "US", + "agent": "Mozilla/5.0 (via ggpht.com GoogleImageProxy)", + "Payload": "", + } + ] + response = self.client.post( + "/anymail/mailjet/tracking/", + content_type="application/json", + data=json.dumps(raw_events), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailjetTrackingWebhookView, - event=ANY, esp_name='Mailjet') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailjetTrackingWebhookView, + event=ANY, + esp_name="Mailjet", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "opened") self.assertEqual(event.message_id, "12345678901234567") self.assertEqual(event.recipient, "recipient@example.com") - self.assertEqual(event.user_agent, "Mozilla/5.0 (via ggpht.com GoogleImageProxy)") + self.assertEqual( + event.user_agent, "Mozilla/5.0 (via ggpht.com GoogleImageProxy)" + ) self.assertEqual(event.tags, []) self.assertEqual(event.metadata, {}) def test_click_event(self): - raw_events = [{ - "event": "open", - "time": 1498093527, - "MessageID": 12345678901234567, - "email": "recipient@example.com", - "mj_campaign_id": 1234567890, - "mj_contact_id": 9876543210, - "customcampaign": "", - "url": "http://example.com", - "ip": "192.168.100.100", - "geo": "US", - "agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_5) Chrome/58.0.3029.110", - }] - response = self.client.post('/anymail/mailjet/tracking/', - content_type='application/json', data=json.dumps(raw_events)) + raw_events = [ + { + "event": "open", + "time": 1498093527, + "MessageID": 12345678901234567, + "email": "recipient@example.com", + "mj_campaign_id": 1234567890, + "mj_contact_id": 9876543210, + "customcampaign": "", + "url": "http://example.com", + "ip": "192.168.100.100", + "geo": "US", + "agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_5)" + " Chrome/58.0.3029.110", + } + ] + response = self.client.post( + "/anymail/mailjet/tracking/", + content_type="application/json", + data=json.dumps(raw_events), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailjetTrackingWebhookView, - event=ANY, esp_name='Mailjet') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailjetTrackingWebhookView, + event=ANY, + esp_name="Mailjet", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "opened") self.assertEqual(event.message_id, "12345678901234567") self.assertEqual(event.recipient, "recipient@example.com") - self.assertEqual(event.user_agent, "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_5) Chrome/58.0.3029.110") + self.assertEqual( + event.user_agent, + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_5) Chrome/58.0.3029.110", + ) self.assertEqual(event.click_url, "http://example.com") self.assertEqual(event.tags, []) self.assertEqual(event.metadata, {}) def test_bounce_event(self): - raw_events = [{ - "event": "bounce", - "time": 1498093527, - "MessageID": 12345678901234567, - "email": "invalid@invalid", - "mj_campaign_id": 1234567890, - "mj_contact_id": 9876543210, - "customcampaign": "", - "blocked": True, - "hard_bounce": True, - "error_related_to": "domain", - "error": "invalid domain" - }] - response = self.client.post('/anymail/mailjet/tracking/', - content_type='application/json', data=json.dumps(raw_events)) + raw_events = [ + { + "event": "bounce", + "time": 1498093527, + "MessageID": 12345678901234567, + "email": "invalid@invalid", + "mj_campaign_id": 1234567890, + "mj_contact_id": 9876543210, + "customcampaign": "", + "blocked": True, + "hard_bounce": True, + "error_related_to": "domain", + "error": "invalid domain", + } + ] + response = self.client.post( + "/anymail/mailjet/tracking/", + content_type="application/json", + data=json.dumps(raw_events), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailjetTrackingWebhookView, - event=ANY, esp_name='Mailjet') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailjetTrackingWebhookView, + event=ANY, + esp_name="Mailjet", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "bounced") self.assertEqual(event.message_id, "12345678901234567") self.assertEqual(event.recipient, "invalid@invalid") @@ -133,23 +185,32 @@ class MailjetDeliveryTestCase(WebhookTestCase): self.assertEqual(event.mta_response, None) def test_blocked_event(self): - raw_events = [{ - "event": "blocked", - "time": 1498093527, - "MessageID": 12345678901234567, - "email": "bad@example.com", - "mj_campaign_id": 0, - "mj_contact_id": 9876543210, - "customcampaign": "", - "error_related_to": "domain", - "error": "typofix", - }] - response = self.client.post('/anymail/mailjet/tracking/', - content_type='application/json', data=json.dumps(raw_events)) + raw_events = [ + { + "event": "blocked", + "time": 1498093527, + "MessageID": 12345678901234567, + "email": "bad@example.com", + "mj_campaign_id": 0, + "mj_contact_id": 9876543210, + "customcampaign": "", + "error_related_to": "domain", + "error": "typofix", + } + ] + response = self.client.post( + "/anymail/mailjet/tracking/", + content_type="application/json", + data=json.dumps(raw_events), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailjetTrackingWebhookView, - event=ANY, esp_name='Mailjet') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailjetTrackingWebhookView, + event=ANY, + esp_name="Mailjet", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "rejected") self.assertEqual(event.message_id, "12345678901234567") self.assertEqual(event.recipient, "bad@example.com") @@ -157,79 +218,106 @@ class MailjetDeliveryTestCase(WebhookTestCase): self.assertEqual(event.mta_response, None) def test_spam_event(self): - raw_events = [{ - "event": "spam", - "time": 1498093527, - "MessageID": 12345678901234567, - "email": "spam@example.com", - "mj_campaign_id": 1234567890, - "mj_contact_id": 9876543210, - "customcampaign": "", - "source": "greylisted" - }] - response = self.client.post('/anymail/mailjet/tracking/', - content_type='application/json', data=json.dumps(raw_events)) + raw_events = [ + { + "event": "spam", + "time": 1498093527, + "MessageID": 12345678901234567, + "email": "spam@example.com", + "mj_campaign_id": 1234567890, + "mj_contact_id": 9876543210, + "customcampaign": "", + "source": "greylisted", + } + ] + response = self.client.post( + "/anymail/mailjet/tracking/", + content_type="application/json", + data=json.dumps(raw_events), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailjetTrackingWebhookView, - event=ANY, esp_name='Mailjet') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailjetTrackingWebhookView, + event=ANY, + esp_name="Mailjet", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "complained") self.assertEqual(event.message_id, "12345678901234567") self.assertEqual(event.recipient, "spam@example.com") def test_unsub_event(self): - raw_events = [{ - "event": "unsub", - "time": 1498093527, - "MessageID": 12345678901234567, - "email": "recipient@example.com", - "mj_campaign_id": 1234567890, - "mj_contact_id": 9876543210, - "customcampaign": "", - "mj_list_id": 0, - "ip": "127.0.0.4", - "geo": "", - "agent": "List-Unsubscribe" - }] - response = self.client.post('/anymail/mailjet/tracking/', - content_type='application/json', data=json.dumps(raw_events)) + raw_events = [ + { + "event": "unsub", + "time": 1498093527, + "MessageID": 12345678901234567, + "email": "recipient@example.com", + "mj_campaign_id": 1234567890, + "mj_contact_id": 9876543210, + "customcampaign": "", + "mj_list_id": 0, + "ip": "127.0.0.4", + "geo": "", + "agent": "List-Unsubscribe", + } + ] + response = self.client.post( + "/anymail/mailjet/tracking/", + content_type="application/json", + data=json.dumps(raw_events), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailjetTrackingWebhookView, - event=ANY, esp_name='Mailjet') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailjetTrackingWebhookView, + event=ANY, + esp_name="Mailjet", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "unsubscribed") self.assertEqual(event.message_id, "12345678901234567") self.assertEqual(event.recipient, "recipient@example.com") def test_bounced_greylist_event(self): # greylist "bounce" should be reported as "deferred" (will be retried later) - raw_events = [{ - "event": "bounce", - "time": 1498093527, - "MessageID": 12345678901234567, - "email": "protected@example.com", - "mj_campaign_id": 1234567890, - "mj_contact_id": 9876543210, - "customcampaign": "", - "blocked": True, - "hard_bounce": False, - "error_related_to": "domain", - "error": "greylisted" - }] - response = self.client.post('/anymail/mailjet/tracking/', - content_type='application/json', data=json.dumps(raw_events)) + raw_events = [ + { + "event": "bounce", + "time": 1498093527, + "MessageID": 12345678901234567, + "email": "protected@example.com", + "mj_campaign_id": 1234567890, + "mj_contact_id": 9876543210, + "customcampaign": "", + "blocked": True, + "hard_bounce": False, + "error_related_to": "domain", + "error": "greylisted", + } + ] + response = self.client.post( + "/anymail/mailjet/tracking/", + content_type="application/json", + data=json.dumps(raw_events), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailjetTrackingWebhookView, - event=ANY, esp_name='Mailjet') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailjetTrackingWebhookView, + event=ANY, + esp_name="Mailjet", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "deferred") self.assertEqual(event.message_id, "12345678901234567") self.assertEqual(event.recipient, "protected@example.com") self.assertEqual(event.reject_reason, "other") def test_non_grouped_event(self): - # If you don't enable "group events" on a webhook, Mailjet sends a single bare event - # (not a list of one event, despite what the docs say). + # If you don't enable "group events" on a webhook, Mailjet sends a single bare + # event (not a list of one event, despite what the docs say). raw_event = { "event": "sent", "time": 1498093527, @@ -242,16 +330,29 @@ class MailjetDeliveryTestCase(WebhookTestCase): "smtp_reply": "sent (250 2.0.0 OK 1498093527 a67bc12345def.22 - gsmtp)", "Payload": "", } - response = self.client.post('/anymail/mailjet/tracking/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/mailjet/tracking/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MailjetTrackingWebhookView, - event=ANY, esp_name='Mailjet') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MailjetTrackingWebhookView, + event=ANY, + esp_name="Mailjet", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "delivered") - self.assertEqual(event.timestamp, datetime(2017, 6, 22, 1, 5, 27, tzinfo=timezone.utc)) + self.assertEqual( + event.timestamp, datetime(2017, 6, 22, 1, 5, 27, tzinfo=timezone.utc) + ) self.assertEqual(event.esp_event, raw_event) - self.assertEqual(event.mta_response, "sent (250 2.0.0 OK 1498093527 a67bc12345def.22 - gsmtp)") - self.assertEqual(event.message_id, "12345678901234567") # converted to str (matching backend status) + self.assertEqual( + event.mta_response, + "sent (250 2.0.0 OK 1498093527 a67bc12345def.22 - gsmtp)", + ) + # message_id converted to str (matching backend status) + self.assertEqual(event.message_id, "12345678901234567") self.assertEqual(event.recipient, "recipient@example.com") diff --git a/tests/test_mandrill_backend.py b/tests/test_mandrill_backend.py index dc660b2..fb9aa8e 100644 --- a/tests/test_mandrill_backend.py +++ b/tests/test_mandrill_backend.py @@ -6,19 +6,37 @@ from email.mime.image import MIMEImage from django.core import mail from django.core.exceptions import ImproperlyConfigured from django.test import SimpleTestCase, override_settings, tag -from django.utils.timezone import get_fixed_timezone, override as override_current_timezone +from django.utils.timezone import ( + get_fixed_timezone, + override as override_current_timezone, +) -from anymail.exceptions import (AnymailAPIError, AnymailRecipientsRefused, - AnymailSerializationError, AnymailUnsupportedFeature) +from anymail.exceptions import ( + AnymailAPIError, + AnymailRecipientsRefused, + AnymailSerializationError, + AnymailUnsupportedFeature, +) from anymail.message import attach_inline_image -from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCases -from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin, decode_att +from .mock_requests_backend import ( + RequestsBackendMockAPITestCase, + SessionSharingTestCases, +) +from .utils import ( + SAMPLE_IMAGE_FILENAME, + AnymailTestMixin, + decode_att, + sample_image_content, + sample_image_path, +) -@tag('mandrill') -@override_settings(EMAIL_BACKEND='anymail.backends.mandrill.EmailBackend', - ANYMAIL={'MANDRILL_API_KEY': 'test_api_key'}) +@tag("mandrill") +@override_settings( + EMAIL_BACKEND="anymail.backends.mandrill.EmailBackend", + ANYMAIL={"MANDRILL_API_KEY": "test_api_key"}, +) class MandrillBackendMockAPITestCase(RequestsBackendMockAPITestCase): DEFAULT_RAW_RESPONSE = b"""[{ "email": "to@example.com", @@ -30,24 +48,33 @@ class MandrillBackendMockAPITestCase(RequestsBackendMockAPITestCase): def setUp(self): super().setUp() # Simple message useful for many tests - self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com']) + self.message = mail.EmailMultiAlternatives( + "Subject", "Text Body", "from@example.com", ["to@example.com"] + ) -@tag('mandrill') +@tag("mandrill") class MandrillBackendStandardEmailTests(MandrillBackendMockAPITestCase): """Test backend support for Django mail wrappers""" def test_send_mail(self): - mail.send_mail('Subject here', 'Here is the message.', - 'from@example.com', ['to@example.com'], fail_silently=False) + mail.send_mail( + "Subject here", + "Here is the message.", + "from@example.com", + ["to@example.com"], + fail_silently=False, + ) self.assert_esp_called("/messages/send.json") data = self.get_api_call_json() - self.assertEqual(data['key'], "test_api_key") - self.assertEqual(data['message']['subject'], "Subject here") - self.assertEqual(data['message']['text'], "Here is the message.") - self.assertNotIn('from_name', data['message']) - self.assertEqual(data['message']['from_email'], "from@example.com") - self.assertEqual(data['message']['to'], [{'email': 'to@example.com', 'type': 'to'}]) + self.assertEqual(data["key"], "test_api_key") + self.assertEqual(data["message"]["subject"], "Subject here") + self.assertEqual(data["message"]["text"], "Here is the message.") + self.assertNotIn("from_name", data["message"]) + self.assertEqual(data["message"]["from_email"], "from@example.com") + self.assertEqual( + data["message"]["to"], [{"email": "to@example.com", "type": "to"}] + ) def test_name_addr(self): """Make sure RFC2822 name-addr format (with display-name) is allowed @@ -55,91 +82,119 @@ class MandrillBackendStandardEmailTests(MandrillBackendMockAPITestCase): (Test both sender and recipient addresses) """ msg = mail.EmailMessage( - 'Subject', 'Message', - 'From Name ', - ['Recipient #1 ', 'to2@example.com'], - cc=['Carbon Copy ', 'cc2@example.com'], - bcc=['Blind Copy ', 'bcc2@example.com']) + "Subject", + "Message", + "From Name ", + ["Recipient #1 ", "to2@example.com"], + cc=["Carbon Copy ", "cc2@example.com"], + bcc=["Blind Copy ", "bcc2@example.com"], + ) msg.send() data = self.get_api_call_json() - self.assertEqual(data['message']['from_name'], "From Name") - self.assertEqual(data['message']['from_email'], "from@example.com") - self.assertEqual(data['message']['to'], [ - {'email': 'to1@example.com', 'name': 'Recipient #1', 'type': 'to'}, - {'email': 'to2@example.com', 'type': 'to'}, - {'email': 'cc1@example.com', 'name': 'Carbon Copy', 'type': 'cc'}, - {'email': 'cc2@example.com', 'type': 'cc'}, - {'email': 'bcc1@example.com', 'name': 'Blind Copy', 'type': 'bcc'}, - {'email': 'bcc2@example.com', 'type': 'bcc'}, - ]) + self.assertEqual(data["message"]["from_name"], "From Name") + self.assertEqual(data["message"]["from_email"], "from@example.com") + self.assertEqual( + data["message"]["to"], + [ + {"email": "to1@example.com", "name": "Recipient #1", "type": "to"}, + {"email": "to2@example.com", "type": "to"}, + {"email": "cc1@example.com", "name": "Carbon Copy", "type": "cc"}, + {"email": "cc2@example.com", "type": "cc"}, + {"email": "bcc1@example.com", "name": "Blind Copy", "type": "bcc"}, + {"email": "bcc2@example.com", "type": "bcc"}, + ], + ) def test_email_message(self): email = mail.EmailMessage( - 'Subject', 'Body goes here', - 'from@example.com', - ['to1@example.com', 'Also To '], - bcc=['bcc1@example.com', 'Also BCC '], - cc=['cc1@example.com', 'Also CC '], - headers={'Reply-To': 'another@example.com', - 'X-MyHeader': 'my value', - 'Message-ID': 'mycustommsgid@example.com'}) + "Subject", + "Body goes here", + "from@example.com", + ["to1@example.com", "Also To "], + bcc=["bcc1@example.com", "Also BCC "], + cc=["cc1@example.com", "Also CC "], + headers={ + "Reply-To": "another@example.com", + "X-MyHeader": "my value", + "Message-ID": "mycustommsgid@example.com", + }, + ) email.send() data = self.get_api_call_json() - self.assertEqual(data['message']['subject'], "Subject") - self.assertEqual(data['message']['text'], "Body goes here") - self.assertEqual(data['message']['from_email'], "from@example.com") - self.assertEqual(data['message']['headers'], - {'Reply-To': 'another@example.com', - 'X-MyHeader': 'my value', - 'Message-ID': 'mycustommsgid@example.com'}) + self.assertEqual(data["message"]["subject"], "Subject") + self.assertEqual(data["message"]["text"], "Body goes here") + self.assertEqual(data["message"]["from_email"], "from@example.com") + self.assertEqual( + data["message"]["headers"], + { + "Reply-To": "another@example.com", + "X-MyHeader": "my value", + "Message-ID": "mycustommsgid@example.com", + }, + ) # Verify recipients correctly identified as "to", "cc", or "bcc" - self.assertEqual(data['message']['to'], [ - {'email': 'to1@example.com', 'type': 'to'}, - {'email': 'to2@example.com', 'name': 'Also To', 'type': 'to'}, - {'email': 'cc1@example.com', 'type': 'cc'}, - {'email': 'cc2@example.com', 'name': 'Also CC', 'type': 'cc'}, - {'email': 'bcc1@example.com', 'type': 'bcc'}, - {'email': 'bcc2@example.com', 'name': 'Also BCC', 'type': 'bcc'}, - ]) + self.assertEqual( + data["message"]["to"], + [ + {"email": "to1@example.com", "type": "to"}, + {"email": "to2@example.com", "name": "Also To", "type": "to"}, + {"email": "cc1@example.com", "type": "cc"}, + {"email": "cc2@example.com", "name": "Also CC", "type": "cc"}, + {"email": "bcc1@example.com", "type": "bcc"}, + {"email": "bcc2@example.com", "name": "Also BCC", "type": "bcc"}, + ], + ) # Don't use Mandrill's bcc_address "logging" feature for bcc's: - self.assertNotIn('bcc_address', data['message']) + self.assertNotIn("bcc_address", data["message"]) def test_html_message(self): - text_content = 'This is an important message.' - html_content = '

This is an important message.

' - email = mail.EmailMultiAlternatives('Subject', text_content, - 'from@example.com', ['to@example.com']) + text_content = "This is an important message." + html_content = "

This is an important message.

" + email = mail.EmailMultiAlternatives( + "Subject", text_content, "from@example.com", ["to@example.com"] + ) email.attach_alternative(html_content, "text/html") email.send() data = self.get_api_call_json() - self.assertEqual(data['message']['text'], text_content) - self.assertEqual(data['message']['html'], html_content) + self.assertEqual(data["message"]["text"], text_content) + self.assertEqual(data["message"]["html"], html_content) # Don't accidentally send the html part as an attachment: - self.assertFalse('attachments' in data['message']) + self.assertFalse("attachments" in data["message"]) def test_html_only_message(self): - html_content = '

This is an important message.

' - email = mail.EmailMessage('Subject', html_content, - 'from@example.com', ['to@example.com']) + html_content = "

This is an important message.

" + email = mail.EmailMessage( + "Subject", html_content, "from@example.com", ["to@example.com"] + ) email.content_subtype = "html" # Main content is now text/html email.send() data = self.get_api_call_json() - self.assertNotIn('text', data['message']) - self.assertEqual(data['message']['html'], html_content) + self.assertNotIn("text", data["message"]) + self.assertEqual(data["message"]["html"], html_content) def test_reply_to(self): - email = mail.EmailMessage('Subject', 'Body goes here', 'from@example.com', ['to1@example.com'], - reply_to=['reply@example.com', 'Other '], - headers={'X-Other': 'Keep'}) + email = mail.EmailMessage( + "Subject", + "Body goes here", + "from@example.com", + ["to1@example.com"], + reply_to=["reply@example.com", "Other "], + headers={"X-Other": "Keep"}, + ) email.send() data = self.get_api_call_json() - self.assertEqual(data['message']['headers']['Reply-To'], - 'reply@example.com, Other ') - self.assertEqual(data['message']['headers']['X-Other'], 'Keep') # don't lose other headers + self.assertEqual( + data["message"]["headers"]["Reply-To"], + "reply@example.com, Other ", + ) + # don't lose other headers: + self.assertEqual(data["message"]["headers"]["X-Other"], "Keep") def test_attachments(self): text_content = "* Item one\n* Item two\n* Item three" - self.message.attach(filename="test.txt", content=text_content, mimetype="text/plain") + self.message.attach( + filename="test.txt", content=text_content, mimetype="text/plain" + ) # Should guess mimetype if not provided... png_content = b"PNG\xb4 pretend this is the contents of a png file" @@ -147,17 +202,19 @@ class MandrillBackendStandardEmailTests(MandrillBackendMockAPITestCase): # Should work with a MIMEBase object (also tests no filename)... pdf_content = b"PDF\xb4 pretend this is valid pdf data" - mimeattachment = MIMEBase('application', 'pdf') + mimeattachment = MIMEBase("application", "pdf") mimeattachment.set_payload(pdf_content) self.message.attach(mimeattachment) self.message.send() data = self.get_api_call_json() - attachments = data['message']['attachments'] + attachments = data["message"]["attachments"] self.assertEqual(len(attachments), 3) self.assertEqual(attachments[0]["type"], "text/plain") self.assertEqual(attachments[0]["name"], "test.txt") - self.assertEqual(decode_att(attachments[0]["content"]).decode('ascii'), text_content) + self.assertEqual( + decode_att(attachments[0]["content"]).decode("ascii"), text_content + ) self.assertEqual(attachments[1]["type"], "image/png") # inferred from filename self.assertEqual(attachments[1]["name"], "test.png") self.assertEqual(decode_att(attachments[1]["content"]), png_content) @@ -165,44 +222,52 @@ class MandrillBackendStandardEmailTests(MandrillBackendMockAPITestCase): self.assertEqual(attachments[2]["name"], "") # none self.assertEqual(decode_att(attachments[2]["content"]), pdf_content) # Make sure the image attachment is not treated as embedded: - self.assertFalse('images' in data['message']) + self.assertFalse("images" in data["message"]) def test_unicode_attachment_correctly_decoded(self): - self.message.attach("Une pièce jointe.html", '

\u2019

', mimetype='text/html') + self.message.attach( + "Une pièce jointe.html", "

\u2019

", mimetype="text/html" + ) self.message.send() data = self.get_api_call_json() - attachments = data['message']['attachments'] + attachments = data["message"]["attachments"] self.assertEqual(len(attachments), 1) def test_embedded_images(self): image_data = sample_image_content() # Read from a png file cid = attach_inline_image(self.message, image_data) - html_content = '

This has an inline image.

' % cid + html_content = ( + '

This has an inline image.

' % cid + ) self.message.attach_alternative(html_content, "text/html") self.message.send() data = self.get_api_call_json() - self.assertEqual(len(data['message']['images']), 1) - self.assertEqual(data['message']['images'][0]["type"], "image/png") - self.assertEqual(data['message']['images'][0]["name"], cid) - self.assertEqual(decode_att(data['message']['images'][0]["content"]), image_data) + self.assertEqual(len(data["message"]["images"]), 1) + self.assertEqual(data["message"]["images"][0]["type"], "image/png") + self.assertEqual(data["message"]["images"][0]["name"], cid) + self.assertEqual( + decode_att(data["message"]["images"][0]["content"]), image_data + ) # Make sure neither the html nor the inline image is treated as an attachment: - self.assertFalse('attachments' in data['message']) + self.assertFalse("attachments" in data["message"]) def test_attached_images(self): image_filename = SAMPLE_IMAGE_FILENAME image_path = sample_image_path(image_filename) image_data = sample_image_content(image_filename) - self.message.attach_file(image_path) # option 1: attach as a file + # option 1: attach as a file: + self.message.attach_file(image_path) - image = MIMEImage(image_data) # option 2: construct the MIMEImage and attach it directly + # option 2: construct the MIMEImage and attach it directly: + image = MIMEImage(image_data) self.message.attach(image) self.message.send() data = self.get_api_call_json() - attachments = data['message']['attachments'] + attachments = data["message"]["attachments"] self.assertEqual(len(attachments), 2) self.assertEqual(attachments[0]["type"], "image/png") self.assertEqual(attachments[0]["name"], image_filename) @@ -211,7 +276,7 @@ class MandrillBackendStandardEmailTests(MandrillBackendMockAPITestCase): self.assertEqual(attachments[1]["name"], "") # unknown -- not attached as file self.assertEqual(decode_att(attachments[1]["content"]), image_data) # Make sure the image attachments are not treated as embedded: - self.assertFalse('images' in data['message']) + self.assertFalse("images" in data["message"]) def test_multiple_html_alternatives(self): # Multiple alternatives not allowed @@ -236,23 +301,33 @@ class MandrillBackendStandardEmailTests(MandrillBackendMockAPITestCase): def test_api_failure(self): self.set_mock_response(status_code=400) with self.assertRaisesMessage(AnymailAPIError, "Mandrill API response 400"): - mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com']) + mail.send_mail("Subject", "Body", "from@example.com", ["to@example.com"]) # Make sure fail_silently is respected self.set_mock_response(status_code=400) - sent = mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com'], - fail_silently=True) + sent = mail.send_mail( + "Subject", + "Body", + "from@example.com", + ["to@example.com"], + fail_silently=True, + ) self.assertEqual(sent, 0) def test_api_error_includes_details(self): """AnymailAPIError should include ESP's error message""" - self.set_mock_response(status_code=400, raw=b"""{ + self.set_mock_response( + status_code=400, + raw=b"""{ "status": "error", "code": 12, "name": "Error_Name", "message": "Helpful explanation from Mandrill" - }""") - with self.assertRaisesMessage(AnymailAPIError, "Helpful explanation from Mandrill"): + }""", + ) + with self.assertRaisesMessage( + AnymailAPIError, "Helpful explanation from Mandrill" + ): self.message.send() # Non-JSON error response: @@ -266,7 +341,7 @@ class MandrillBackendStandardEmailTests(MandrillBackendMockAPITestCase): self.message.send() -@tag('mandrill') +@tag("mandrill") class MandrillBackendAnymailFeatureTests(MandrillBackendMockAPITestCase): """Test backend support for Anymail added features""" @@ -274,13 +349,13 @@ class MandrillBackendAnymailFeatureTests(MandrillBackendMockAPITestCase): self.message.envelope_sender = "anything@bounces.example.com" self.message.send() data = self.get_api_call_json() - self.assertEqual(data['message']['return_path_domain'], "bounces.example.com") + self.assertEqual(data["message"]["return_path_domain"], "bounces.example.com") def test_metadata(self): - self.message.metadata = {'user_id': "12345", 'items': 6} + self.message.metadata = {"user_id": "12345", "items": 6} self.message.send() data = self.get_api_call_json() - self.assertEqual(data['message']['metadata'], {'user_id': "12345", 'items': 6}) + self.assertEqual(data["message"]["metadata"], {"user_id": "12345", "items": 6}) def test_send_at(self): utc_plus_6 = get_fixed_timezone(6 * 60) @@ -291,37 +366,43 @@ class MandrillBackendAnymailFeatureTests(MandrillBackendMockAPITestCase): self.message.send_at = datetime(2022, 10, 11, 12, 13, 14, 567) self.message.send() data = self.get_api_call_json() - self.assertEqual(data['send_at'], "2022-10-11 06:13:14") # 12:13 UTC+6 == 06:13 UTC + self.assertEqual( + data["send_at"], "2022-10-11 06:13:14" + ) # 12:13 UTC+6 == 06:13 UTC # Timezone-aware datetime converted to UTC: self.message.send_at = datetime(2016, 3, 4, 5, 6, 7, tzinfo=utc_minus_8) self.message.send() data = self.get_api_call_json() - self.assertEqual(data['send_at'], "2016-03-04 13:06:07") # 05:06 UTC-8 == 13:06 UTC + self.assertEqual( + data["send_at"], "2016-03-04 13:06:07" + ) # 05:06 UTC-8 == 13:06 UTC # Date-only treated as midnight in current timezone self.message.send_at = date(2022, 10, 22) self.message.send() data = self.get_api_call_json() - self.assertEqual(data['send_at'], "2022-10-21 18:00:00") # 00:00 UTC+6 == 18:00-1d UTC + self.assertEqual( + data["send_at"], "2022-10-21 18:00:00" + ) # 00:00 UTC+6 == 18:00-1d UTC # POSIX timestamp self.message.send_at = 1651820889 # 2022-05-06 07:08:09 UTC self.message.send() data = self.get_api_call_json() - self.assertEqual(data['send_at'], "2022-05-06 07:08:09") + self.assertEqual(data["send_at"], "2022-05-06 07:08:09") # String passed unchanged (this is *not* portable between ESPs) self.message.send_at = "2013-11-12 01:02:03" self.message.send() data = self.get_api_call_json() - self.assertEqual(data['send_at'], "2013-11-12 01:02:03") + self.assertEqual(data["send_at"], "2013-11-12 01:02:03") def test_tags(self): self.message.tags = ["receipt", "repeat-user"] self.message.send() data = self.get_api_call_json() - self.assertEqual(data['message']['tags'], ["receipt", "repeat-user"]) + self.assertEqual(data["message"]["tags"], ["receipt", "repeat-user"]) def test_tracking(self): # Test one way... @@ -329,73 +410,94 @@ class MandrillBackendAnymailFeatureTests(MandrillBackendMockAPITestCase): self.message.track_clicks = False self.message.send() data = self.get_api_call_json() - self.assertEqual(data['message']['track_opens'], True) - self.assertEqual(data['message']['track_clicks'], False) + self.assertEqual(data["message"]["track_opens"], True) + self.assertEqual(data["message"]["track_clicks"], False) # ...and the opposite way self.message.track_opens = False self.message.track_clicks = True self.message.send() data = self.get_api_call_json() - self.assertEqual(data['message']['track_opens'], False) - self.assertEqual(data['message']['track_clicks'], True) + self.assertEqual(data["message"]["track_opens"], False) + self.assertEqual(data["message"]["track_clicks"], True) def test_template_id(self): self.message.template_id = "welcome_template" self.message.send() data = self.get_api_call_json() - self.assert_esp_called("/messages/send-template.json") # template requires different send API - self.assertEqual(data['template_name'], "welcome_template") - self.assertEqual(data['template_content'], []) # Mandrill requires this field with send-template + # template requires different send API: + self.assert_esp_called("/messages/send-template.json") + self.assertEqual(data["template_name"], "welcome_template") + # Mandrill requires template_content with send-template: + self.assertEqual(data["template_content"], []) def test_merge_data(self): - self.message.to = ['alice@example.com', 'Bob '] + self.message.to = ["alice@example.com", "Bob "] # Mandrill template_id is not required to use merge. # You can just supply template content as the message (e.g.): self.message.body = "Hi *|name|*. Welcome to *|group|* at *|site|*." self.message.merge_data = { - 'alice@example.com': {'name': "Alice", 'group': "Developers"}, - 'bob@example.com': {'name': "Bob"}, # and leave :group undefined + "alice@example.com": {"name": "Alice", "group": "Developers"}, + "bob@example.com": {"name": "Bob"}, # and leave :group undefined } self.message.merge_global_data = { - 'group': "Users", - 'site': "ExampleCo", + "group": "Users", + "site": "ExampleCo", } self.message.send() - self.assert_esp_called("/messages/send.json") # didn't specify template_id, so use normal send + # didn't specify template_id, so use normal send: + self.assert_esp_called("/messages/send.json") data = self.get_api_call_json() - self.assertCountEqual(data['message']['merge_vars'], [ - {'rcpt': "alice@example.com", 'vars': [ - {'name': "group", 'content': "Developers"}, - {'name': "name", 'content': "Alice"} - ]}, - {'rcpt': "bob@example.com", 'vars': [ - {'name': "name", 'content': "Bob"} - ]}, - ]) - self.assertCountEqual(data['message']['global_merge_vars'], [ - {'name': "group", 'content': "Users"}, - {'name': "site", 'content': "ExampleCo"}, - ]) - self.assertIs(data['message']['preserve_recipients'], False) # merge_data implies batch + self.assertCountEqual( + data["message"]["merge_vars"], + [ + { + "rcpt": "alice@example.com", + "vars": [ + {"name": "group", "content": "Developers"}, + {"name": "name", "content": "Alice"}, + ], + }, + { + "rcpt": "bob@example.com", + "vars": [{"name": "name", "content": "Bob"}], + }, + ], + ) + self.assertCountEqual( + data["message"]["global_merge_vars"], + [ + {"name": "group", "content": "Users"}, + {"name": "site", "content": "ExampleCo"}, + ], + ) + # merge_data implies batch: + self.assertIs(data["message"]["preserve_recipients"], False) def test_merge_metadata(self): - self.message.to = ['alice@example.com', 'Bob '] + self.message.to = ["alice@example.com", "Bob "] self.message.merge_metadata = { - 'alice@example.com': {'order_id': 123, 'tier': 'premium'}, - 'bob@example.com': {'order_id': 678}, + "alice@example.com": {"order_id": 123, "tier": "premium"}, + "bob@example.com": {"order_id": 678}, } - self.message.metadata = {'notification_batch': 'zx912'} + self.message.metadata = {"notification_batch": "zx912"} self.message.send() data = self.get_api_call_json() - self.assertCountEqual(data['message']['recipient_metadata'], [{ - 'rcpt': 'alice@example.com', - 'values': {'order_id': 123, 'tier': 'premium'}, - }, { - 'rcpt': 'bob@example.com', - 'values': {'order_id': 678}, - }]) - self.assertIs(data['message']['preserve_recipients'], False) # merge_metadata implies batch + self.assertCountEqual( + data["message"]["recipient_metadata"], + [ + { + "rcpt": "alice@example.com", + "values": {"order_id": 123, "tier": "premium"}, + }, + { + "rcpt": "bob@example.com", + "values": {"order_id": 678}, + }, + ], + ) + # merge_metadata implies batch: + self.assertIs(data["message"]["preserve_recipients"], False) def test_missing_from(self): """Make sure a missing from_email omits from* from API call. @@ -407,8 +509,8 @@ class MandrillBackendAnymailFeatureTests(MandrillBackendMockAPITestCase): self.message.from_email = None self.message.send() data = self.get_api_call_json() - self.assertNotIn('from_email', data['message']) - self.assertNotIn('from_name', data['message']) + self.assertNotIn("from_email", data["message"]) + self.assertNotIn("from_name", data["message"]) def test_missing_subject(self): """Make sure a missing subject omits subject from API call. @@ -418,70 +520,131 @@ class MandrillBackendAnymailFeatureTests(MandrillBackendMockAPITestCase): self.message.subject = None self.message.send() data = self.get_api_call_json() - self.assertNotIn('subject', data['message']) + self.assertNotIn("subject", data["message"]) def test_esp_extra(self): self.message.esp_extra = { - 'ip_pool': 'Bulk Pool', # Mandrill send param that goes at top level of API payload - 'message': { - 'subaccount': 'Marketing Dept.' # param that goes within message dict - } + # Mandrill send param that goes at top level of API payload: + "ip_pool": "Bulk Pool", + "message": { + # param that goes within message dict: + "subaccount": "Marketing Dept." + }, } - self.message.tags = ['test-tag'] # make sure non-esp_extra params are merged + self.message.tags = ["test-tag"] # make sure non-esp_extra params are merged self.message.send() data = self.get_api_call_json() - self.assertEqual(data['ip_pool'], 'Bulk Pool') - self.assertEqual(data['message']['subaccount'], 'Marketing Dept.') - self.assertEqual(data['message']['tags'], ['test-tag']) + self.assertEqual(data["ip_pool"], "Bulk Pool") + self.assertEqual(data["message"]["subaccount"], "Marketing Dept.") + self.assertEqual(data["message"]["tags"], ["test-tag"]) def test_esp_extra_recipient_metadata(self): """Anymail allows pythonic recipient_metadata dict""" - self.message.esp_extra = {'message': {'recipient_metadata': { - # Anymail expands simple python dicts into the more-verbose - # rcpt/values lists the Mandrill API uses - "customer@example.com": {'cust_id': "67890", 'order_id': "54321"}, - "guest@example.com": {'cust_id': "94107", 'order_id': "43215"}, - }}} + self.message.esp_extra = { + "message": { + "recipient_metadata": { + # Anymail expands simple python dicts into the more-verbose + # rcpt/values lists the Mandrill API uses + "customer@example.com": {"cust_id": "67890", "order_id": "54321"}, + "guest@example.com": {"cust_id": "94107", "order_id": "43215"}, + } + } + } self.message.send() data = self.get_api_call_json() - self.assertCountEqual(data['message']['recipient_metadata'], [ - {'rcpt': "customer@example.com", 'values': {'cust_id': "67890", 'order_id': "54321"}}, - {'rcpt': "guest@example.com", 'values': {'cust_id': "94107", 'order_id': "43215"}}]) + self.assertCountEqual( + data["message"]["recipient_metadata"], + [ + { + "rcpt": "customer@example.com", + "values": {"cust_id": "67890", "order_id": "54321"}, + }, + { + "rcpt": "guest@example.com", + "values": {"cust_id": "94107", "order_id": "43215"}, + }, + ], + ) # You can also just supply it in Mandrill's native form - self.message.esp_extra = {'message': {'recipient_metadata': [ - {'rcpt': "customer@example.com", 'values': {'cust_id': "80806", 'order_id': "70701"}}, - {'rcpt': "guest@example.com", 'values': {'cust_id': "21212", 'order_id': "10305"}}]}} + self.message.esp_extra = { + "message": { + "recipient_metadata": [ + { + "rcpt": "customer@example.com", + "values": {"cust_id": "80806", "order_id": "70701"}, + }, + { + "rcpt": "guest@example.com", + "values": {"cust_id": "21212", "order_id": "10305"}, + }, + ] + } + } self.message.send() data = self.get_api_call_json() - self.assertCountEqual(data['message']['recipient_metadata'], [ - {'rcpt': "customer@example.com", 'values': {'cust_id': "80806", 'order_id': "70701"}}, - {'rcpt': "guest@example.com", 'values': {'cust_id': "21212", 'order_id': "10305"}}]) + self.assertCountEqual( + data["message"]["recipient_metadata"], + [ + { + "rcpt": "customer@example.com", + "values": {"cust_id": "80806", "order_id": "70701"}, + }, + { + "rcpt": "guest@example.com", + "values": {"cust_id": "21212", "order_id": "10305"}, + }, + ], + ) def test_esp_extra_template_content(self): """Anymail allows pythonic template_content dict""" - self.message.template_id = "welcome_template" # forces send-template API and default template_content - self.message.esp_extra = {'template_content': { - # Anymail expands simple python dicts into the more-verbose name/content - # structures the Mandrill API uses - 'HEADLINE': "

Specials Just For *|FNAME|*

", - 'OFFER_BLOCK': "

Half off all fruit

", - }} + self.message.template_id = ( + "welcome_template" # forces send-template API and default template_content + ) + self.message.esp_extra = { + "template_content": { + # Anymail expands simple python dicts into the more-verbose name/content + # structures the Mandrill API uses + "HEADLINE": "

Specials Just For *|FNAME|*

", + "OFFER_BLOCK": "

Half off all fruit

", + } + } self.message.send() data = self.get_api_call_json() - self.assertCountEqual(data['template_content'], [ - {'name': "HEADLINE", 'content': "

Specials Just For *|FNAME|*

"}, - {'name': "OFFER_BLOCK", 'content': "

Half off all fruit

"}]) + self.assertCountEqual( + data["template_content"], + [ + {"name": "HEADLINE", "content": "

Specials Just For *|FNAME|*

"}, + { + "name": "OFFER_BLOCK", + "content": "

Half off all fruit

", + }, + ], + ) # You can also just supply it in Mandrill's native form - self.message.esp_extra = {'template_content': [ - {'name': "HEADLINE", 'content': "

Exciting offers for *|FNAME|*

"}, - {'name': "OFFER_BLOCK", 'content': "

25% off all fruit

"}]} + self.message.esp_extra = { + "template_content": [ + { + "name": "HEADLINE", + "content": "

Exciting offers for *|FNAME|*

", + }, + {"name": "OFFER_BLOCK", "content": "

25% off all fruit

"}, + ] + } self.message.send() data = self.get_api_call_json() - self.assertCountEqual(data['template_content'], [ - {'name': "HEADLINE", 'content': "

Exciting offers for *|FNAME|*

"}, - {'name': "OFFER_BLOCK", 'content': "

25% off all fruit

"}]) + self.assertCountEqual( + data["template_content"], + [ + { + "name": "HEADLINE", + "content": "

Exciting offers for *|FNAME|*

", + }, + {"name": "OFFER_BLOCK", "content": "

25% off all fruit

"}, + ], + ) def test_default_omits_options(self): """Make sure by default we don't send any ESP-specific options. @@ -493,33 +656,44 @@ class MandrillBackendAnymailFeatureTests(MandrillBackendMockAPITestCase): self.message.send() self.assert_esp_called("/messages/send.json") data = self.get_api_call_json() - self.assertNotIn('global_merge_vars', data['message']) - self.assertNotIn('merge_vars', data['message']) - self.assertNotIn('metadata', data['message']) - self.assertNotIn('send_at', data) - self.assertNotIn('tags', data['message']) - self.assertNotIn('template_content', data['message']) - self.assertNotIn('template_name', data['message']) - self.assertNotIn('track_clicks', data['message']) - self.assertNotIn('track_opens', data['message']) + self.assertNotIn("global_merge_vars", data["message"]) + self.assertNotIn("merge_vars", data["message"]) + self.assertNotIn("metadata", data["message"]) + self.assertNotIn("send_at", data) + self.assertNotIn("tags", data["message"]) + self.assertNotIn("template_content", data["message"]) + self.assertNotIn("template_name", data["message"]) + self.assertNotIn("track_clicks", data["message"]) + self.assertNotIn("track_opens", data["message"]) # noinspection PyUnresolvedReferences def test_send_attaches_anymail_status(self): - """ The anymail_status should be attached to the message when it is sent """ - response_content = b'[{"email": "to1@example.com", "status": "sent", "_id": "abc123"}]' + """The anymail_status should be attached to the message when it is sent""" + response_content = ( + b'[{"email": "to1@example.com", "status": "sent", "_id": "abc123"}]' + ) self.set_mock_response(raw=response_content) - msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],) + msg = mail.EmailMessage( + "Subject", + "Message", + "from@example.com", + ["to1@example.com"], + ) sent = msg.send() self.assertEqual(sent, 1) - self.assertEqual(msg.anymail_status.status, {'sent'}) - self.assertEqual(msg.anymail_status.message_id, 'abc123') - self.assertEqual(msg.anymail_status.recipients['to1@example.com'].status, 'sent') - self.assertEqual(msg.anymail_status.recipients['to1@example.com'].message_id, 'abc123') + self.assertEqual(msg.anymail_status.status, {"sent"}) + self.assertEqual(msg.anymail_status.message_id, "abc123") + self.assertEqual( + msg.anymail_status.recipients["to1@example.com"].status, "sent" + ) + self.assertEqual( + msg.anymail_status.recipients["to1@example.com"].message_id, "abc123" + ) 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""" + """If the send fails, anymail_status should contain initial values""" self.set_mock_response(status_code=500) sent = self.message.send(fail_silently=True) self.assertEqual(sent, 0) @@ -530,9 +704,12 @@ class MandrillBackendAnymailFeatureTests(MandrillBackendMockAPITestCase): # noinspection PyUnresolvedReferences def test_send_unparsable_response(self): - """If the send succeeds, but a non-JSON API response, should raise an API exception""" - mock_response = self.set_mock_response(status_code=200, - raw=b"yikes, this isn't a real response") + """ + If the send succeeds, but a non-JSON API response, should raise an API exception + """ + mock_response = self.set_mock_response( + status_code=200, raw=b"yikes, this isn't a real response" + ) with self.assertRaises(AnymailAPIError): self.message.send() self.assertIsNone(self.message.anymail_status.status) @@ -542,100 +719,141 @@ class MandrillBackendAnymailFeatureTests(MandrillBackendMockAPITestCase): def test_json_serialization_errors(self): """Try to provide more information about non-json-serializable data""" - self.message.metadata = {'total': Decimal('19.99')} + self.message.metadata = {"total": Decimal("19.99")} with self.assertRaises(AnymailSerializationError) as cm: self.message.send() print(self.get_api_call_data()) err = cm.exception self.assertIsInstance(err, TypeError) # compatibility with json.dumps - self.assertIn("Don't know how to send this data to Mandrill", str(err)) # our added context - self.assertRegex(str(err), r"Decimal.*is not JSON serializable") # original message + # our added context: + self.assertIn("Don't know how to send this data to Mandrill", str(err)) + # original message: + self.assertRegex(str(err), r"Decimal.*is not JSON serializable") def test_no_extraneous_fields(self): """Don't send empty fields that have no effect on sending""" mail.send_mail("Subject", "Body", "from@example.com", ["to@example.com"]) data = self.get_api_call_json() # Simple send should contain exactly this, nothing more: - self.assertEqual(data, { - "key": "test_api_key", - "message": { - "from_email": "from@example.com", - "to": [{"email": "to@example.com", "type": "to"}], - "subject": "Subject", - "text": "Body", + self.assertEqual( + data, + { + "key": "test_api_key", + "message": { + "from_email": "from@example.com", + "to": [{"email": "to@example.com", "type": "to"}], + "subject": "Subject", + "text": "Body", + }, }, - }) + ) -@tag('mandrill') +@tag("mandrill") class MandrillBackendRecipientsRefusedTests(MandrillBackendMockAPITestCase): - """Should raise AnymailRecipientsRefused when *all* recipients are rejected or invalid""" + """ + Should raise AnymailRecipientsRefused when *all* recipients are rejected or invalid + """ def test_recipients_refused(self): - msg = mail.EmailMessage('Subject', 'Body', 'from@example.com', - ['invalid@localhost', 'reject@test.mandrillapp.com']) - self.set_mock_response(raw=b"""[ + msg = mail.EmailMessage( + "Subject", + "Body", + "from@example.com", + ["invalid@localhost", "reject@test.mandrillapp.com"], + ) + self.set_mock_response( + raw=b"""[ {"email": "invalid@localhost", "status": "invalid"}, {"email": "reject@test.mandrillapp.com", "status": "rejected"} - ]""") + ]""" + ) with self.assertRaises(AnymailRecipientsRefused): msg.send() def test_fail_silently(self): - self.set_mock_response(raw=b"""[ + self.set_mock_response( + raw=b"""[ {"email": "invalid@localhost", "status": "invalid"}, {"email": "reject@test.mandrillapp.com", "status": "rejected"} - ]""") - sent = mail.send_mail('Subject', 'Body', 'from@example.com', - ['invalid@localhost', 'reject@test.mandrillapp.com'], - fail_silently=True) + ]""" + ) + sent = mail.send_mail( + "Subject", + "Body", + "from@example.com", + ["invalid@localhost", "reject@test.mandrillapp.com"], + fail_silently=True, + ) self.assertEqual(sent, 0) def test_mixed_response(self): """If *any* recipients are valid or queued, no exception is raised""" - msg = mail.EmailMessage('Subject', 'Body', 'from@example.com', - ['invalid@localhost', 'valid@example.com', - 'reject@test.mandrillapp.com', 'also.valid@example.com']) - self.set_mock_response(raw=b"""[ + msg = mail.EmailMessage( + "Subject", + "Body", + "from@example.com", + [ + "invalid@localhost", + "valid@example.com", + "reject@test.mandrillapp.com", + "also.valid@example.com", + ], + ) + self.set_mock_response( + raw=b"""[ {"email": "invalid@localhost", "status": "invalid"}, {"email": "valid@example.com", "status": "sent"}, {"email": "reject@test.mandrillapp.com", "status": "rejected"}, {"email": "also.valid@example.com", "status": "queued"} - ]""") + ]""" + ) sent = msg.send() - self.assertEqual(sent, 1) # one message sent, successfully, to 2 of 4 recipients + # one message sent, successfully, to 2 of 4 recipients: + self.assertEqual(sent, 1) status = msg.anymail_status - self.assertEqual(status.recipients['invalid@localhost'].status, 'invalid') - self.assertEqual(status.recipients['valid@example.com'].status, 'sent') - self.assertEqual(status.recipients['reject@test.mandrillapp.com'].status, 'rejected') - self.assertEqual(status.recipients['also.valid@example.com'].status, 'queued') + self.assertEqual(status.recipients["invalid@localhost"].status, "invalid") + self.assertEqual(status.recipients["valid@example.com"].status, "sent") + self.assertEqual( + status.recipients["reject@test.mandrillapp.com"].status, "rejected" + ) + self.assertEqual(status.recipients["also.valid@example.com"].status, "queued") @override_settings(ANYMAIL_IGNORE_RECIPIENT_STATUS=True) def test_settings_override(self): """No exception with ignore setting""" - self.set_mock_response(raw=b"""[ + self.set_mock_response( + raw=b"""[ {"email": "invalid@localhost", "status": "invalid"}, {"email": "reject@test.mandrillapp.com", "status": "rejected"} - ]""") - sent = mail.send_mail('Subject', 'Body', 'from@example.com', - ['invalid@localhost', 'reject@test.mandrillapp.com']) + ]""" + ) + sent = mail.send_mail( + "Subject", + "Body", + "from@example.com", + ["invalid@localhost", "reject@test.mandrillapp.com"], + ) self.assertEqual(sent, 1) # refused message is included in sent count -@tag('mandrill') -class MandrillBackendSessionSharingTestCase(SessionSharingTestCases, MandrillBackendMockAPITestCase): +@tag("mandrill") +class MandrillBackendSessionSharingTestCase( + SessionSharingTestCases, MandrillBackendMockAPITestCase +): """Requests session sharing tests""" + pass # tests are defined in SessionSharingTestCases -@tag('mandrill') +@tag("mandrill") @override_settings(EMAIL_BACKEND="anymail.backends.mandrill.EmailBackend") class MandrillBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase): """Test backend without required settings""" def test_missing_api_key(self): with self.assertRaises(ImproperlyConfigured) as cm: - mail.send_mail('Subject', 'Message', 'from@example.com', ['to@example.com']) + mail.send_mail("Subject", "Message", "from@example.com", ["to@example.com"]) errmsg = str(cm.exception) - self.assertRegex(errmsg, r'\bMANDRILL_API_KEY\b') - self.assertRegex(errmsg, r'\bANYMAIL_MANDRILL_API_KEY\b') + self.assertRegex(errmsg, r"\bMANDRILL_API_KEY\b") + self.assertRegex(errmsg, r"\bANYMAIL_MANDRILL_API_KEY\b") diff --git a/tests/test_mandrill_djrill_features.py b/tests/test_mandrill_djrill_features.py index fec15d6..1dc1e55 100644 --- a/tests/test_mandrill_djrill_features.py +++ b/tests/test_mandrill_djrill_features.py @@ -1,4 +1,5 @@ from datetime import date + from django.core import mail from django.test import override_settings, tag @@ -7,7 +8,7 @@ from anymail.exceptions import AnymailSerializationError from .test_mandrill_backend import MandrillBackendMockAPITestCase -@tag('mandrill') +@tag("mandrill") class MandrillBackendDjrillFeatureTests(MandrillBackendMockAPITestCase): """Test backend support for deprecated features leftover from Djrill""" @@ -18,164 +19,180 @@ class MandrillBackendDjrillFeatureTests(MandrillBackendMockAPITestCase): # self.message.async = True # it should be changed to: # self.message.esp_extra = {"async": True} - # (The setattr below keeps these tests compatible, but isn't recommended for your code.) - setattr(self.message, 'async', True) # don't do this; use esp_extra instead - with self.assertWarnsRegex(DeprecationWarning, 'async'): + # (The setattr below keeps these tests compatible, + # but isn't recommended for your code.) + setattr(self.message, "async", True) # don't do this; use esp_extra instead + with self.assertWarnsRegex(DeprecationWarning, "async"): self.message.send() data = self.get_api_call_json() - self.assertEqual(data['async'], True) + self.assertEqual(data["async"], True) def test_auto_html(self): self.message.auto_html = True - with self.assertWarnsRegex(DeprecationWarning, 'auto_html'): + with self.assertWarnsRegex(DeprecationWarning, "auto_html"): self.message.send() data = self.get_api_call_json() - self.assertEqual(data['message']['auto_html'], True) + self.assertEqual(data["message"]["auto_html"], True) def test_auto_text(self): self.message.auto_text = True - with self.assertWarnsRegex(DeprecationWarning, 'auto_text'): + with self.assertWarnsRegex(DeprecationWarning, "auto_text"): self.message.send() data = self.get_api_call_json() - self.assertEqual(data['message']['auto_text'], True) + self.assertEqual(data["message"]["auto_text"], True) def test_google_analytics_campaign(self): self.message.google_analytics_campaign = "Email Receipts" - with self.assertWarnsRegex(DeprecationWarning, 'google_analytics_campaign'): + with self.assertWarnsRegex(DeprecationWarning, "google_analytics_campaign"): self.message.send() data = self.get_api_call_json() - self.assertEqual(data['message']['google_analytics_campaign'], "Email Receipts") + self.assertEqual(data["message"]["google_analytics_campaign"], "Email Receipts") def test_google_analytics_domains(self): self.message.google_analytics_domains = ["example.com"] - with self.assertWarnsRegex(DeprecationWarning, 'google_analytics_domains'): + with self.assertWarnsRegex(DeprecationWarning, "google_analytics_domains"): self.message.send() data = self.get_api_call_json() - self.assertEqual(data['message']['google_analytics_domains'], ["example.com"]) + self.assertEqual(data["message"]["google_analytics_domains"], ["example.com"]) def test_important(self): self.message.important = True - with self.assertWarnsRegex(DeprecationWarning, 'important'): + with self.assertWarnsRegex(DeprecationWarning, "important"): self.message.send() data = self.get_api_call_json() - self.assertEqual(data['message']['important'], True) + self.assertEqual(data["message"]["important"], True) def test_inline_css(self): self.message.inline_css = True - with self.assertWarnsRegex(DeprecationWarning, 'inline_css'): + with self.assertWarnsRegex(DeprecationWarning, "inline_css"): self.message.send() data = self.get_api_call_json() - self.assertEqual(data['message']['inline_css'], True) + self.assertEqual(data["message"]["inline_css"], True) def test_ip_pool(self): self.message.ip_pool = "Bulk Pool" - with self.assertWarnsRegex(DeprecationWarning, 'ip_pool'): + with self.assertWarnsRegex(DeprecationWarning, "ip_pool"): self.message.send() data = self.get_api_call_json() - self.assertEqual(data['ip_pool'], "Bulk Pool") + self.assertEqual(data["ip_pool"], "Bulk Pool") def test_merge_language(self): self.message.merge_language = "mailchimp" - with self.assertWarnsRegex(DeprecationWarning, 'merge_language'): + with self.assertWarnsRegex(DeprecationWarning, "merge_language"): self.message.send() data = self.get_api_call_json() - self.assertEqual(data['message']['merge_language'], "mailchimp") + self.assertEqual(data["message"]["merge_language"], "mailchimp") def test_preserve_recipients(self): self.message.preserve_recipients = True - with self.assertWarnsRegex(DeprecationWarning, 'preserve_recipients'): + with self.assertWarnsRegex(DeprecationWarning, "preserve_recipients"): self.message.send() data = self.get_api_call_json() - self.assertEqual(data['message']['preserve_recipients'], True) + self.assertEqual(data["message"]["preserve_recipients"], True) def test_recipient_metadata(self): self.message.recipient_metadata = { # Anymail expands simple python dicts into the more-verbose # rcpt/values structures the Mandrill API uses - "customer@example.com": {'cust_id': "67890", 'order_id': "54321"}, - "guest@example.com": {'cust_id': "94107", 'order_id': "43215"} + "customer@example.com": {"cust_id": "67890", "order_id": "54321"}, + "guest@example.com": {"cust_id": "94107", "order_id": "43215"}, } - with self.assertWarnsRegex(DeprecationWarning, 'recipient_metadata'): + with self.assertWarnsRegex(DeprecationWarning, "recipient_metadata"): self.message.send() data = self.get_api_call_json() - self.assertCountEqual(data['message']['recipient_metadata'], [ - {'rcpt': "customer@example.com", - 'values': {'cust_id': "67890", 'order_id': "54321"}}, - {'rcpt': "guest@example.com", - 'values': {'cust_id': "94107", 'order_id': "43215"}}]) + self.assertCountEqual( + data["message"]["recipient_metadata"], + [ + { + "rcpt": "customer@example.com", + "values": {"cust_id": "67890", "order_id": "54321"}, + }, + { + "rcpt": "guest@example.com", + "values": {"cust_id": "94107", "order_id": "43215"}, + }, + ], + ) def test_return_path_domain(self): self.message.return_path_domain = "support.example.com" - with self.assertWarnsRegex(DeprecationWarning, 'return_path_domain'): + with self.assertWarnsRegex(DeprecationWarning, "return_path_domain"): self.message.send() data = self.get_api_call_json() - self.assertEqual(data['message']['return_path_domain'], "support.example.com") + self.assertEqual(data["message"]["return_path_domain"], "support.example.com") def test_signing_domain(self): self.message.signing_domain = "example.com" - with self.assertWarnsRegex(DeprecationWarning, 'signing_domain'): + with self.assertWarnsRegex(DeprecationWarning, "signing_domain"): self.message.send() data = self.get_api_call_json() - self.assertEqual(data['message']['signing_domain'], "example.com") + self.assertEqual(data["message"]["signing_domain"], "example.com") def test_subaccount(self): self.message.subaccount = "marketing-dept" - with self.assertWarnsRegex(DeprecationWarning, 'subaccount'): + with self.assertWarnsRegex(DeprecationWarning, "subaccount"): self.message.send() data = self.get_api_call_json() - self.assertEqual(data['message']['subaccount'], "marketing-dept") + self.assertEqual(data["message"]["subaccount"], "marketing-dept") def test_template_content(self): self.message.template_content = { - 'HEADLINE': "

Specials Just For *|FNAME|*

", - 'OFFER_BLOCK': "

Half off all fruit

" + "HEADLINE": "

Specials Just For *|FNAME|*

", + "OFFER_BLOCK": "

Half off all fruit

", } - with self.assertWarnsRegex(DeprecationWarning, 'template_content'): + with self.assertWarnsRegex(DeprecationWarning, "template_content"): self.message.send() data = self.get_api_call_json() # Anymail expands simple python dicts into the more-verbose name/content # structures the Mandrill API uses - self.assertCountEqual(data['template_content'], [ - {'name': "HEADLINE", 'content': "

Specials Just For *|FNAME|*

"}, - {'name': "OFFER_BLOCK", 'content': "

Half off all fruit

"}]) + self.assertCountEqual( + data["template_content"], + [ + {"name": "HEADLINE", "content": "

Specials Just For *|FNAME|*

"}, + { + "name": "OFFER_BLOCK", + "content": "

Half off all fruit

", + }, + ], + ) def test_tracking_domain(self): self.message.tracking_domain = "click.example.com" - with self.assertWarnsRegex(DeprecationWarning, 'tracking_domain'): + with self.assertWarnsRegex(DeprecationWarning, "tracking_domain"): self.message.send() data = self.get_api_call_json() - self.assertEqual(data['message']['tracking_domain'], "click.example.com") + self.assertEqual(data["message"]["tracking_domain"], "click.example.com") def test_url_strip_qs(self): self.message.url_strip_qs = True - with self.assertWarnsRegex(DeprecationWarning, 'url_strip_qs'): + with self.assertWarnsRegex(DeprecationWarning, "url_strip_qs"): self.message.send() data = self.get_api_call_json() - self.assertEqual(data['message']['url_strip_qs'], True) + self.assertEqual(data["message"]["url_strip_qs"], True) def test_use_template_from(self): self.message.template_id = "PERSONALIZED_SPECIALS" # forces send-template api self.message.use_template_from = True - with self.assertWarnsRegex(DeprecationWarning, 'use_template_from'): + with self.assertWarnsRegex(DeprecationWarning, "use_template_from"): self.message.send() data = self.get_api_call_json() - self.assertNotIn('from_email', data['message']) - self.assertNotIn('from_name', data['message']) + self.assertNotIn("from_email", data["message"]) + self.assertNotIn("from_name", data["message"]) def test_use_template_subject(self): self.message.template_id = "PERSONALIZED_SPECIALS" # force send-template API self.message.use_template_subject = True - with self.assertWarnsRegex(DeprecationWarning, 'use_template_subject'): + with self.assertWarnsRegex(DeprecationWarning, "use_template_subject"): self.message.send() data = self.get_api_call_json() - self.assertNotIn('subject', data['message']) + self.assertNotIn("subject", data["message"]) def test_view_content_link(self): self.message.view_content_link = True - with self.assertWarnsRegex(DeprecationWarning, 'view_content_link'): + with self.assertWarnsRegex(DeprecationWarning, "view_content_link"): self.message.send() data = self.get_api_call_json() - self.assertEqual(data['message']['view_content_link'], True) + self.assertEqual(data["message"]["view_content_link"], True) def test_default_omits_options(self): """Make sure by default we don't send any Mandrill-specific options. @@ -187,49 +204,56 @@ class MandrillBackendDjrillFeatureTests(MandrillBackendMockAPITestCase): self.message.send() self.assert_esp_called("/messages/send.json") data = self.get_api_call_json() - self.assertFalse('auto_html' in data['message']) - self.assertFalse('auto_text' in data['message']) - self.assertFalse('bcc_address' in data['message']) - self.assertFalse('from_name' in data['message']) - self.assertFalse('global_merge_vars' in data['message']) - self.assertFalse('google_analytics_campaign' in data['message']) - self.assertFalse('google_analytics_domains' in data['message']) - self.assertFalse('important' in data['message']) - self.assertFalse('inline_css' in data['message']) - self.assertFalse('merge_language' in data['message']) - self.assertFalse('merge_vars' in data['message']) - self.assertFalse('preserve_recipients' in data['message']) - self.assertFalse('recipient_metadata' in data['message']) - self.assertFalse('return_path_domain' in data['message']) - self.assertFalse('signing_domain' in data['message']) - self.assertFalse('subaccount' in data['message']) - self.assertFalse('tracking_domain' in data['message']) - self.assertFalse('url_strip_qs' in data['message']) - self.assertFalse('view_content_link' in data['message']) + self.assertFalse("auto_html" in data["message"]) + self.assertFalse("auto_text" in data["message"]) + self.assertFalse("bcc_address" in data["message"]) + self.assertFalse("from_name" in data["message"]) + self.assertFalse("global_merge_vars" in data["message"]) + self.assertFalse("google_analytics_campaign" in data["message"]) + self.assertFalse("google_analytics_domains" in data["message"]) + self.assertFalse("important" in data["message"]) + self.assertFalse("inline_css" in data["message"]) + self.assertFalse("merge_language" in data["message"]) + self.assertFalse("merge_vars" in data["message"]) + self.assertFalse("preserve_recipients" in data["message"]) + self.assertFalse("recipient_metadata" in data["message"]) + self.assertFalse("return_path_domain" in data["message"]) + self.assertFalse("signing_domain" in data["message"]) + self.assertFalse("subaccount" in data["message"]) + self.assertFalse("tracking_domain" in data["message"]) + self.assertFalse("url_strip_qs" in data["message"]) + self.assertFalse("view_content_link" in data["message"]) # Options at top level of api params (not in message dict): - self.assertFalse('async' in data) - self.assertFalse('ip_pool' in data) + self.assertFalse("async" in data) + self.assertFalse("ip_pool" in data) def test_dates_not_serialized(self): - """Old versions of predecessor package Djrill accidentally serialized dates to ISO""" - self.message.metadata = {'SHIP_DATE': date(2015, 12, 2)} + """ + Old versions of predecessor package Djrill accidentally serialized dates to ISO + """ + self.message.metadata = {"SHIP_DATE": date(2015, 12, 2)} with self.assertRaises(AnymailSerializationError): self.message.send() - @override_settings(ANYMAIL_MANDRILL_SEND_DEFAULTS={'subaccount': 'test_subaccount'}) + @override_settings(ANYMAIL_MANDRILL_SEND_DEFAULTS={"subaccount": "test_subaccount"}) def test_subaccount_setting(self): """Global, non-esp_extra version of subaccount default""" - with self.assertWarnsRegex(DeprecationWarning, 'subaccount'): - mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com']) + with self.assertWarnsRegex(DeprecationWarning, "subaccount"): + mail.send_mail("Subject", "Body", "from@example.com", ["to@example.com"]) data = self.get_api_call_json() - self.assertEqual(data['message']['subaccount'], "test_subaccount") + self.assertEqual(data["message"]["subaccount"], "test_subaccount") - @override_settings(ANYMAIL_MANDRILL_SEND_DEFAULTS={'subaccount': 'global_setting_subaccount'}) + @override_settings( + ANYMAIL_MANDRILL_SEND_DEFAULTS={"subaccount": "global_setting_subaccount"} + ) def test_subaccount_message_overrides_setting(self): """Global, non-esp_extra version of subaccount default""" - message = mail.EmailMessage('Subject', 'Body', 'from@example.com', ['to@example.com']) - message.subaccount = "individual_message_subaccount" # should override global setting - with self.assertWarnsRegex(DeprecationWarning, 'subaccount'): + message = mail.EmailMessage( + "Subject", "Body", "from@example.com", ["to@example.com"] + ) + # subaccount should override global setting: + message.subaccount = "individual_message_subaccount" + with self.assertWarnsRegex(DeprecationWarning, "subaccount"): message.send() data = self.get_api_call_json() - self.assertEqual(data['message']['subaccount'], "individual_message_subaccount") + self.assertEqual(data["message"]["subaccount"], "individual_message_subaccount") diff --git a/tests/test_mandrill_inbound.py b/tests/test_mandrill_inbound.py index 7e34782..340affc 100644 --- a/tests/test_mandrill_inbound.py +++ b/tests/test_mandrill_inbound.py @@ -11,7 +11,7 @@ from .test_mandrill_webhooks import TEST_WEBHOOK_KEY, mandrill_args from .webhook_cases import WebhookTestCase -@tag('mandrill') +@tag("mandrill") @override_settings(ANYMAIL_MANDRILL_WEBHOOK_KEY=TEST_WEBHOOK_KEY) class MandrillInboundTestCase(WebhookTestCase): def test_inbound_basics(self): @@ -19,7 +19,8 @@ class MandrillInboundTestCase(WebhookTestCase): "event": "inbound", "ts": 1507856722, "msg": { - "raw_msg": dedent("""\ + "raw_msg": dedent( + """\ From: A tester Date: Thu, 12 Oct 2017 18:03:30 -0700 Message-ID: @@ -41,9 +42,11 @@ class MandrillInboundTestCase(WebhookTestCase):
It's a body=E2=80=A6
--94eb2c05e174adb140055b6339c5-- - """), + """ # NOQA: E501 + ), "email": "delivered-to@example.com", - "sender": None, # Mandrill populates "sender" only for outbound message events + # Mandrill populates "sender" only for outbound message events + "sender": None, "spam_report": { "score": 1.7, }, @@ -52,13 +55,20 @@ class MandrillInboundTestCase(WebhookTestCase): }, } - response = self.client.post(**mandrill_args(events=[raw_event], path='/anymail/mandrill/')) + response = self.client.post( + **mandrill_args(events=[raw_event], path="/anymail/mandrill/") + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.inbound_handler, sender=MandrillCombinedWebhookView, - event=ANY, esp_name='Mandrill') - self.assertEqual(self.tracking_handler.call_count, 0) # Inbound should not dispatch tracking signal + kwargs = self.assert_handler_called_once_with( + self.inbound_handler, + sender=MandrillCombinedWebhookView, + event=ANY, + esp_name="Mandrill", + ) + # Inbound should not dispatch tracking signal: + self.assertEqual(self.tracking_handler.call_count, 0) - event = kwargs['event'] + event = kwargs["event"] self.assertIsInstance(event, AnymailInboundEvent) self.assertEqual(event.event_type, "inbound") self.assertEqual(event.timestamp.isoformat(), "2017-10-13T01:05:22+00:00") @@ -67,22 +77,28 @@ class MandrillInboundTestCase(WebhookTestCase): self.assertEqual(event.esp_event, raw_event) message = event.message - self.assertEqual(message.from_email.display_name, 'A tester') - self.assertEqual(message.from_email.addr_spec, 'test@example.org') + self.assertEqual(message.from_email.display_name, "A tester") + self.assertEqual(message.from_email.addr_spec, "test@example.org") self.assertEqual(len(message.to), 2) - self.assertEqual(message.to[0].display_name, 'Test, Inbound') - self.assertEqual(message.to[0].addr_spec, 'test@inbound.example.com') - self.assertEqual(message.to[1].addr_spec, 'other@example.com') - self.assertEqual(message.subject, 'Test subject') + self.assertEqual(message.to[0].display_name, "Test, Inbound") + self.assertEqual(message.to[0].addr_spec, "test@inbound.example.com") + self.assertEqual(message.to[1].addr_spec, "other@example.com") + self.assertEqual(message.subject, "Test subject") self.assertEqual(message.date.isoformat(" "), "2017-10-12 18:03:30-07:00") self.assertEqual(message.text, "It's a body\N{HORIZONTAL ELLIPSIS}\n") - self.assertEqual(message.html, """
It's a body\N{HORIZONTAL ELLIPSIS}
\n""") + self.assertEqual( + message.html, + """
It's a body\N{HORIZONTAL ELLIPSIS}
\n""", + ) self.assertIsNone(message.envelope_sender) # Mandrill doesn't provide sender - self.assertEqual(message.envelope_recipient, 'delivered-to@example.com') - self.assertIsNone(message.stripped_text) # Mandrill doesn't provide stripped plaintext body - self.assertIsNone(message.stripped_html) # Mandrill doesn't provide stripped html - self.assertIsNone(message.spam_detected) # Mandrill doesn't provide spam boolean + self.assertEqual(message.envelope_recipient, "delivered-to@example.com") + # Mandrill doesn't provide stripped plaintext body: + self.assertIsNone(message.stripped_text) + # Mandrill doesn't provide stripped html: + self.assertIsNone(message.stripped_html) + # Mandrill doesn't provide spam boolean: + self.assertIsNone(message.spam_detected) self.assertEqual(message.spam_score, 1.7) # Anymail will also parse attachments (if any) from the raw mime. diff --git a/tests/test_mandrill_integration.py b/tests/test_mandrill_integration.py index 402ae02..df78ea2 100644 --- a/tests/test_mandrill_integration.py +++ b/tests/test_mandrill_integration.py @@ -10,16 +10,20 @@ from anymail.message import AnymailMessage from .utils import AnymailTestMixin, sample_image_path -ANYMAIL_TEST_MANDRILL_API_KEY = os.getenv('ANYMAIL_TEST_MANDRILL_API_KEY') -ANYMAIL_TEST_MANDRILL_DOMAIN = os.getenv('ANYMAIL_TEST_MANDRILL_DOMAIN') +ANYMAIL_TEST_MANDRILL_API_KEY = os.getenv("ANYMAIL_TEST_MANDRILL_API_KEY") +ANYMAIL_TEST_MANDRILL_DOMAIN = os.getenv("ANYMAIL_TEST_MANDRILL_DOMAIN") -@tag('mandrill', 'live') -@unittest.skipUnless(ANYMAIL_TEST_MANDRILL_API_KEY and ANYMAIL_TEST_MANDRILL_DOMAIN, - "Set ANYMAIL_TEST_MANDRILL_API_KEY and ANYMAIL_TEST_MANDRILL_DOMAIN " - "environment variables to run integration tests") -@override_settings(MANDRILL_API_KEY=ANYMAIL_TEST_MANDRILL_API_KEY, - EMAIL_BACKEND="anymail.backends.mandrill.EmailBackend") +@tag("mandrill", "live") +@unittest.skipUnless( + ANYMAIL_TEST_MANDRILL_API_KEY and ANYMAIL_TEST_MANDRILL_DOMAIN, + "Set ANYMAIL_TEST_MANDRILL_API_KEY and ANYMAIL_TEST_MANDRILL_DOMAIN " + "environment variables to run integration tests", +) +@override_settings( + MANDRILL_API_KEY=ANYMAIL_TEST_MANDRILL_API_KEY, + EMAIL_BACKEND="anymail.backends.mandrill.EmailBackend", +) class MandrillBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): """Mandrill API integration tests @@ -33,17 +37,23 @@ class MandrillBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): def setUp(self): super().setUp() - self.from_email = self.addr('from') - self.message = mail.EmailMultiAlternatives('Anymail Mandrill integration test', 'Text content', - self.from_email, [self.addr('test+to1')]) - self.message.attach_alternative('

HTML content

', "text/html") + self.from_email = self.addr("from") + self.message = mail.EmailMultiAlternatives( + "Anymail Mandrill integration test", + "Text content", + self.from_email, + [self.addr("test+to1")], + ) + self.message.attach_alternative("

HTML content

", "text/html") def addr(self, username, display_name=None): """Construct test email address within our test domain""" # Because integration tests run within a Mandrill trial account, # both sender and recipient addresses must be within the test domain. # (Other recipient addresses will be rejected with 'recipient-domain-mismatch'.) - email = '{username}@{domain}'.format(username=username, domain=ANYMAIL_TEST_MANDRILL_DOMAIN) + email = "{username}@{domain}".format( + username=username, domain=ANYMAIL_TEST_MANDRILL_DOMAIN + ) if display_name is not None: return formataddr((display_name, email)) else: @@ -60,11 +70,15 @@ class MandrillBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): sent_status = anymail_status.recipients[to_email].status message_id = anymail_status.recipients[to_email].message_id - self.assertIn(sent_status, ['sent', 'queued']) # successful send (could still bounce later) - self.assertGreater(len(message_id), 0) # don't know what it'll be, but it should exist + # successful send (could still bounce later): + self.assertIn(sent_status, ["sent", "queued"]) + # don't know what it'll be, but it should exist: + self.assertGreater(len(message_id), 0) - self.assertEqual(anymail_status.status, {sent_status}) # set of all recipient statuses - self.assertEqual(anymail_status.message_id, message_id) # because only a single recipient (else would be a set) + # set of all recipient statuses: + self.assertEqual(anymail_status.status, {sent_status}) + # because only a single recipient (else would be a set): + self.assertEqual(anymail_status.message_id, message_id) def test_all_options(self): message = AnymailMessage( @@ -76,7 +90,6 @@ class MandrillBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): bcc=[self.addr("test+bcc1"), self.addr("test+bcc2", "Blind Copy 2")], reply_to=["reply1@example.com", "Reply 2 "], headers={"X-Anymail-Test": "value"}, - # no metadata, send_at, track_clicks support tags=["tag 1"], # max one tag track_opens=True, @@ -87,15 +100,18 @@ class MandrillBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): message.attach_alternative( "

HTML: with link" "and image: " % cid, - "text/html") + "text/html", + ) message.send() - self.assertTrue(message.anymail_status.status.issubset({'queued', 'sent'})) + self.assertTrue(message.anymail_status.status.issubset({"queued", "sent"})) def test_invalid_from(self): # Example of trying to send from an invalid address # Mandrill returns a 500 response (which raises a MandrillAPIError) - self.message.from_email = 'webmaster@localhost' # Django default DEFAULT_FROM_EMAIL + self.message.from_email = ( + "webmaster@localhost" # Django default DEFAULT_FROM_EMAIL + ) with self.assertRaises(AnymailAPIError) as cm: self.message.send() err = cm.exception @@ -104,41 +120,54 @@ class MandrillBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): def test_invalid_to(self): # Example of detecting when a recipient is not a valid email address - self.message.to = ['invalid@localhost'] + self.message.to = ["invalid@localhost"] try: self.message.send() except AnymailRecipientsRefused: - # Mandrill refused to deliver the mail -- message.anymail_status will tell you why: + # Mandrill refused to deliver the mail -- message.anymail_status + # will tell you why: # noinspection PyUnresolvedReferences anymail_status = self.message.anymail_status - self.assertEqual(anymail_status.recipients['invalid@localhost'].status, 'invalid') - self.assertEqual(anymail_status.status, {'invalid'}) + self.assertEqual( + anymail_status.recipients["invalid@localhost"].status, "invalid" + ) + self.assertEqual(anymail_status.status, {"invalid"}) else: # Sometimes Mandrill queues these test sends # noinspection PyUnresolvedReferences - if self.message.anymail_status.status == {'queued'}: + if self.message.anymail_status.status == {"queued"}: self.skipTest("Mandrill queued the send -- can't complete this test") else: - self.fail("Anymail did not raise AnymailRecipientsRefused for invalid recipient") + self.fail( + "Anymail did not raise AnymailRecipientsRefused" + " for invalid recipient" + ) def test_rejected_to(self): # Example of detecting when a recipient is on Mandrill's rejection blacklist - self.message.to = ['reject@test.mandrillapp.com'] + self.message.to = ["reject@test.mandrillapp.com"] try: self.message.send() except AnymailRecipientsRefused: - # Mandrill refused to deliver the mail -- message.anymail_status will tell you why: + # Mandrill refused to deliver the mail -- message.anymail_status will + # tell you why: # noinspection PyUnresolvedReferences anymail_status = self.message.anymail_status - self.assertEqual(anymail_status.recipients['reject@test.mandrillapp.com'].status, 'rejected') - self.assertEqual(anymail_status.status, {'rejected'}) + self.assertEqual( + anymail_status.recipients["reject@test.mandrillapp.com"].status, + "rejected", + ) + self.assertEqual(anymail_status.status, {"rejected"}) else: # Sometimes Mandrill queues these test sends # noinspection PyUnresolvedReferences - if self.message.anymail_status.status == {'queued'}: + if self.message.anymail_status.status == {"queued"}: self.skipTest("Mandrill queued the send -- can't complete this test") else: - self.fail("Anymail did not raise AnymailRecipientsRefused for blacklist recipient") + self.fail( + "Anymail did not raise AnymailRecipientsRefused" + " for blacklist recipient" + ) @override_settings(MANDRILL_API_KEY="Hey, that's not an API key!") def test_invalid_api_key(self): diff --git a/tests/test_mandrill_webhooks.py b/tests/test_mandrill_webhooks.py index 1890e79..996ac6b 100644 --- a/tests/test_mandrill_webhooks.py +++ b/tests/test_mandrill_webhooks.py @@ -10,17 +10,23 @@ from django.core.exceptions import ImproperlyConfigured from django.test import override_settings, tag from anymail.signals import AnymailTrackingEvent -from anymail.webhooks.mandrill import MandrillCombinedWebhookView, MandrillTrackingWebhookView +from anymail.webhooks.mandrill import ( + MandrillCombinedWebhookView, + MandrillTrackingWebhookView, +) + from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase -TEST_WEBHOOK_KEY = 'TEST_WEBHOOK_KEY' +TEST_WEBHOOK_KEY = "TEST_WEBHOOK_KEY" -def mandrill_args(events=None, - host="http://testserver/", # Django test-client default - path='/anymail/mandrill/', # Anymail urlconf default - auth="username:password", # WebhookTestCase default - key=TEST_WEBHOOK_KEY): +def mandrill_args( + events=None, + host="http://testserver/", # Django test-client default + path="/anymail/mandrill/", # Anymail urlconf default + auth="username:password", # WebhookTestCase default + key=TEST_WEBHOOK_KEY, +): """Returns TestClient.post kwargs for Mandrill webhook call with events Computes correct signature. @@ -35,55 +41,59 @@ def mandrill_args(events=None, else: full_url = test_client_path mandrill_events = json.dumps(events) - signed_data = full_url + 'mandrill_events' + mandrill_events - signature = b64encode(hmac.new(key=key.encode('ascii'), - msg=signed_data.encode('utf-8'), - digestmod=hashlib.sha1).digest()) + signed_data = full_url + "mandrill_events" + mandrill_events + signature = b64encode( + hmac.new( + key=key.encode("ascii"), + msg=signed_data.encode("utf-8"), + digestmod=hashlib.sha1, + ).digest() + ) return { - 'path': test_client_path, - 'data': {'mandrill_events': mandrill_events}, - 'HTTP_X_MANDRILL_SIGNATURE': signature, + "path": test_client_path, + "data": {"mandrill_events": mandrill_events}, + "HTTP_X_MANDRILL_SIGNATURE": signature, } -@tag('mandrill') +@tag("mandrill") class MandrillWebhookSettingsTestCase(WebhookTestCase): def test_requires_webhook_key(self): - with self.assertRaisesRegex(ImproperlyConfigured, r'MANDRILL_WEBHOOK_KEY'): - self.client.post('/anymail/mandrill/', - data={'mandrill_events': '[]'}) + with self.assertRaisesRegex(ImproperlyConfigured, r"MANDRILL_WEBHOOK_KEY"): + self.client.post("/anymail/mandrill/", data={"mandrill_events": "[]"}) def test_head_does_not_require_webhook_key(self): # Mandrill issues an unsigned HEAD request to verify the wehbook url. # Only *after* that succeeds will Mandrill will tell you the webhook key. # So make sure that HEAD request will go through without any key set: - response = self.client.head('/anymail/mandrill/') + response = self.client.head("/anymail/mandrill/") self.assertEqual(response.status_code, 200) -@tag('mandrill') +@tag("mandrill") @override_settings(ANYMAIL_MANDRILL_WEBHOOK_KEY=TEST_WEBHOOK_KEY) class MandrillWebhookSecurityTestCase(WebhookBasicAuthTestCase): should_warn_if_no_auth = False # because we check webhook signature def call_webhook(self): - kwargs = mandrill_args([{'event': 'send'}]) + kwargs = mandrill_args([{"event": "send"}]) return self.client.post(**kwargs) # Additional tests are in WebhookBasicAuthTestCase def test_verifies_correct_signature(self): - kwargs = mandrill_args([{'event': 'send'}]) + kwargs = mandrill_args([{"event": "send"}]) response = self.client.post(**kwargs) self.assertEqual(response.status_code, 200) def test_verifies_missing_signature(self): - response = self.client.post('/anymail/mandrill/', - data={'mandrill_events': '[{"event":"send"}]'}) + response = self.client.post( + "/anymail/mandrill/", data={"mandrill_events": '[{"event":"send"}]'} + ) self.assertEqual(response.status_code, 400) def test_verifies_bad_signature(self): - kwargs = mandrill_args([{'event': 'send'}], key="wrong API key") + kwargs = mandrill_args([{"event": "send"}], key="wrong API key") response = self.client.post(**kwargs) self.assertEqual(response.status_code, 400) @@ -91,81 +101,93 @@ class MandrillWebhookSecurityTestCase(WebhookBasicAuthTestCase): def test_no_basic_auth(self): # Signature validation should work properly if you're not using basic auth self.clear_basic_auth() - kwargs = mandrill_args([{'event': 'send'}], auth="") + kwargs = mandrill_args([{"event": "send"}], auth="") response = self.client.post(**kwargs) self.assertEqual(response.status_code, 200) @override_settings( - ALLOWED_HOSTS=['127.0.0.1', '.example.com'], + ALLOWED_HOSTS=["127.0.0.1", ".example.com"], ANYMAIL={ "MANDRILL_WEBHOOK_URL": "https://abcde:12345@example.com/anymail/mandrill/", "WEBHOOK_SECRET": "abcde:12345", - }) + }, + ) def test_webhook_url_setting(self): # If Django can't build_absolute_uri correctly (e.g., because your proxy # frontend isn't setting the proxy headers correctly), you must set - # MANDRILL_WEBHOOK_URL to the actual public url where Mandrill calls the webhook. + # MANDRILL_WEBHOOK_URL to the actual public url where Mandrill calls + # the webhook. self.set_basic_auth("abcde", "12345") - kwargs = mandrill_args([{'event': 'send'}], host="https://example.com/", auth="abcde:12345") + kwargs = mandrill_args( + [{"event": "send"}], host="https://example.com/", auth="abcde:12345" + ) response = self.client.post(SERVER_NAME="127.0.0.1", **kwargs) self.assertEqual(response.status_code, 200) # override WebhookBasicAuthTestCase version of this test - @override_settings(ANYMAIL={'WEBHOOK_SECRET': ['cred1:pass1', 'cred2:pass2']}) + @override_settings(ANYMAIL={"WEBHOOK_SECRET": ["cred1:pass1", "cred2:pass2"]}) def test_supports_credential_rotation(self): """You can supply a list of basic auth credentials, and any is allowed""" - self.set_basic_auth('cred1', 'pass1') + self.set_basic_auth("cred1", "pass1") response = self.client.post(**mandrill_args(auth="cred1:pass1")) self.assertEqual(response.status_code, 200) - self.set_basic_auth('cred2', 'pass2') + self.set_basic_auth("cred2", "pass2") response = self.client.post(**mandrill_args(auth="cred2:pass2")) self.assertEqual(response.status_code, 200) - self.set_basic_auth('baduser', 'wrongpassword') + self.set_basic_auth("baduser", "wrongpassword") response = self.client.post(**mandrill_args(auth="baduser:wrongpassword")) self.assertEqual(response.status_code, 400) -@tag('mandrill') +@tag("mandrill") @override_settings(ANYMAIL_MANDRILL_WEBHOOK_KEY=TEST_WEBHOOK_KEY) class MandrillTrackingTestCase(WebhookTestCase): - def test_head_request(self): # Mandrill verifies webhooks at config time with a HEAD request - # (See MandrillWebhookSettingsTestCase above for equivalent without the key yet set) - response = self.client.head('/anymail/mandrill/tracking/') + # (See MandrillWebhookSettingsTestCase above for equivalent + # without the key yet set) + response = self.client.head("/anymail/mandrill/tracking/") self.assertEqual(response.status_code, 200) def test_post_request_invalid_json(self): kwargs = mandrill_args() - kwargs['data'] = {'mandrill_events': "GARBAGE DATA"} + kwargs["data"] = {"mandrill_events": "GARBAGE DATA"} response = self.client.post(**kwargs) self.assertEqual(response.status_code, 400) def test_send_event(self): - raw_events = [{ - "event": "send", - "msg": { - "ts": 1461095211, # time send called - "subject": "Webhook Test", - "email": "recipient@example.com", - "sender": "sender@example.com", - "tags": ["tag1", "tag2"], - "metadata": {"custom1": "value1", "custom2": "value2"}, - "_id": "abcdef012345789abcdef012345789" - }, - "_id": "abcdef012345789abcdef012345789", - "ts": 1461095246 # time of event - }] + raw_events = [ + { + "event": "send", + "msg": { + "ts": 1461095211, # time send called + "subject": "Webhook Test", + "email": "recipient@example.com", + "sender": "sender@example.com", + "tags": ["tag1", "tag2"], + "metadata": {"custom1": "value1", "custom2": "value2"}, + "_id": "abcdef012345789abcdef012345789", + }, + "_id": "abcdef012345789abcdef012345789", + "ts": 1461095246, # time of event + } + ] response = self.client.post(**mandrill_args(events=raw_events)) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MandrillCombinedWebhookView, - event=ANY, esp_name='Mandrill') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MandrillCombinedWebhookView, + event=ANY, + esp_name="Mandrill", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "sent") - self.assertEqual(event.timestamp, datetime(2016, 4, 19, 19, 47, 26, tzinfo=timezone.utc)) + self.assertEqual( + event.timestamp, datetime(2016, 4, 19, 19, 47, 26, tzinfo=timezone.utc) + ) self.assertEqual(event.esp_event, raw_events[0]) self.assertEqual(event.message_id, "abcdef012345789abcdef012345789") self.assertEqual(event.recipient, "recipient@example.com") @@ -173,100 +195,129 @@ class MandrillTrackingTestCase(WebhookTestCase): self.assertEqual(event.metadata, {"custom1": "value1", "custom2": "value2"}) def test_hard_bounce_event(self): - raw_events = [{ - "event": "hard_bounce", - "msg": { - "ts": 1461095211, # time send called - "subject": "Webhook Test", - "email": "bounce@example.com", - "sender": "sender@example.com", - "bounce_description": "bad_mailbox", - "bgtools_code": 10, - "diag": "smtp;550 5.1.1 The email account that you tried to reach does not exist.", - "_id": "abcdef012345789abcdef012345789" - }, - "_id": "abcdef012345789abcdef012345789", - "ts": 1461095246 # time of event - }] + raw_events = [ + { + "event": "hard_bounce", + "msg": { + "ts": 1461095211, # time send called + "subject": "Webhook Test", + "email": "bounce@example.com", + "sender": "sender@example.com", + "bounce_description": "bad_mailbox", + "bgtools_code": 10, + "diag": "smtp;550 5.1.1 The email account that you tried" + " to reach does not exist.", + "_id": "abcdef012345789abcdef012345789", + }, + "_id": "abcdef012345789abcdef012345789", + "ts": 1461095246, # time of event + } + ] response = self.client.post(**mandrill_args(events=raw_events)) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MandrillCombinedWebhookView, - event=ANY, esp_name='Mandrill') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MandrillCombinedWebhookView, + event=ANY, + esp_name="Mandrill", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "bounced") self.assertEqual(event.esp_event, raw_events[0]) self.assertEqual(event.message_id, "abcdef012345789abcdef012345789") self.assertEqual(event.recipient, "bounce@example.com") - self.assertEqual(event.mta_response, - "smtp;550 5.1.1 The email account that you tried to reach does not exist.") + self.assertEqual( + event.mta_response, + "smtp;550 5.1.1 The email account that you tried to reach does not exist.", + ) def test_click_event(self): - raw_events = [{ - "event": "click", - "msg": { - "ts": 1461095211, # time send called - "subject": "Webhook Test", - "email": "recipient@example.com", - "sender": "sender@example.com", - "opens": [{"ts": 1461095242}], - "clicks": [{"ts": 1461095246, "url": "http://example.com"}], - "_id": "abcdef012345789abcdef012345789" - }, - "user_agent": "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0", - "url": "http://example.com", - "_id": "abcdef012345789abcdef012345789", - "ts": 1461095246 # time of event - }] + raw_events = [ + { + "event": "click", + "msg": { + "ts": 1461095211, # time send called + "subject": "Webhook Test", + "email": "recipient@example.com", + "sender": "sender@example.com", + "opens": [{"ts": 1461095242}], + "clicks": [{"ts": 1461095246, "url": "http://example.com"}], + "_id": "abcdef012345789abcdef012345789", + }, + "user_agent": "Mozilla/5.0 (Windows NT 5.1; rv:11.0)" + " Gecko Firefox/11.0", + "url": "http://example.com", + "_id": "abcdef012345789abcdef012345789", + "ts": 1461095246, # time of event + } + ] response = self.client.post(**mandrill_args(events=raw_events)) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MandrillCombinedWebhookView, - event=ANY, esp_name='Mandrill') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MandrillCombinedWebhookView, + event=ANY, + esp_name="Mandrill", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "clicked") self.assertEqual(event.esp_event, raw_events[0]) self.assertEqual(event.click_url, "http://example.com") - self.assertEqual(event.user_agent, "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0") + self.assertEqual( + event.user_agent, "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0" + ) def test_sync_event(self): # Mandrill sync events use a different format from other events # https://mandrill.zendesk.com/hc/en-us/articles/205583297-Sync-Event-Webhook-format - raw_events = [{ - "type": "blacklist", - "action": "add", - "reject": { - "email": "recipient@example.com", - "reason": "manual edit" + raw_events = [ + { + "type": "blacklist", + "action": "add", + "reject": {"email": "recipient@example.com", "reason": "manual edit"}, } - }] + ] response = self.client.post(**mandrill_args(events=raw_events)) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=MandrillCombinedWebhookView, - event=ANY, esp_name='Mandrill') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=MandrillCombinedWebhookView, + event=ANY, + esp_name="Mandrill", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "unknown") self.assertEqual(event.recipient, "recipient@example.com") self.assertEqual(event.description, "manual edit") def test_old_tracking_url(self): - # Earlier versions of Anymail used /mandrill/tracking/ (and didn't support inbound); - # make sure that URL continues to work. - raw_events = [{ - "event": "send", - "msg": { - "ts": 1461095211, # time send called - "subject": "Webhook Test", - "email": "recipient@example.com", - "sender": "sender@example.com", - "tags": ["tag1", "tag2"], - "metadata": {"custom1": "value1", "custom2": "value2"}, - "_id": "abcdef012345789abcdef012345789" - }, - "_id": "abcdef012345789abcdef012345789", - "ts": 1461095246 # time of event - }] - response = self.client.post(**mandrill_args(events=raw_events, path='/anymail/mandrill/tracking/')) + # Earlier versions of Anymail used /mandrill/tracking/ (and didn't support + # inbound); make sure that URL continues to work. + raw_events = [ + { + "event": "send", + "msg": { + "ts": 1461095211, # time send called + "subject": "Webhook Test", + "email": "recipient@example.com", + "sender": "sender@example.com", + "tags": ["tag1", "tag2"], + "metadata": {"custom1": "value1", "custom2": "value2"}, + "_id": "abcdef012345789abcdef012345789", + }, + "_id": "abcdef012345789abcdef012345789", + "ts": 1461095246, # time of event + } + ] + response = self.client.post( + **mandrill_args(events=raw_events, path="/anymail/mandrill/tracking/") + ) self.assertEqual(response.status_code, 200) - self.assert_handler_called_once_with(self.tracking_handler, sender=MandrillTrackingWebhookView, - event=ANY, esp_name='Mandrill') + self.assert_handler_called_once_with( + self.tracking_handler, + sender=MandrillTrackingWebhookView, + event=ANY, + esp_name="Mandrill", + ) diff --git a/tests/test_message.py b/tests/test_message.py index ee4199b..82ff032 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -21,15 +21,21 @@ class InlineImageTests(AnymailTestMixin, SimpleTestCase): # an inline attachment filename that causes Gmail to reject the message.) mock_getfqdn.return_value = "server.example.com" cid = attach_inline_image(self.message, sample_image_content()) - self.assertRegex(cid, r"[\w.]+@inline", - "Content-ID should be a valid Message-ID, " - "but _not_ @server.example.com") + self.assertRegex( + cid, + r"[\w.]+@inline", + "Content-ID should be a valid Message-ID, " "but _not_ @server.example.com", + ) def test_domain_override(self): - cid = attach_inline_image(self.message, sample_image_content(), - domain="example.org") - self.assertRegex(cid, r"[\w.]+@example\.org", - "Content-ID should be a valid Message-ID @example.org") + cid = attach_inline_image( + self.message, sample_image_content(), domain="example.org" + ) + self.assertRegex( + cid, + r"[\w.]+@example\.org", + "Content-ID should be a valid Message-ID @example.org", + ) class AnymailStatusTests(AnymailTestMixin, SimpleTestCase): @@ -42,10 +48,14 @@ class AnymailStatusTests(AnymailTestMixin, SimpleTestCase): self.assertEqual(status.status, {"sent"}) self.assertEqual(status.message_id, "12345") self.assertEqual(status.recipients, recipients) - self.assertEqual(repr(status), - "AnymailStatus") - self.assertEqual(repr(status.recipients["one@example.com"]), - "AnymailRecipientStatus('12345', 'sent')") + self.assertEqual( + repr(status), + "AnymailStatus", + ) + self.assertEqual( + repr(status.recipients["one@example.com"]), + "AnymailRecipientStatus('12345', 'sent')", + ) def test_multiple_recipients(self): recipients = { @@ -57,8 +67,11 @@ class AnymailStatusTests(AnymailTestMixin, SimpleTestCase): self.assertEqual(status.status, {"queued", "sent"}) self.assertEqual(status.message_id, {"12345", "45678"}) self.assertEqual(status.recipients, recipients) - self.assertEqual(repr(status), - "AnymailStatus") + self.assertEqual( + repr(status), + "AnymailStatus", + ) def test_multiple_recipients_same_message_id(self): # status.message_id collapses when it's the same for all recipients @@ -69,8 +82,11 @@ class AnymailStatusTests(AnymailTestMixin, SimpleTestCase): status = AnymailStatus() status.set_recipient_status(recipients) self.assertEqual(status.message_id, "12345") - self.assertEqual(repr(status), - "AnymailStatus") + self.assertEqual( + repr(status), + "AnymailStatus", + ) def test_none(self): status = AnymailStatus() diff --git a/tests/test_postal_backend.py b/tests/test_postal_backend.py index 8964ff0..0242b93 100644 --- a/tests/test_postal_backend.py +++ b/tests/test_postal_backend.py @@ -8,16 +8,33 @@ from django.core.exceptions import ImproperlyConfigured from django.test import SimpleTestCase, override_settings, tag from anymail.exceptions import ( - AnymailAPIError, AnymailSerializationError, - AnymailUnsupportedFeature) + AnymailAPIError, + AnymailSerializationError, + AnymailUnsupportedFeature, +) from anymail.message import attach_inline_image_file -from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCases -from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin, decode_att + +from .mock_requests_backend import ( + RequestsBackendMockAPITestCase, + SessionSharingTestCases, +) +from .utils import ( + SAMPLE_IMAGE_FILENAME, + AnymailTestMixin, + decode_att, + sample_image_content, + sample_image_path, +) -@tag('postal') -@override_settings(EMAIL_BACKEND='anymail.backends.postal.EmailBackend', - ANYMAIL={'POSTAL_API_KEY': 'test_server_token', 'POSTAL_API_URL': 'https://postal.example.com'}) +@tag("postal") +@override_settings( + EMAIL_BACKEND="anymail.backends.postal.EmailBackend", + ANYMAIL={ + "POSTAL_API_KEY": "test_server_token", + "POSTAL_API_URL": "https://postal.example.com", + }, +) class PostalBackendMockAPITestCase(RequestsBackendMockAPITestCase): DEFAULT_RAW_RESPONSE = b"""{ "status": "success", @@ -34,25 +51,32 @@ class PostalBackendMockAPITestCase(RequestsBackendMockAPITestCase): def setUp(self): super().setUp() # Simple message useful for many tests - self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com']) + self.message = mail.EmailMultiAlternatives( + "Subject", "Text Body", "from@example.com", ["to@example.com"] + ) -@tag('postal') +@tag("postal") class PostalBackendStandardEmailTests(PostalBackendMockAPITestCase): """Test backend support for Django standard email features""" def test_send_mail(self): """Test basic API for simple send""" - mail.send_mail('Subject here', 'Here is the message.', - 'from@sender.example.com', ['to@example.com'], fail_silently=False) - self.assert_esp_called('/message') + mail.send_mail( + "Subject here", + "Here is the message.", + "from@sender.example.com", + ["to@example.com"], + fail_silently=False, + ) + self.assert_esp_called("/message") headers = self.get_api_call_headers() self.assertEqual(headers["X-Server-API-Key"], "test_server_token") data = self.get_api_call_json() - self.assertEqual(data['subject'], "Subject here") - self.assertEqual(data['plain_body'], "Here is the message.") - self.assertEqual(data['from'], "from@sender.example.com") - self.assertEqual(data['to'], ["to@example.com"]) + self.assertEqual(data["subject"], "Subject here") + self.assertEqual(data["plain_body"], "Here is the message.") + self.assertEqual(data["from"], "from@sender.example.com") + self.assertEqual(data["to"], ["to@example.com"]) def test_name_addr(self): """Make sure RFC2822 name-addr format (with display-name) is allowed @@ -60,93 +84,124 @@ class PostalBackendStandardEmailTests(PostalBackendMockAPITestCase): (Test both sender and recipient addresses) """ msg = mail.EmailMessage( - 'Subject', 'Message', 'From Name ', - ['Recipient #1 ', 'to2@example.com'], - cc=['Carbon Copy ', 'cc2@example.com'], - bcc=['Blind Copy ', 'bcc2@example.com']) + "Subject", + "Message", + "From Name ", + ["Recipient #1 ", "to2@example.com"], + cc=["Carbon Copy ", "cc2@example.com"], + bcc=["Blind Copy ", "bcc2@example.com"], + ) msg.send() data = self.get_api_call_json() - self.assertEqual(data['from'], 'From Name ') - self.assertEqual(data['to'], ['Recipient #1 ', 'to2@example.com']) - self.assertEqual(data['cc'], ['Carbon Copy ', 'cc2@example.com']) - self.assertEqual(data['bcc'], ['Blind Copy ', 'bcc2@example.com']) + self.assertEqual(data["from"], "From Name ") + self.assertEqual( + data["to"], ["Recipient #1 ", "to2@example.com"] + ) + self.assertEqual( + data["cc"], ["Carbon Copy ", "cc2@example.com"] + ) + self.assertEqual( + data["bcc"], ["Blind Copy ", "bcc2@example.com"] + ) def test_email_message(self): email = mail.EmailMessage( - 'Subject', 'Body goes here', 'from@example.com', - ['to1@example.com', 'Also To '], - bcc=['bcc1@example.com', 'Also BCC '], - cc=['cc1@example.com', 'Also CC '], - headers={'Reply-To': 'another@example.com', - 'X-MyHeader': 'my value', - 'Message-ID': 'mycustommsgid@sales.example.com'}) # should override backend msgid + "Subject", + "Body goes here", + "from@example.com", + ["to1@example.com", "Also To "], + bcc=["bcc1@example.com", "Also BCC "], + cc=["cc1@example.com", "Also CC "], + headers={ + "Reply-To": "another@example.com", + "X-MyHeader": "my value", + # should override backend msgid: + "Message-ID": "mycustommsgid@sales.example.com", + }, + ) email.send() data = self.get_api_call_json() - self.assertEqual(data['subject'], "Subject") - self.assertEqual(data['plain_body'], "Body goes here") - self.assertEqual(data['from'], "from@example.com") - self.assertEqual(data['to'], ['to1@example.com', 'Also To ']) - self.assertEqual(data['bcc'], ['bcc1@example.com', 'Also BCC ']) - self.assertEqual(data['cc'], ['cc1@example.com', 'Also CC ']) - self.assertEqual(data['reply_to'], 'another@example.com') - self.assertCountEqual(data['headers'], { - 'Message-ID': 'mycustommsgid@sales.example.com', - 'X-MyHeader': 'my value' - }) + self.assertEqual(data["subject"], "Subject") + self.assertEqual(data["plain_body"], "Body goes here") + self.assertEqual(data["from"], "from@example.com") + self.assertEqual(data["to"], ["to1@example.com", "Also To "]) + self.assertEqual( + data["bcc"], ["bcc1@example.com", "Also BCC "] + ) + self.assertEqual(data["cc"], ["cc1@example.com", "Also CC "]) + self.assertEqual(data["reply_to"], "another@example.com") + self.assertCountEqual( + data["headers"], + {"Message-ID": "mycustommsgid@sales.example.com", "X-MyHeader": "my value"}, + ) def test_html_message(self): - text_content = 'This is an important message.' - html_content = '

This is an important message.

' - email = mail.EmailMultiAlternatives('Subject', text_content, - 'from@example.com', ['to@example.com']) + text_content = "This is an important message." + html_content = "

This is an important message.

" + email = mail.EmailMultiAlternatives( + "Subject", text_content, "from@example.com", ["to@example.com"] + ) email.attach_alternative(html_content, "text/html") email.send() data = self.get_api_call_json() - self.assertEqual(data['plain_body'], text_content) - self.assertEqual(data['html_body'], html_content) + self.assertEqual(data["plain_body"], text_content) + self.assertEqual(data["html_body"], html_content) # Don't accidentally send the html part as an attachment: - self.assertNotIn('attachments', data) + self.assertNotIn("attachments", data) def test_html_only_message(self): - html_content = '

This is an important message.

' - email = mail.EmailMessage('Subject', html_content, 'from@example.com', ['to@example.com']) + html_content = "

This is an important message.

" + email = mail.EmailMessage( + "Subject", html_content, "from@example.com", ["to@example.com"] + ) email.content_subtype = "html" # Main content is now text/html email.send() data = self.get_api_call_json() - self.assertNotIn('plain_body', data) - self.assertEqual(data['html_body'], html_content) + self.assertNotIn("plain_body", data) + self.assertEqual(data["html_body"], html_content) def test_extra_headers(self): - self.message.extra_headers = {'X-Custom': 'string', 'X-Num': 123} + self.message.extra_headers = {"X-Custom": "string", "X-Num": 123} self.message.send() data = self.get_api_call_json() - self.assertCountEqual(data['headers'], { - 'X-Custom': 'string', - 'X-Num': 123 - }) + self.assertCountEqual(data["headers"], {"X-Custom": "string", "X-Num": 123}) def test_extra_headers_serialization_error(self): - self.message.extra_headers = {'X-Custom': Decimal(12.5)} + self.message.extra_headers = {"X-Custom": Decimal(12.5)} with self.assertRaisesMessage(AnymailSerializationError, "Decimal"): self.message.send() - @override_settings(ANYMAIL_IGNORE_UNSUPPORTED_FEATURES=True) # Postal only allows single reply-to + @override_settings(ANYMAIL_IGNORE_UNSUPPORTED_FEATURES=True) def test_reply_to(self): - email = mail.EmailMessage('Subject', 'Body goes here', 'from@example.com', ['to1@example.com'], - reply_to=['reply@example.com', 'Other ']) + # Postal only allows single reply-to. Test handling for multiple reply + # addresses when ignoring errors: + email = mail.EmailMessage( + "Subject", + "Body goes here", + "from@example.com", + ["to1@example.com"], + reply_to=["reply@example.com", "Other "], + ) email.send() data = self.get_api_call_json() - self.assertEqual(data['reply_to'], 'reply@example.com') # keeps first email + self.assertEqual(data["reply_to"], "reply@example.com") # keeps first email def test_multiple_reply_to(self): - email = mail.EmailMessage('Subject', 'Body goes here', 'from@example.com', ['to1@example.com'], - reply_to=['reply@example.com', 'Other ']) + email = mail.EmailMessage( + "Subject", + "Body goes here", + "from@example.com", + ["to1@example.com"], + reply_to=["reply@example.com", "Other "], + ) with self.assertRaises(AnymailUnsupportedFeature): email.send() def test_attachments(self): text_content = "* Item one\n* Item two\n* Item three" - self.message.attach(filename="test.txt", content=text_content, mimetype="text/plain") + self.message.attach( + filename="test.txt", content=text_content, mimetype="text/plain" + ) # Should guess mimetype if not provided... png_content = b"PNG\xb4 pretend this is the contents of a png file" @@ -154,19 +209,22 @@ class PostalBackendStandardEmailTests(PostalBackendMockAPITestCase): # Should work with a MIMEBase object (also tests no filename)... pdf_content = b"PDF\xb4 pretend this is valid pdf data" - mimeattachment = MIMEBase('application', 'pdf') + mimeattachment = MIMEBase("application", "pdf") mimeattachment.set_payload(pdf_content) self.message.attach(mimeattachment) self.message.send() data = self.get_api_call_json() - attachments = data['attachments'] + attachments = data["attachments"] self.assertEqual(len(attachments), 3) self.assertEqual(attachments[0]["name"], "test.txt") self.assertEqual(attachments[0]["content_type"], "text/plain") - self.assertEqual(decode_att(attachments[0]["data"]).decode('ascii'), text_content) + self.assertEqual( + decode_att(attachments[0]["data"]).decode("ascii"), text_content + ) - self.assertEqual(attachments[1]["content_type"], "image/png") # inferred from filename + # content_type inferred from filename: + self.assertEqual(attachments[1]["content_type"], "image/png") self.assertEqual(attachments[1]["name"], "test.png") self.assertEqual(decode_att(attachments[1]["data"]), png_content) @@ -175,24 +233,33 @@ class PostalBackendStandardEmailTests(PostalBackendMockAPITestCase): self.assertEqual(decode_att(attachments[2]["data"]), pdf_content) def test_unicode_attachment_correctly_decoded(self): - self.message.attach("Une pièce jointe.html", '

\u2019

', mimetype='text/html') + self.message.attach( + "Une pièce jointe.html", "

\u2019

", mimetype="text/html" + ) self.message.send() data = self.get_api_call_json() - self.assertEqual(data['attachments'], [{ - 'name': 'Une pièce jointe.html', - 'content_type': 'text/html', - 'data': b64encode('

\u2019

'.encode('utf-8')).decode('ascii') - }]) + self.assertEqual( + data["attachments"], + [ + { + "name": "Une pièce jointe.html", + "content_type": "text/html", + "data": b64encode("

\u2019

".encode("utf-8")).decode("ascii"), + } + ], + ) def test_embedded_images(self): image_filename = SAMPLE_IMAGE_FILENAME image_path = sample_image_path(image_filename) cid = attach_inline_image_file(self.message, image_path) # Read from a png file - html_content = '

This has an inline image.

' % cid + html_content = ( + '

This has an inline image.

' % cid + ) self.message.attach_alternative(html_content, "text/html") - with self.assertRaisesMessage(AnymailUnsupportedFeature, 'inline attachments'): + with self.assertRaisesMessage(AnymailUnsupportedFeature, "inline attachments"): self.message.send() def test_attached_images(self): @@ -200,33 +267,38 @@ class PostalBackendStandardEmailTests(PostalBackendMockAPITestCase): image_path = sample_image_path(image_filename) image_data = sample_image_content(image_filename) - self.message.attach_file(image_path) # option 1: attach as a file + # option 1: attach as a file + self.message.attach_file(image_path) - image = MIMEImage(image_data) # option 2: construct the MIMEImage and attach it directly + # option 2: construct the MIMEImage and attach it directly + image = MIMEImage(image_data) self.message.attach(image) - image_data_b64 = b64encode(image_data).decode('ascii') + image_data_b64 = b64encode(image_data).decode("ascii") self.message.send() data = self.get_api_call_json() - self.assertEqual(data['attachments'], [ - { - 'name': image_filename, # the named one - 'content_type': 'image/png', - 'data': image_data_b64, - }, - { - 'name': '', # the unnamed one - 'content_type': 'image/png', - 'data': image_data_b64, - }, - ]) + self.assertEqual( + data["attachments"], + [ + { + "name": image_filename, # the named one + "content_type": "image/png", + "data": image_data_b64, + }, + { + "name": "", # the unnamed one + "content_type": "image/png", + "data": image_data_b64, + }, + ], + ) def test_multiple_html_alternatives(self): # Multiple alternatives not allowed self.message.attach_alternative("

First html is OK

", "text/html") self.message.attach_alternative("

But not second html

", "text/html") - with self.assertRaisesMessage(AnymailUnsupportedFeature, 'multiple html parts'): + with self.assertRaisesMessage(AnymailUnsupportedFeature, "multiple html parts"): self.message.send() def test_html_alternative(self): @@ -246,16 +318,16 @@ class PostalBackendStandardEmailTests(PostalBackendMockAPITestCase): """Empty to, cc, bcc, and reply_to shouldn't generate empty fields""" self.message.send() data = self.get_api_call_json() - self.assertNotIn('Cc', data) - self.assertNotIn('Bcc', data) - self.assertNotIn('ReplyTo', data) + self.assertNotIn("Cc", data) + self.assertNotIn("Bcc", data) + self.assertNotIn("ReplyTo", data) - # Test empty `to` -- but send requires at least one recipient somewhere (like cc) + # Test empty `to`--but send requires at least one recipient somewhere (like cc) self.message.to = [] - self.message.cc = ['cc@example.com'] + self.message.cc = ["cc@example.com"] self.message.send() data = self.get_api_call_json() - self.assertNotIn('To', data) + self.assertNotIn("To", data) def test_api_failure(self): failure_response = b"""{ @@ -268,11 +340,17 @@ class PostalBackendStandardEmailTests(PostalBackendMockAPITestCase): }""" self.set_mock_response(status_code=200, raw=failure_response) with self.assertRaisesMessage(AnymailAPIError, "Postal API response 200"): - mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com']) + mail.send_mail("Subject", "Body", "from@example.com", ["to@example.com"]) # Make sure fail_silently is respected self.set_mock_response(status_code=200, raw=failure_response) - sent = mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com'], fail_silently=True) + sent = mail.send_mail( + "Subject", + "Body", + "from@example.com", + ["to@example.com"], + fail_silently=True, + ) self.assertEqual(sent, 0) def test_api_error_includes_details(self): @@ -301,7 +379,7 @@ class PostalBackendStandardEmailTests(PostalBackendMockAPITestCase): self.message.send() -@tag('postal') +@tag("postal") class PostalBackendAnymailFeatureTests(PostalBackendMockAPITestCase): """Test backend support for Anymail added features""" @@ -312,33 +390,33 @@ class PostalBackendAnymailFeatureTests(PostalBackendMockAPITestCase): self.assertEqual(data["sender"], "anything@bounces.example.com") def test_metadata(self): - self.message.metadata = {'user_id': "12345", 'items': 6} - with self.assertRaisesMessage(AnymailUnsupportedFeature, 'metadata'): + self.message.metadata = {"user_id": "12345", "items": 6} + with self.assertRaisesMessage(AnymailUnsupportedFeature, "metadata"): self.message.send() def test_send_at(self): self.message.send_at = 1651820889 # 2022-05-06 07:08:09 UTC - with self.assertRaisesMessage(AnymailUnsupportedFeature, 'send_at'): + with self.assertRaisesMessage(AnymailUnsupportedFeature, "send_at"): self.message.send() def test_tags(self): self.message.tags = ["receipt"] self.message.send() data = self.get_api_call_json() - self.assertEqual(data['tag'], "receipt") + self.assertEqual(data["tag"], "receipt") self.message.tags = ["receipt", "repeat-user"] - with self.assertRaisesMessage(AnymailUnsupportedFeature, 'multiple tags'): + with self.assertRaisesMessage(AnymailUnsupportedFeature, "multiple tags"): self.message.send() def test_track_opens(self): self.message.track_opens = True - with self.assertRaisesMessage(AnymailUnsupportedFeature, 'track_opens'): + with self.assertRaisesMessage(AnymailUnsupportedFeature, "track_opens"): self.message.send() def test_track_clicks(self): self.message.track_clicks = True - with self.assertRaisesMessage(AnymailUnsupportedFeature, 'track_clicks'): + with self.assertRaisesMessage(AnymailUnsupportedFeature, "track_clicks"): self.message.send() def test_default_omits_options(self): @@ -350,19 +428,19 @@ class PostalBackendAnymailFeatureTests(PostalBackendMockAPITestCase): """ self.message.send() data = self.get_api_call_json() - self.assertNotIn('tag', data) + self.assertNotIn("tag", data) def test_esp_extra(self): self.message.esp_extra = { - 'future_postal_option': 'some-value', + "future_postal_option": "some-value", } self.message.send() data = self.get_api_call_json() - self.assertEqual(data['future_postal_option'], 'some-value') + self.assertEqual(data["future_postal_option"], "some-value") # noinspection PyUnresolvedReferences def test_send_attaches_anymail_status(self): - """ The anymail_status should be attached to the message when it is sent """ + """The anymail_status should be attached to the message when it is sent""" response_content = b"""{ "status": "success", "time": 1.08, @@ -375,14 +453,22 @@ class PostalBackendAnymailFeatureTests(PostalBackendMockAPITestCase): } }""" self.set_mock_response(raw=response_content) - msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['Recipient '],) + msg = mail.EmailMessage( + "Subject", + "Message", + "from@example.com", + ["Recipient "], + ) sent = msg.send() self.assertEqual(sent, 1) - self.assertEqual(msg.anymail_status.status, {'queued'}) + self.assertEqual(msg.anymail_status.status, {"queued"}) self.assertEqual(msg.anymail_status.message_id, 1531) - self.assertEqual(msg.anymail_status.recipients['to1@example.com'].status, 'queued') - self.assertEqual(msg.anymail_status.recipients['to1@example.com'].message_id, - 1531) + self.assertEqual( + msg.anymail_status.recipients["to1@example.com"].status, "queued" + ) + self.assertEqual( + msg.anymail_status.recipients["to1@example.com"].message_id, 1531 + ) self.assertEqual(msg.anymail_status.esp_response.content, response_content) # noinspection PyUnresolvedReferences @@ -400,19 +486,27 @@ class PostalBackendAnymailFeatureTests(PostalBackendMockAPITestCase): } }""" self.set_mock_response(raw=response_content) - msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', cc=['cc@example.com'],) + 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, {'queued'}) + self.assertEqual(msg.anymail_status.status, {"queued"}) self.assertEqual(msg.anymail_status.message_id, 1531) - self.assertEqual(msg.anymail_status.recipients['cc@example.com'].status, 'queued') - self.assertEqual(msg.anymail_status.recipients['cc@example.com'].message_id, - 1531) + self.assertEqual( + msg.anymail_status.recipients["cc@example.com"].status, "queued" + ) + self.assertEqual( + msg.anymail_status.recipients["cc@example.com"].message_id, 1531 + ) 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""" + """If the send fails, anymail_status should contain initial values""" self.set_mock_response(status_code=500) sent = self.message.send(fail_silently=True) self.assertEqual(sent, 0) @@ -423,9 +517,12 @@ class PostalBackendAnymailFeatureTests(PostalBackendMockAPITestCase): # noinspection PyUnresolvedReferences def test_send_unparsable_response(self): - """If the send succeeds, but a non-JSON API response, should raise an API exception""" - mock_response = self.set_mock_response(status_code=200, - raw=b"yikes, this isn't a real response") + """ + If the send succeeds, but a non-JSON API response, should raise an API exception + """ + mock_response = self.set_mock_response( + status_code=200, raw=b"yikes, this isn't a real response" + ) with self.assertRaises(AnymailAPIError): self.message.send() self.assertIsNone(self.message.anymail_status.status) @@ -435,17 +532,19 @@ class PostalBackendAnymailFeatureTests(PostalBackendMockAPITestCase): def test_json_serialization_errors(self): """Try to provide more information about non-json-serializable data""" - self.message.tags = [Decimal('19.99')] # yeah, don't do this + self.message.tags = [Decimal("19.99")] # yeah, don't do this with self.assertRaises(AnymailSerializationError) as cm: self.message.send() print(self.get_api_call_json()) err = cm.exception self.assertIsInstance(err, TypeError) # compatibility with json.dumps - self.assertIn("Don't know how to send this data to Postal", str(err)) # our added context - self.assertRegex(str(err), r"Decimal.*is not JSON serializable") # original message + # our added context: + self.assertIn("Don't know how to send this data to Postal", str(err)) + # original message: + self.assertRegex(str(err), r"Decimal.*is not JSON serializable") -@tag('postal') +@tag("postal") class PostalBackendRecipientsRefusedTests(PostalBackendMockAPITestCase): # Postal doesn't check email bounce or complaint lists at time of send -- # it always just queues the message. You'll need to listen for the "rejected" @@ -453,20 +552,23 @@ class PostalBackendRecipientsRefusedTests(PostalBackendMockAPITestCase): pass -@tag('postal') -class PostalBackendSessionSharingTestCase(SessionSharingTestCases, PostalBackendMockAPITestCase): +@tag("postal") +class PostalBackendSessionSharingTestCase( + SessionSharingTestCases, PostalBackendMockAPITestCase +): """Requests session sharing tests""" + pass # tests are defined in SessionSharingTestCases -@tag('postal') +@tag("postal") @override_settings(EMAIL_BACKEND="anymail.backends.postal.EmailBackend") class PostalBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase): """Test ESP backend without required settings in place""" def test_missing_api_key(self): with self.assertRaises(ImproperlyConfigured) as cm: - mail.send_mail('Subject', 'Message', 'from@example.com', ['to@example.com']) + mail.send_mail("Subject", "Message", "from@example.com", ["to@example.com"]) errmsg = str(cm.exception) - self.assertRegex(errmsg, r'\bPOSTAL_API_KEY\b') - self.assertRegex(errmsg, r'\bANYMAIL_POSTAL_API_KEY\b') + self.assertRegex(errmsg, r"\bPOSTAL_API_KEY\b") + self.assertRegex(errmsg, r"\bANYMAIL_POSTAL_API_KEY\b") diff --git a/tests/test_postal_inbound.py b/tests/test_postal_inbound.py index 1317b5e..42f8d4b 100644 --- a/tests/test_postal_inbound.py +++ b/tests/test_postal_inbound.py @@ -10,13 +10,16 @@ from anymail.exceptions import AnymailConfigurationError from anymail.inbound import AnymailInboundMessage from anymail.signals import AnymailInboundEvent from anymail.webhooks.postal import PostalInboundWebhookView -from .utils import sample_image_content, sample_email_content + +from .utils import sample_email_content, sample_image_content from .utils_postal import ClientWithPostalSignature, make_key from .webhook_cases import WebhookTestCase -@tag('postal') -@unittest.skipUnless(ClientWithPostalSignature, "Install 'cryptography' to run postal webhook tests") +@tag("postal") +@unittest.skipUnless( + ClientWithPostalSignature, "Install 'cryptography' to run postal webhook tests" +) class PostalInboundTestCase(WebhookTestCase): client_class = ClientWithPostalSignature @@ -31,7 +34,9 @@ class PostalInboundTestCase(WebhookTestCase): "id": 233980, "rcpt_to": "test@inbound.example.com", "mail_from": "envelope-from@example.org", - "message": b64encode(dedent("""\ + "message": b64encode( + dedent( + """\ Received: from mail.example.org by postal.example.com ... Received: by mail.example.org for ... DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.org; ... @@ -58,20 +63,30 @@ class PostalInboundTestCase(WebhookTestCase):
It's a body=E2=80=A6
--94eb2c05e174adb140055b6339c5-- - """).encode('utf-8')).decode('ascii'), + """ # NOQA: E501 + ).encode("utf-8") + ).decode("ascii"), "base64": True, } - response = self.client.post('/anymail/postal/inbound/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/postal/inbound/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.inbound_handler, sender=PostalInboundWebhookView, - event=ANY, esp_name='Postal') + kwargs = self.assert_handler_called_once_with( + self.inbound_handler, + sender=PostalInboundWebhookView, + event=ANY, + esp_name="Postal", + ) # AnymailInboundEvent - event = kwargs['event'] + event = kwargs["event"] self.assertIsInstance(event, AnymailInboundEvent) - self.assertEqual(event.event_type, 'inbound') - self.assertIsNone(event.timestamp) # Postal doesn't provide inbound event timestamp + self.assertEqual(event.event_type, "inbound") + # Postal doesn't provide inbound event timestamp: + self.assertIsNone(event.timestamp) self.assertEqual(event.event_id, 233980) self.assertIsInstance(event.message, AnymailInboundMessage) self.assertEqual(event.esp_event, raw_event) @@ -79,36 +94,44 @@ class PostalInboundTestCase(WebhookTestCase): # AnymailInboundMessage - convenience properties message = event.message - self.assertEqual(message.from_email.display_name, 'Displayed From') - self.assertEqual(message.from_email.addr_spec, 'from+test@example.org') - self.assertEqual([str(e) for e in message.to], - ['Test Inbound ', 'other@example.com']) - self.assertEqual([str(e) for e in message.cc], - ['cc@example.com']) - self.assertEqual(message.subject, 'Test subject') + self.assertEqual(message.from_email.display_name, "Displayed From") + self.assertEqual(message.from_email.addr_spec, "from+test@example.org") + self.assertEqual( + [str(e) for e in message.to], + ["Test Inbound ", "other@example.com"], + ) + self.assertEqual([str(e) for e in message.cc], ["cc@example.com"]) + self.assertEqual(message.subject, "Test subject") self.assertEqual(message.date.isoformat(" "), "2017-10-11 18:31:04-07:00") self.assertEqual(message.text, "It's a body\N{HORIZONTAL ELLIPSIS}\n") - self.assertEqual(message.html, """
It's a body\N{HORIZONTAL ELLIPSIS}
\n""") + self.assertEqual( + message.html, + """
It's a body\N{HORIZONTAL ELLIPSIS}
\n""", + ) - self.assertEqual(message.envelope_sender, 'envelope-from@example.org') - self.assertEqual(message.envelope_recipient, 'test@inbound.example.com') + self.assertEqual(message.envelope_sender, "envelope-from@example.org") + self.assertEqual(message.envelope_recipient, "test@inbound.example.com") self.assertIsNone(message.stripped_text) self.assertIsNone(message.stripped_html) self.assertIsNone(message.spam_detected) self.assertIsNone(message.spam_score) # AnymailInboundMessage - other headers - self.assertEqual(message['Message-ID'], "") - self.assertEqual(message.get_all('Received'), [ - "from mail.example.org by postal.example.com ...", - "by mail.example.org for ...", - "by 10.10.1.71 with HTTP; Wed, 11 Oct 2017 18:31:04 -0700 (PDT)", - ]) + self.assertEqual(message["Message-ID"], "") + self.assertEqual( + message.get_all("Received"), + [ + "from mail.example.org by postal.example.com ...", + "by mail.example.org for ...", + "by 10.10.1.71 with HTTP; Wed, 11 Oct 2017 18:31:04 -0700 (PDT)", + ], + ) def test_attachments(self): image_content = sample_image_content() email_content = sample_email_content() - raw_mime = dedent("""\ + raw_mime = dedent( + """\ MIME-Version: 1.0 From: from@example.org Subject: Attachments @@ -143,41 +166,59 @@ class PostalInboundTestCase(WebhookTestCase): {email_content} --boundary0-- - """).format(image_content_base64=b64encode(image_content).decode('ascii'), - email_content=email_content.decode('ascii')) + """ # NOQA: E501 + ).format( + image_content_base64=b64encode(image_content).decode("ascii"), + email_content=email_content.decode("ascii"), + ) raw_event = { "id": 233980, "rcpt_to": "test@inbound.example.com", "mail_from": "envelope-from@example.org", - "message": b64encode(raw_mime.encode('utf-8')).decode('ascii'), + "message": b64encode(raw_mime.encode("utf-8")).decode("ascii"), "base64": True, } - response = self.client.post('/anymail/postal/inbound/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/postal/inbound/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.inbound_handler, sender=PostalInboundWebhookView, - event=ANY, esp_name='Postal') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.inbound_handler, + sender=PostalInboundWebhookView, + event=ANY, + esp_name="Postal", + ) + event = kwargs["event"] message = event.message attachments = message.attachments # AnymailInboundMessage convenience accessor self.assertEqual(len(attachments), 2) - self.assertEqual(attachments[0].get_filename(), 'test.txt') - self.assertEqual(attachments[0].get_content_type(), 'text/plain') - self.assertEqual(attachments[0].get_content_text(), 'test attachment') - self.assertEqual(attachments[1].get_content_type(), 'message/rfc822') - self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content) + self.assertEqual(attachments[0].get_filename(), "test.txt") + self.assertEqual(attachments[0].get_content_type(), "text/plain") + self.assertEqual(attachments[0].get_content_text(), "test attachment") + self.assertEqual(attachments[1].get_content_type(), "message/rfc822") + self.assertEqualIgnoringHeaderFolding( + attachments[1].get_content_bytes(), email_content + ) inlines = message.inline_attachments self.assertEqual(len(inlines), 1) - inline = inlines['abc123'] - self.assertEqual(inline.get_filename(), 'image.png') - self.assertEqual(inline.get_content_type(), 'image/png') + inline = inlines["abc123"] + self.assertEqual(inline.get_filename(), "image.png") + self.assertEqual(inline.get_content_type(), "image/png") self.assertEqual(inline.get_content_bytes(), image_content) def test_misconfigured_tracking(self): - errmsg = "You seem to have set Postal's *tracking* webhook to Anymail's Postal *inbound* webhook URL." - with self.assertRaisesMessage(AnymailConfigurationError, errmsg): - self.client.post('/anymail/postal/inbound/', content_type='application/json', - data=json.dumps({"status": "Held"})) + with self.assertRaisesMessage( + AnymailConfigurationError, + "You seem to have set Postal's *tracking* webhook" + " to Anymail's Postal *inbound* webhook URL.", + ): + self.client.post( + "/anymail/postal/inbound/", + content_type="application/json", + data=json.dumps({"status": "Held"}), + ) diff --git a/tests/test_postal_integration.py b/tests/test_postal_integration.py index 37c81eb..abe1f2b 100644 --- a/tests/test_postal_integration.py +++ b/tests/test_postal_integration.py @@ -9,20 +9,24 @@ from anymail.message import AnymailMessage from .utils import AnymailTestMixin - -ANYMAIL_TEST_POSTAL_API_KEY = os.getenv('ANYMAIL_TEST_POSTAL_API_KEY') -ANYMAIL_TEST_POSTAL_API_URL = os.getenv('ANYMAIL_TEST_POSTAL_API_URL') -ANYMAIL_TEST_POSTAL_DOMAIN = os.getenv('ANYMAIL_TEST_POSTAL_DOMAIN') +ANYMAIL_TEST_POSTAL_API_KEY = os.getenv("ANYMAIL_TEST_POSTAL_API_KEY") +ANYMAIL_TEST_POSTAL_API_URL = os.getenv("ANYMAIL_TEST_POSTAL_API_URL") +ANYMAIL_TEST_POSTAL_DOMAIN = os.getenv("ANYMAIL_TEST_POSTAL_DOMAIN") -@tag('postal', 'live') +@tag("postal", "live") @unittest.skipUnless( - ANYMAIL_TEST_POSTAL_API_KEY and ANYMAIL_TEST_POSTAL_API_URL and ANYMAIL_TEST_POSTAL_DOMAIN, - "Set ANYMAIL_TEST_POSTAL_API_KEY and ANYMAIL_TEST_POSTAL_API_URL and ANYMAIL_TEST_POSTAL_DOMAIN " - "environment variables to run Postal integration tests") -@override_settings(ANYMAIL_POSTAL_API_KEY=ANYMAIL_TEST_POSTAL_API_KEY, - ANYMAIL_POSTAL_API_URL=ANYMAIL_TEST_POSTAL_API_URL, - EMAIL_BACKEND="anymail.backends.postal.EmailBackend") + ANYMAIL_TEST_POSTAL_API_KEY + and ANYMAIL_TEST_POSTAL_API_URL + and ANYMAIL_TEST_POSTAL_DOMAIN, + "Set ANYMAIL_TEST_POSTAL_API_KEY and ANYMAIL_TEST_POSTAL_API_URL and" + " ANYMAIL_TEST_POSTAL_DOMAIN environment variables to run Postal integration tests", +) +@override_settings( + ANYMAIL_POSTAL_API_KEY=ANYMAIL_TEST_POSTAL_API_KEY, + ANYMAIL_POSTAL_API_URL=ANYMAIL_TEST_POSTAL_API_URL, + EMAIL_BACKEND="anymail.backends.postal.EmailBackend", +) class PostalBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): """Postal API integration tests @@ -34,10 +38,14 @@ class PostalBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): def setUp(self): super().setUp() - self.from_email = 'from@%s' % ANYMAIL_TEST_POSTAL_DOMAIN - self.message = AnymailMessage('Anymail Postal integration test', 'Text content', - self.from_email, ['test+to1@anymail.dev']) - self.message.attach_alternative('

HTML content

', "text/html") + self.from_email = "from@%s" % ANYMAIL_TEST_POSTAL_DOMAIN + self.message = AnymailMessage( + "Anymail Postal integration test", + "Text content", + self.from_email, + ["test+to1@anymail.dev"], + ) + self.message.attach_alternative("

HTML content

", "text/html") def test_simple_send(self): # Example of getting the Postal send status and message id from the message @@ -45,12 +53,13 @@ class PostalBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): self.assertEqual(sent_count, 1) anymail_status = self.message.anymail_status - sent_status = anymail_status.recipients['test+to1@anymail.dev'].status - message_id = anymail_status.recipients['test+to1@anymail.dev'].message_id + sent_status = anymail_status.recipients["test+to1@anymail.dev"].status + message_id = anymail_status.recipients["test+to1@anymail.dev"].message_id - self.assertEqual(sent_status, 'queued') + self.assertEqual(sent_status, "queued") self.assertGreater(len(message_id), 0) # non-empty string - self.assertEqual(anymail_status.status, {sent_status}) # set of all recipient statuses + # set of all recipient statuses: + self.assertEqual(anymail_status.status, {sent_status}) self.assertEqual(anymail_status.message_id, message_id) def test_all_options(self): @@ -70,23 +79,32 @@ class PostalBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv") message.send() - self.assertEqual(message.anymail_status.status, {'queued'}) - self.assertEqual(message.anymail_status.recipients['test+to1@anymail.dev'].status, 'queued') - self.assertEqual(message.anymail_status.recipients['test+to2@anymail.dev'].status, 'queued') + self.assertEqual(message.anymail_status.status, {"queued"}) + self.assertEqual( + message.anymail_status.recipients["test+to1@anymail.dev"].status, "queued" + ) + self.assertEqual( + message.anymail_status.recipients["test+to2@anymail.dev"].status, "queued" + ) # distinct messages should have different message_ids: - self.assertNotEqual(message.anymail_status.recipients['test+to1@anymail.dev'].message_id, - message.anymail_status.recipients['teset+to2@anymail.dev'].message_id) + self.assertNotEqual( + message.anymail_status.recipients["test+to1@anymail.dev"].message_id, + message.anymail_status.recipients["teset+to2@anymail.dev"].message_id, + ) def test_invalid_from(self): - self.message.from_email = 'webmaster@localhost' # Django's default From + self.message.from_email = "webmaster@localhost" # Django's default From with self.assertRaises(AnymailAPIError) as cm: self.message.send() err = cm.exception response = err.response.json() self.assertEqual(err.status_code, 200) - self.assertEqual(response['status'], 'error') - self.assertIn("The From address is not authorised to send mail from this server", response['data']['message']) - self.assertIn("UnauthenticatedFromAddress", response['data']['code']) + self.assertEqual(response["status"], "error") + self.assertIn( + "The From address is not authorised to send mail from this server", + response["data"]["message"], + ) + self.assertIn("UnauthenticatedFromAddress", response["data"]["code"]) @override_settings(ANYMAIL_POSTAL_API_KEY="Hey, that's not an API key!") def test_invalid_server_token(self): @@ -95,6 +113,9 @@ class PostalBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): err = cm.exception response = err.response.json() self.assertEqual(err.status_code, 200) - self.assertEqual(response['status'], 'error') - self.assertIn("The API token provided in X-Server-API-Key was not valid.", response['data']['message']) - self.assertIn("InvalidServerAPIKey", response['data']['code']) + self.assertEqual(response["status"], "error") + self.assertIn( + "The API token provided in X-Server-API-Key was not valid.", + response["data"]["message"], + ) + self.assertIn("InvalidServerAPIKey", response["data"]["code"]) diff --git a/tests/test_postal_webhooks.py b/tests/test_postal_webhooks.py index 58a51f9..4edfa2a 100644 --- a/tests/test_postal_webhooks.py +++ b/tests/test_postal_webhooks.py @@ -9,12 +9,15 @@ from django.test import tag from anymail.exceptions import AnymailConfigurationError from anymail.signals import AnymailTrackingEvent from anymail.webhooks.postal import PostalTrackingWebhookView + from .utils_postal import ClientWithPostalSignature, make_key from .webhook_cases import WebhookTestCase -@tag('postal') -@unittest.skipUnless(ClientWithPostalSignature, "Install 'cryptography' to run postal webhook tests") +@tag("postal") +@unittest.skipUnless( + ClientWithPostalSignature, "Install 'cryptography' to run postal webhook tests" +) class PostalWebhookSecurityTestCase(WebhookTestCase): client_class = ClientWithPostalSignature @@ -25,24 +28,35 @@ class PostalWebhookSecurityTestCase(WebhookTestCase): self.client.set_private_key(make_key()) def test_failed_signature_check(self): - response = self.client.post('/anymail/postal/tracking/', - content_type='application/json', data=json.dumps({'some': 'data'}), - HTTP_X_POSTAL_SIGNATURE=b64encode('invalid'.encode('utf-8'))) + response = self.client.post( + "/anymail/postal/tracking/", + content_type="application/json", + data=json.dumps({"some": "data"}), + HTTP_X_POSTAL_SIGNATURE=b64encode("invalid".encode("utf-8")), + ) self.assertEqual(response.status_code, 400) - response = self.client.post('/anymail/postal/tracking/', - content_type='application/json', data=json.dumps({'some': 'data'}), - HTTP_X_POSTAL_SIGNATURE='garbage') + response = self.client.post( + "/anymail/postal/tracking/", + content_type="application/json", + data=json.dumps({"some": "data"}), + HTTP_X_POSTAL_SIGNATURE="garbage", + ) self.assertEqual(response.status_code, 400) - response = self.client.post('/anymail/postal/tracking/', - content_type='application/json', data=json.dumps({'some': 'data'}), - HTTP_X_POSTAL_SIGNATURE='') + response = self.client.post( + "/anymail/postal/tracking/", + content_type="application/json", + data=json.dumps({"some": "data"}), + HTTP_X_POSTAL_SIGNATURE="", + ) self.assertEqual(response.status_code, 400) -@tag('postal') -@unittest.skipUnless(ClientWithPostalSignature, "Install 'cryptography' to run postal webhook tests") +@tag("postal") +@unittest.skipUnless( + ClientWithPostalSignature, "Install 'cryptography' to run postal webhook tests" +) class PostalDeliveryTestCase(WebhookTestCase): client_class = ClientWithPostalSignature @@ -61,13 +75,14 @@ class PostalDeliveryTestCase(WebhookTestCase): "id": 233843, "token": "McC2tuqg7mhx", "direction": "outgoing", - "message_id": "7b82aac4-5d63-41b8-8e35-9faa31a892dc@rp.postal.example.com", + "message_id": "7b82aac4-5d63-41b8-8e35-9faa31a892dc" + "@rp.postal.example.com", "to": "bounce@example.com", "from": "sender@example.com", "subject": "...", "timestamp": 1606436187.8883688, "spam_status": "NotChecked", - "tag": None + "tag": None, }, "bounce": { "id": 233864, @@ -79,35 +94,42 @@ class PostalDeliveryTestCase(WebhookTestCase): "subject": "Mail delivery failed: returning message to sender", "timestamp": 1606436523.6060522, "spam_status": "NotChecked", - "tag": None + "tag": None, }, "details": "details", "output": "server output", "sent_with_ssl": None, "timestamp": 1606753101.9110143, - "time": None + "time": None, }, - "uuid": "0fcc831f-92b9-4e2b-97f2-d873abc77fab" + "uuid": "0fcc831f-92b9-4e2b-97f2-d873abc77fab", } - response = self.client.post('/anymail/postal/tracking/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/postal/tracking/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=PostalTrackingWebhookView, - event=ANY, esp_name='Postal') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=PostalTrackingWebhookView, + event=ANY, + esp_name="Postal", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "bounced") self.assertEqual(event.esp_event, raw_event) - self.assertEqual(event.timestamp, datetime.fromtimestamp(1606753101, tz=timezone.utc)) + self.assertEqual( + event.timestamp, datetime.fromtimestamp(1606753101, tz=timezone.utc) + ) self.assertEqual(event.message_id, 233843) self.assertEqual(event.event_id, "0fcc831f-92b9-4e2b-97f2-d873abc77fab") self.assertEqual(event.recipient, "bounce@example.com") self.assertEqual(event.reject_reason, "bounced") - self.assertEqual(event.description, - "details") - self.assertEqual(event.mta_response, - "server output") + self.assertEqual(event.description, "details") + self.assertEqual(event.mta_response, "server output") def test_deferred_event(self): raw_event = { @@ -118,41 +140,49 @@ class PostalDeliveryTestCase(WebhookTestCase): "id": 1564, "token": "Kmo8CRdjuM7B", "direction": "outgoing", - "message_id": "7b095c0e-2c98-4e68-a41f-7bd217a83925@rp.postal.example.com", + "message_id": "7b095c0e-2c98-4e68-a41f-7bd217a83925" + "@rp.postal.example.com", "to": "deferred@example.com", "from": "test@postal.example.com", "subject": "Test Message at November 30, 2020 16:03", "timestamp": 1606752235.195664, "spam_status": "NotChecked", - "tag": None + "tag": None, }, "status": "SoftFail", "details": "details", "output": "server output", "sent_with_ssl": None, "timestamp": 1606753101.9110143, - "time": None + "time": None, }, - "uuid": "0fcc831f-92b9-4e2b-97f2-d873abc77fab" + "uuid": "0fcc831f-92b9-4e2b-97f2-d873abc77fab", } - response = self.client.post('/anymail/postal/tracking/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/postal/tracking/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=PostalTrackingWebhookView, - event=ANY, esp_name='Postal') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=PostalTrackingWebhookView, + event=ANY, + esp_name="Postal", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "deferred") self.assertEqual(event.esp_event, raw_event) - self.assertEqual(event.timestamp, datetime.fromtimestamp(1606753101, tz=timezone.utc)) + self.assertEqual( + event.timestamp, datetime.fromtimestamp(1606753101, tz=timezone.utc) + ) self.assertEqual(event.message_id, 1564) self.assertEqual(event.event_id, "0fcc831f-92b9-4e2b-97f2-d873abc77fab") self.assertEqual(event.recipient, "deferred@example.com") self.assertEqual(event.reject_reason, None) - self.assertEqual(event.description, - "details") - self.assertEqual(event.mta_response, - "server output") + self.assertEqual(event.description, "details") + self.assertEqual(event.mta_response, "server output") def test_queued_event(self): raw_event = { @@ -163,41 +193,53 @@ class PostalDeliveryTestCase(WebhookTestCase): "id": 1568, "token": "VRvQMS20Bb4Y", "direction": "outgoing", - "message_id": "ec7b6375-4045-451a-9503-2a23a607c1c1@rp.postal.example.com", + "message_id": "ec7b6375-4045-451a-9503-2a23a607c1c1" + "@rp.postal.example.com", "to": "suppressed@example.com", "from": "test@example.com", "subject": "Test Message at November 30, 2020 16:12", "timestamp": 1606752750.993815, "spam_status": "NotChecked", - "tag": None + "tag": None, }, "status": "Held", - "details": "Recipient (suppressed@example.com) is on the suppression list", + "details": "Recipient (suppressed@example.com)" + " is on the suppression list", "output": "server output", "sent_with_ssl": None, "timestamp": 1606752751.8933666, - "time": None + "time": None, }, - "uuid": "9be13015-2e54-456c-bf66-eacbe33da824" + "uuid": "9be13015-2e54-456c-bf66-eacbe33da824", } - response = self.client.post('/anymail/postal/tracking/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/postal/tracking/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=PostalTrackingWebhookView, - event=ANY, esp_name='Postal') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=PostalTrackingWebhookView, + event=ANY, + esp_name="Postal", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "queued") self.assertEqual(event.esp_event, raw_event) - self.assertEqual(event.timestamp, datetime.fromtimestamp(1606753101, tz=timezone.utc)) + self.assertEqual( + event.timestamp, datetime.fromtimestamp(1606753101, tz=timezone.utc) + ) self.assertEqual(event.message_id, 1568) self.assertEqual(event.event_id, "9be13015-2e54-456c-bf66-eacbe33da824") self.assertEqual(event.recipient, "suppressed@example.com") self.assertEqual(event.reject_reason, None) - self.assertEqual(event.description, - "Recipient (suppressed@example.com) is on the suppression list") - self.assertEqual(event.mta_response, - "server output") + self.assertEqual( + event.description, + "Recipient (suppressed@example.com) is on the suppression list", + ) + self.assertEqual(event.mta_response, "server output") def test_failed_event(self): raw_event = { @@ -208,41 +250,49 @@ class PostalDeliveryTestCase(WebhookTestCase): "id": 1571, "token": "MzWWQPubXXWz", "direction": "outgoing", - "message_id": "cfb29da8ed1e4ed5a6c8a0f24d7a9ef3@rp.postal.example.com", + "message_id": "cfb29da8ed1e4ed5a6c8a0f24d7a9ef3" + "@rp.postal.example.com", "to": "failed@example.com", "from": "test@example.com", "subject": "Message delivery failed...", "timestamp": 1606753318.072171, "spam_status": "NotChecked", - "tag": None + "tag": None, }, "status": "HardFail", "details": "Could not deliver", "output": "server output", "sent_with_ssl": None, "timestamp": 1606753318.7010343, - "time": None + "time": None, }, - "uuid": "5fec5077-dae7-4989-94d5-e1963f3e9181" + "uuid": "5fec5077-dae7-4989-94d5-e1963f3e9181", } - response = self.client.post('/anymail/postal/tracking/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/postal/tracking/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=PostalTrackingWebhookView, - event=ANY, esp_name='Postal') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=PostalTrackingWebhookView, + event=ANY, + esp_name="Postal", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "failed") self.assertEqual(event.esp_event, raw_event) - self.assertEqual(event.timestamp, datetime.fromtimestamp(1606753101, tz=timezone.utc)) + self.assertEqual( + event.timestamp, datetime.fromtimestamp(1606753101, tz=timezone.utc) + ) self.assertEqual(event.message_id, 1571) self.assertEqual(event.event_id, "5fec5077-dae7-4989-94d5-e1963f3e9181") self.assertEqual(event.recipient, "failed@example.com") self.assertEqual(event.reject_reason, None) - self.assertEqual(event.description, - "Could not deliver") - self.assertEqual(event.mta_response, - "server output") + self.assertEqual(event.description, "Could not deliver") + self.assertEqual(event.mta_response, "server output") def test_delivered_event(self): raw_event = { @@ -253,33 +303,43 @@ class PostalDeliveryTestCase(WebhookTestCase): "id": 1563, "token": "zw6psSlgo6ki", "direction": "outgoing", - "message_id": "c462ad36-be49-469c-b7b2-dfd317eb40fa@rp.postal.example.com", + "message_id": "c462ad36-be49-469c-b7b2-dfd317eb40fa" + "@rp.postal.example.com", "to": "recipient@example.com", "from": "test@example.com", "subject": "Test Message at November 30, 2020 16:01", "timestamp": 1606752104.699201, "spam_status": "NotChecked", - "tag": "welcome-email" + "tag": "welcome-email", }, "status": "Sent", "details": "Message for recipient@example.com accepted", "output": "250 2.0.0 OK\n", "sent_with_ssl": False, "timestamp": 1606752106.9858062, - "time": 0.89 + "time": 0.89, }, - "uuid": "58e8d7ee-2cd5-4db2-9af3-3f436105795a" + "uuid": "58e8d7ee-2cd5-4db2-9af3-3f436105795a", } - response = self.client.post('/anymail/postal/tracking/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/postal/tracking/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=PostalTrackingWebhookView, - event=ANY, esp_name='Postal') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=PostalTrackingWebhookView, + event=ANY, + esp_name="Postal", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "delivered") self.assertEqual(event.esp_event, raw_event) - self.assertEqual(event.timestamp, datetime.fromtimestamp(1606753101, tz=timezone.utc)) + self.assertEqual( + event.timestamp, datetime.fromtimestamp(1606753101, tz=timezone.utc) + ) self.assertEqual(event.message_id, 1563) self.assertEqual(event.recipient, "recipient@example.com") self.assertEqual(event.tags, ["welcome-email"]) @@ -300,24 +360,34 @@ class PostalDeliveryTestCase(WebhookTestCase): "subject": "test", "timestamp": 1606756008.718169, "spam_status": "NotSpam", - "tag": None + "tag": None, }, "status": "HardFail", - "details": "Received a 400 from https://anymail.example.com/anymail/postal/tracking/.", + "details": "Received a 400 from https://anymail.example.com/" + "anymail/postal/tracking/.", "output": "Not found", "sent_with_ssl": False, "timestamp": 1606756014.1078613, - "time": 0.15 + "time": 0.15, }, - "uuid": "a01724c0-0d1a-4090-89aa-c3da5a683375" + "uuid": "a01724c0-0d1a-4090-89aa-c3da5a683375", } - response = self.client.post('/anymail/postal/tracking/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/postal/tracking/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) self.assertEqual(self.tracking_handler.call_count, 0) def test_misconfigured_inbound(self): - errmsg = "You seem to have set Postal's *inbound* webhook to Anymail's Postal *tracking* webhook URL." - with self.assertRaisesMessage(AnymailConfigurationError, errmsg): - self.client.post('/anymail/postal/tracking/', content_type='application/json', - data=json.dumps({"rcpt_to": "to@example.org"})) + with self.assertRaisesMessage( + AnymailConfigurationError, + "You seem to have set Postal's *inbound* webhook" + " to Anymail's Postal *tracking* webhook URL.", + ): + self.client.post( + "/anymail/postal/tracking/", + content_type="application/json", + data=json.dumps({"rcpt_to": "to@example.org"}), + ) diff --git a/tests/test_postmark_backend.py b/tests/test_postmark_backend.py index 449685d..00a1571 100644 --- a/tests/test_postmark_backend.py +++ b/tests/test_postmark_backend.py @@ -9,17 +9,32 @@ from django.core.exceptions import ImproperlyConfigured from django.test import SimpleTestCase, override_settings, tag from anymail.exceptions import ( - AnymailAPIError, AnymailSerializationError, - AnymailUnsupportedFeature, AnymailRecipientsRefused, AnymailInvalidAddress) -from anymail.message import attach_inline_image_file, AnymailMessage + AnymailAPIError, + AnymailInvalidAddress, + AnymailRecipientsRefused, + AnymailSerializationError, + AnymailUnsupportedFeature, +) +from anymail.message import AnymailMessage, attach_inline_image_file -from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCases -from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin, decode_att +from .mock_requests_backend import ( + RequestsBackendMockAPITestCase, + SessionSharingTestCases, +) +from .utils import ( + SAMPLE_IMAGE_FILENAME, + AnymailTestMixin, + decode_att, + sample_image_content, + sample_image_path, +) -@tag('postmark') -@override_settings(EMAIL_BACKEND='anymail.backends.postmark.EmailBackend', - ANYMAIL={'POSTMARK_SERVER_TOKEN': 'test_server_token'}) +@tag("postmark") +@override_settings( + EMAIL_BACKEND="anymail.backends.postmark.EmailBackend", + ANYMAIL={"POSTMARK_SERVER_TOKEN": "test_server_token"}, +) class PostmarkBackendMockAPITestCase(RequestsBackendMockAPITestCase): DEFAULT_RAW_RESPONSE = b"""{ "To": "to@example.com", @@ -32,25 +47,32 @@ class PostmarkBackendMockAPITestCase(RequestsBackendMockAPITestCase): def setUp(self): super().setUp() # Simple message useful for many tests - self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com']) + self.message = mail.EmailMultiAlternatives( + "Subject", "Text Body", "from@example.com", ["to@example.com"] + ) -@tag('postmark') +@tag("postmark") class PostmarkBackendStandardEmailTests(PostmarkBackendMockAPITestCase): """Test backend support for Django standard email features""" def test_send_mail(self): """Test basic API for simple send""" - mail.send_mail('Subject here', 'Here is the message.', - 'from@sender.example.com', ['to@example.com'], fail_silently=False) - self.assert_esp_called('/email') + mail.send_mail( + "Subject here", + "Here is the message.", + "from@sender.example.com", + ["to@example.com"], + fail_silently=False, + ) + self.assert_esp_called("/email") headers = self.get_api_call_headers() self.assertEqual(headers["X-Postmark-Server-Token"], "test_server_token") data = self.get_api_call_json() - self.assertEqual(data['Subject'], "Subject here") - self.assertEqual(data['TextBody'], "Here is the message.") - self.assertEqual(data['From'], "from@sender.example.com") - self.assertEqual(data['To'], "to@example.com") + self.assertEqual(data["Subject"], "Subject here") + self.assertEqual(data["TextBody"], "Here is the message.") + self.assertEqual(data["From"], "from@sender.example.com") + self.assertEqual(data["To"], "to@example.com") def test_name_addr(self): """Make sure RFC2822 name-addr format (with display-name) is allowed @@ -58,98 +80,133 @@ class PostmarkBackendStandardEmailTests(PostmarkBackendMockAPITestCase): (Test both sender and recipient addresses) """ msg = mail.EmailMessage( - 'Subject', 'Message', 'From Name ', - ['Recipient #1 ', 'to2@example.com'], - cc=['Carbon Copy ', 'cc2@example.com'], - bcc=['Blind Copy ', 'bcc2@example.com']) + "Subject", + "Message", + "From Name ", + ["Recipient #1 ", "to2@example.com"], + cc=["Carbon Copy ", "cc2@example.com"], + bcc=["Blind Copy ", "bcc2@example.com"], + ) msg.send() data = self.get_api_call_json() - self.assertEqual(data['From'], 'From Name ') - self.assertEqual(data['To'], 'Recipient #1 , to2@example.com') - self.assertEqual(data['Cc'], 'Carbon Copy , cc2@example.com') - self.assertEqual(data['Bcc'], 'Blind Copy , bcc2@example.com') + self.assertEqual(data["From"], "From Name ") + self.assertEqual(data["To"], "Recipient #1 , to2@example.com") + self.assertEqual(data["Cc"], "Carbon Copy , cc2@example.com") + self.assertEqual(data["Bcc"], "Blind Copy , bcc2@example.com") def test_email_message(self): email = mail.EmailMessage( - 'Subject', 'Body goes here', 'from@example.com', - ['to1@example.com', 'Also To '], - bcc=['bcc1@example.com', 'Also BCC '], - cc=['cc1@example.com', 'Also CC '], - headers={'Reply-To': 'another@example.com', - 'X-MyHeader': 'my value', - 'Message-ID': 'mycustommsgid@sales.example.com'}) # should override backend msgid + "Subject", + "Body goes here", + "from@example.com", + ["to1@example.com", "Also To "], + bcc=["bcc1@example.com", "Also BCC "], + cc=["cc1@example.com", "Also CC "], + headers={ + "Reply-To": "another@example.com", + "X-MyHeader": "my value", + # should override backend msgid: + "Message-ID": "mycustommsgid@sales.example.com", + }, + ) email.send() data = self.get_api_call_json() - self.assertEqual(data['Subject'], "Subject") - self.assertEqual(data['TextBody'], "Body goes here") - self.assertEqual(data['From'], "from@example.com") - self.assertEqual(data['To'], 'to1@example.com, Also To ') - self.assertEqual(data['Bcc'], 'bcc1@example.com, Also BCC ') - self.assertEqual(data['Cc'], 'cc1@example.com, Also CC ') - self.assertEqual(data['ReplyTo'], 'another@example.com') - self.assertCountEqual(data['Headers'], [ - {'Name': 'Message-ID', 'Value': 'mycustommsgid@sales.example.com'}, - {'Name': 'X-MyHeader', 'Value': 'my value'}, - ]) + self.assertEqual(data["Subject"], "Subject") + self.assertEqual(data["TextBody"], "Body goes here") + self.assertEqual(data["From"], "from@example.com") + self.assertEqual(data["To"], "to1@example.com, Also To ") + self.assertEqual(data["Bcc"], "bcc1@example.com, Also BCC ") + self.assertEqual(data["Cc"], "cc1@example.com, Also CC ") + self.assertEqual(data["ReplyTo"], "another@example.com") + self.assertCountEqual( + data["Headers"], + [ + {"Name": "Message-ID", "Value": "mycustommsgid@sales.example.com"}, + {"Name": "X-MyHeader", "Value": "my value"}, + ], + ) def test_html_message(self): - text_content = 'This is an important message.' - html_content = '

This is an important message.

' - email = mail.EmailMultiAlternatives('Subject', text_content, - 'from@example.com', ['to@example.com']) + text_content = "This is an important message." + html_content = "

This is an important message.

" + email = mail.EmailMultiAlternatives( + "Subject", text_content, "from@example.com", ["to@example.com"] + ) email.attach_alternative(html_content, "text/html") email.send() data = self.get_api_call_json() - self.assertEqual(data['TextBody'], text_content) - self.assertEqual(data['HtmlBody'], html_content) + self.assertEqual(data["TextBody"], text_content) + self.assertEqual(data["HtmlBody"], html_content) # Don't accidentally send the html part as an attachment: - self.assertNotIn('Attachments', data) + self.assertNotIn("Attachments", data) def test_html_only_message(self): - html_content = '

This is an important message.

' - email = mail.EmailMessage('Subject', html_content, 'from@example.com', ['to@example.com']) + html_content = "

This is an important message.

" + email = mail.EmailMessage( + "Subject", html_content, "from@example.com", ["to@example.com"] + ) email.content_subtype = "html" # Main content is now text/html email.send() data = self.get_api_call_json() - self.assertNotIn('TextBody', data) - self.assertEqual(data['HtmlBody'], html_content) + self.assertNotIn("TextBody", data) + self.assertEqual(data["HtmlBody"], html_content) def test_extra_headers(self): - self.message.extra_headers = {'X-Custom': 'string', 'X-Num': 123} + self.message.extra_headers = {"X-Custom": "string", "X-Num": 123} self.message.send() data = self.get_api_call_json() - self.assertCountEqual(data['Headers'], [ - {'Name': 'X-Custom', 'Value': 'string'}, - {'Name': 'X-Num', 'Value': 123} - ]) + self.assertCountEqual( + data["Headers"], + [{"Name": "X-Custom", "Value": "string"}, {"Name": "X-Num", "Value": 123}], + ) def test_extra_headers_serialization_error(self): - self.message.extra_headers = {'X-Custom': Decimal(12.5)} + self.message.extra_headers = {"X-Custom": Decimal(12.5)} with self.assertRaisesMessage(AnymailSerializationError, "Decimal"): self.message.send() def test_reply_to(self): - email = mail.EmailMessage('Subject', 'Body goes here', 'from@example.com', ['to1@example.com'], - reply_to=['reply@example.com', 'Other '], - headers={'X-Other': 'Keep'}) + email = mail.EmailMessage( + "Subject", + "Body goes here", + "from@example.com", + ["to1@example.com"], + reply_to=["reply@example.com", "Other "], + headers={"X-Other": "Keep"}, + ) email.send() data = self.get_api_call_json() - self.assertEqual(data['ReplyTo'], 'reply@example.com, Other ') - self.assertEqual(data['Headers'], [{'Name': 'X-Other', 'Value': 'Keep'}]) # don't lose other headers + self.assertEqual( + data["ReplyTo"], "reply@example.com, Other " + ) + # don't lose other headers: + self.assertEqual(data["Headers"], [{"Name": "X-Other", "Value": "Keep"}]) def test_reply_to_header(self): # Reply-To needs to be moved out of headers, into dedicated param - email = mail.EmailMessage('Subject', 'Body goes here', 'from@example.com', ['to1@example.com'], - headers={'reply-to': 'reply@example.com, Other ', - 'X-Other': 'Keep'}) + email = mail.EmailMessage( + "Subject", + "Body goes here", + "from@example.com", + ["to1@example.com"], + headers={ + "reply-to": "reply@example.com, Other ", + "X-Other": "Keep", + }, + ) email.send() data = self.get_api_call_json() - self.assertEqual(data['ReplyTo'], 'reply@example.com, Other ') - self.assertEqual(data['Headers'], [{'Name': 'X-Other', 'Value': 'Keep'}]) # don't lose other headers + self.assertEqual( + data["ReplyTo"], "reply@example.com, Other " + ) + # don't lose other headers: + self.assertEqual(data["Headers"], [{"Name": "X-Other", "Value": "Keep"}]) def test_attachments(self): text_content = "* Item one\n* Item two\n* Item three" - self.message.attach(filename="test.txt", content=text_content, mimetype="text/plain") + self.message.attach( + filename="test.txt", content=text_content, mimetype="text/plain" + ) # Should guess mimetype if not provided... png_content = b"PNG\xb4 pretend this is the contents of a png file" @@ -157,38 +214,51 @@ class PostmarkBackendStandardEmailTests(PostmarkBackendMockAPITestCase): # Should work with a MIMEBase object (also tests no filename)... pdf_content = b"PDF\xb4 pretend this is valid pdf data" - mimeattachment = MIMEBase('application', 'pdf') + mimeattachment = MIMEBase("application", "pdf") mimeattachment.set_payload(pdf_content) self.message.attach(mimeattachment) self.message.send() data = self.get_api_call_json() - attachments = data['Attachments'] + attachments = data["Attachments"] self.assertEqual(len(attachments), 3) self.assertEqual(attachments[0]["Name"], "test.txt") self.assertEqual(attachments[0]["ContentType"], "text/plain") - self.assertEqual(decode_att(attachments[0]["Content"]).decode('ascii'), text_content) - self.assertNotIn('ContentID', attachments[0]) + self.assertEqual( + decode_att(attachments[0]["Content"]).decode("ascii"), text_content + ) + self.assertNotIn("ContentID", attachments[0]) - self.assertEqual(attachments[1]["ContentType"], "image/png") # inferred from filename + # ContentType inferred from filename: + self.assertEqual(attachments[1]["ContentType"], "image/png") self.assertEqual(attachments[1]["Name"], "test.png") self.assertEqual(decode_att(attachments[1]["Content"]), png_content) - self.assertNotIn('ContentID', attachments[1]) # make sure image not treated as inline + # make sure image not treated as inline: + self.assertNotIn("ContentID", attachments[1]) self.assertEqual(attachments[2]["ContentType"], "application/pdf") self.assertEqual(attachments[2]["Name"], "") # none self.assertEqual(decode_att(attachments[2]["Content"]), pdf_content) - self.assertNotIn('ContentID', attachments[2]) + self.assertNotIn("ContentID", attachments[2]) def test_unicode_attachment_correctly_decoded(self): - self.message.attach("Une pièce jointe.html", '

\u2019

', mimetype='text/html') + self.message.attach( + "Une pièce jointe.html", "

\u2019

", mimetype="text/html" + ) self.message.send() data = self.get_api_call_json() - self.assertEqual(data['Attachments'], [{ - 'Name': 'Une pièce jointe.html', - 'ContentType': 'text/html', - 'Content': b64encode('

\u2019

'.encode('utf-8')).decode('ascii') - }]) + self.assertEqual( + data["Attachments"], + [ + { + "Name": "Une pièce jointe.html", + "ContentType": "text/html", + "Content": b64encode("

\u2019

".encode("utf-8")).decode( + "ascii" + ), + } + ], + ) def test_embedded_images(self): image_filename = SAMPLE_IMAGE_FILENAME @@ -196,46 +266,53 @@ class PostmarkBackendStandardEmailTests(PostmarkBackendMockAPITestCase): image_data = sample_image_content(image_filename) cid = attach_inline_image_file(self.message, image_path) # Read from a png file - html_content = '

This has an inline image.

' % cid + html_content = ( + '

This has an inline image.

' % cid + ) self.message.attach_alternative(html_content, "text/html") self.message.send() data = self.get_api_call_json() - self.assertEqual(data['HtmlBody'], html_content) + self.assertEqual(data["HtmlBody"], html_content) - attachments = data['Attachments'] + attachments = data["Attachments"] self.assertEqual(len(attachments), 1) - self.assertEqual(attachments[0]['Name'], image_filename) - self.assertEqual(attachments[0]['ContentType'], 'image/png') + self.assertEqual(attachments[0]["Name"], image_filename) + self.assertEqual(attachments[0]["ContentType"], "image/png") self.assertEqual(decode_att(attachments[0]["Content"]), image_data) - self.assertEqual(attachments[0]["ContentID"], 'cid:%s' % cid) + self.assertEqual(attachments[0]["ContentID"], "cid:%s" % cid) def test_attached_images(self): image_filename = SAMPLE_IMAGE_FILENAME image_path = sample_image_path(image_filename) image_data = sample_image_content(image_filename) - self.message.attach_file(image_path) # option 1: attach as a file + # option 1: attach as a file + self.message.attach_file(image_path) - image = MIMEImage(image_data) # option 2: construct the MIMEImage and attach it directly + # option 2: construct the MIMEImage and attach it directly + image = MIMEImage(image_data) self.message.attach(image) - image_data_b64 = b64encode(image_data).decode('ascii') + image_data_b64 = b64encode(image_data).decode("ascii") self.message.send() data = self.get_api_call_json() - self.assertEqual(data['Attachments'], [ - { - 'Name': image_filename, # the named one - 'ContentType': 'image/png', - 'Content': image_data_b64, - }, - { - 'Name': '', # the unnamed one - 'ContentType': 'image/png', - 'Content': image_data_b64, - }, - ]) + self.assertEqual( + data["Attachments"], + [ + { + "Name": image_filename, # the named one + "ContentType": "image/png", + "Content": image_data_b64, + }, + { + "Name": "", # the unnamed one + "ContentType": "image/png", + "Content": image_data_b64, + }, + ], + ) def test_multiple_html_alternatives(self): # Multiple alternatives not allowed @@ -261,39 +338,48 @@ class PostmarkBackendStandardEmailTests(PostmarkBackendMockAPITestCase): """Empty to, cc, bcc, and reply_to shouldn't generate empty fields""" self.message.send() data = self.get_api_call_json() - self.assertNotIn('Cc', data) - self.assertNotIn('Bcc', data) - self.assertNotIn('ReplyTo', data) + self.assertNotIn("Cc", data) + self.assertNotIn("Bcc", data) + self.assertNotIn("ReplyTo", data) - # Test empty `to` -- but send requires at least one recipient somewhere (like cc) + # Test empty `to`--but send requires at least one recipient somewhere (like cc) self.message.to = [] - self.message.cc = ['cc@example.com'] + self.message.cc = ["cc@example.com"] self.message.send() data = self.get_api_call_json() - self.assertNotIn('To', data) + self.assertNotIn("To", data) def test_multiple_from_emails(self): - """Postmark accepts multiple addresses in from_email (though only uses the first)""" + """ + Postmark accepts multiple addresses in from_email (though only uses the first) + """ self.message.from_email = 'first@example.com, "From, also" ' self.message.send() data = self.get_api_call_json() - self.assertEqual(data['From'], - 'first@example.com, "From, also" ') + self.assertEqual( + data["From"], 'first@example.com, "From, also" ' + ) # Make sure the far-more-likely scenario of a single from_email # with an unquoted display-name issues a reasonable error: - self.message.from_email = 'Unquoted, display-name ' + self.message.from_email = "Unquoted, display-name " with self.assertRaises(AnymailInvalidAddress): self.message.send() def test_api_failure(self): self.set_mock_response(status_code=500) with self.assertRaisesMessage(AnymailAPIError, "Postmark API response 500"): - mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com']) + mail.send_mail("Subject", "Body", "from@example.com", ["to@example.com"]) # Make sure fail_silently is respected self.set_mock_response(status_code=500) - sent = mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com'], fail_silently=True) + sent = mail.send_mail( + "Subject", + "Body", + "from@example.com", + ["to@example.com"], + fail_silently=True, + ) self.assertEqual(sent, 0) def test_api_error_includes_details(self): @@ -304,7 +390,9 @@ class PostmarkBackendStandardEmailTests(PostmarkBackendMockAPITestCase): "Message": "Helpful explanation from Postmark." }""" self.set_mock_response(status_code=200, raw=error_response) - with self.assertRaisesMessage(AnymailAPIError, "Helpful explanation from Postmark"): + with self.assertRaisesMessage( + AnymailAPIError, "Helpful explanation from Postmark" + ): self.message.send() # Non-JSON error response: @@ -318,254 +406,311 @@ class PostmarkBackendStandardEmailTests(PostmarkBackendMockAPITestCase): self.message.send() -@tag('postmark') +@tag("postmark") class PostmarkBackendAnymailFeatureTests(PostmarkBackendMockAPITestCase): """Test backend support for Anymail added features""" def test_envelope_sender(self): # Postmark doesn't allow overriding envelope sender on individual messages. - # You can configure a custom return-path domain for each server in their control panel. + # You can configure a custom return-path domain for each server in their + # control panel. self.message.envelope_sender = "anything@bounces.example.com" - with self.assertRaisesMessage(AnymailUnsupportedFeature, 'envelope_sender'): + with self.assertRaisesMessage(AnymailUnsupportedFeature, "envelope_sender"): self.message.send() def test_metadata(self): - self.message.metadata = {'user_id': "12345", 'items': 6} + self.message.metadata = {"user_id": "12345", "items": 6} self.message.send() data = self.get_api_call_json() - self.assertEqual(data['Metadata'], {'user_id': "12345", 'items': 6}) + self.assertEqual(data["Metadata"], {"user_id": "12345", "items": 6}) def test_send_at(self): self.message.send_at = 1651820889 # 2022-05-06 07:08:09 UTC - with self.assertRaisesMessage(AnymailUnsupportedFeature, 'send_at'): + with self.assertRaisesMessage(AnymailUnsupportedFeature, "send_at"): self.message.send() def test_tags(self): self.message.tags = ["receipt"] self.message.send() data = self.get_api_call_json() - self.assertEqual(data['Tag'], "receipt") + self.assertEqual(data["Tag"], "receipt") self.message.tags = ["receipt", "repeat-user"] - with self.assertRaisesMessage(AnymailUnsupportedFeature, 'multiple tags'): + with self.assertRaisesMessage(AnymailUnsupportedFeature, "multiple tags"): self.message.send() def test_track_opens(self): self.message.track_opens = True self.message.send() data = self.get_api_call_json() - self.assertEqual(data['TrackOpens'], True) + self.assertEqual(data["TrackOpens"], True) def test_track_clicks(self): self.message.track_clicks = True self.message.send() data = self.get_api_call_json() - self.assertEqual(data['TrackLinks'], 'HtmlAndText') + self.assertEqual(data["TrackLinks"], "HtmlAndText") # Also explicit "None" for False (to override server default) self.message.track_clicks = False self.message.send() data = self.get_api_call_json() - self.assertEqual(data['TrackLinks'], 'None') + self.assertEqual(data["TrackLinks"], "None") def test_template(self): message = AnymailMessage( # Omit subject and body (Postmark prohibits them with templates) - from_email='from@example.com', to=['to@example.com'], + from_email="from@example.com", + to=["to@example.com"], template_id=1234567, - merge_global_data={'name': "Alice", 'group': "Developers"}, + merge_global_data={"name": "Alice", "group": "Developers"}, ) message.send() - self.assert_esp_called('/email/withTemplate/') + self.assert_esp_called("/email/withTemplate/") data = self.get_api_call_json() - self.assertEqual(data['TemplateId'], 1234567) - self.assertEqual(data['TemplateModel'], {'name': "Alice", 'group': "Developers"}) + self.assertEqual(data["TemplateId"], 1234567) + self.assertEqual( + data["TemplateModel"], {"name": "Alice", "group": "Developers"} + ) # Make sure Django default subject and body didn't end up in the payload: - self.assertNotIn('Subject', data) - self.assertNotIn('HtmlBody', data) - self.assertNotIn('TextBody', data) + self.assertNotIn("Subject", data) + self.assertNotIn("HtmlBody", data) + self.assertNotIn("TextBody", data) def test_template_alias(self): # Anymail template_id can be either Postmark TemplateId or TemplateAlias message = AnymailMessage( - from_email='from@example.com', to=['to@example.com'], - template_id='welcome-message', + from_email="from@example.com", + to=["to@example.com"], + template_id="welcome-message", ) message.send() - self.assert_esp_called('/email/withTemplate/') + self.assert_esp_called("/email/withTemplate/") data = self.get_api_call_json() - self.assertEqual(data['TemplateAlias'], 'welcome-message') + self.assertEqual(data["TemplateAlias"], "welcome-message") # Postmark requires TemplateModel (can be empty) with TemplateId/TemplateAlias - self.assertEqual(data['TemplateModel'], {}) + self.assertEqual(data["TemplateModel"], {}) def test_template_multiple_recipients(self): # This is a non-batch (no merge_data) template send message = AnymailMessage( - from_email='from@example.com', - to=['to@example.com', "Also to "], + from_email="from@example.com", + to=["to@example.com", "Also to "], template_id=1234567, ) message.send() - self.assert_esp_called('/email/withTemplate/') + self.assert_esp_called("/email/withTemplate/") data = self.get_api_call_json() - self.assertEqual(data['To'], 'to@example.com, Also to ') - self.assertEqual(data['TemplateId'], 1234567) + self.assertEqual(data["To"], "to@example.com, Also to ") + self.assertEqual(data["TemplateId"], 1234567) - _mock_batch_response = json.dumps([{ - "ErrorCode": 0, - "Message": "OK", - "To": "alice@example.com", - "SubmittedAt": "2016-03-12T15:27:50.4468803-05:00", - "MessageID": "b7bc2f4a-e38e-4336-af7d-e6c392c2f817", - }, { - "ErrorCode": 0, - "Message": "OK", - "To": "bob@example.com", - "SubmittedAt": "2016-03-12T15:27:50.4468803-05:00", - "MessageID": "e2ecbbfc-fe12-463d-b933-9fe22915106d", - }]).encode('utf-8') + _mock_batch_response = json.dumps( + [ + { + "ErrorCode": 0, + "Message": "OK", + "To": "alice@example.com", + "SubmittedAt": "2016-03-12T15:27:50.4468803-05:00", + "MessageID": "b7bc2f4a-e38e-4336-af7d-e6c392c2f817", + }, + { + "ErrorCode": 0, + "Message": "OK", + "To": "bob@example.com", + "SubmittedAt": "2016-03-12T15:27:50.4468803-05:00", + "MessageID": "e2ecbbfc-fe12-463d-b933-9fe22915106d", + }, + ] + ).encode("utf-8") def test_merge_data(self): self.set_mock_response(raw=self._mock_batch_response) message = AnymailMessage( - from_email='from@example.com', - template_id=1234567, # Postmark only supports merge_data content in a template - to=['alice@example.com', 'Bob '], + from_email="from@example.com", + # Postmark only supports merge_data content in a template + template_id=1234567, + to=["alice@example.com", "Bob "], merge_data={ - 'alice@example.com': {'name': "Alice", 'group': "Developers"}, - 'bob@example.com': {'name': "Bob"}, # and leave group undefined - 'nobody@example.com': {'name': "Not a recipient for this message"}, + "alice@example.com": {"name": "Alice", "group": "Developers"}, + "bob@example.com": {"name": "Bob"}, # and leave group undefined + "nobody@example.com": {"name": "Not a recipient for this message"}, }, - merge_global_data={'group': "Users", 'site': "ExampleCo"} + merge_global_data={"group": "Users", "site": "ExampleCo"}, ) message.send() - self.assert_esp_called('/email/batchWithTemplates') + self.assert_esp_called("/email/batchWithTemplates") data = self.get_api_call_json() messages = data["Messages"] self.assertEqual(len(messages), 2) - self.assertEqual(messages[0], { - "From": "from@example.com", - "To": "alice@example.com", - "TemplateId": 1234567, - "TemplateModel": {"name": "Alice", "group": "Developers", "site": "ExampleCo"}, - }) - self.assertEqual(messages[1], { - "From": "from@example.com", - "To": "Bob ", - "TemplateId": 1234567, - "TemplateModel": {"name": "Bob", "group": "Users", "site": "ExampleCo"}, - }) + self.assertEqual( + messages[0], + { + "From": "from@example.com", + "To": "alice@example.com", + "TemplateId": 1234567, + "TemplateModel": { + "name": "Alice", + "group": "Developers", + "site": "ExampleCo", + }, + }, + ) + self.assertEqual( + messages[1], + { + "From": "from@example.com", + "To": "Bob ", + "TemplateId": 1234567, + "TemplateModel": {"name": "Bob", "group": "Users", "site": "ExampleCo"}, + }, + ) recipients = message.anymail_status.recipients - self.assertEqual(recipients['alice@example.com'].status, 'sent') - self.assertEqual(recipients['alice@example.com'].message_id, 'b7bc2f4a-e38e-4336-af7d-e6c392c2f817') - self.assertEqual(recipients['bob@example.com'].status, 'sent') - self.assertEqual(recipients['bob@example.com'].message_id, 'e2ecbbfc-fe12-463d-b933-9fe22915106d') + self.assertEqual(recipients["alice@example.com"].status, "sent") + self.assertEqual( + recipients["alice@example.com"].message_id, + "b7bc2f4a-e38e-4336-af7d-e6c392c2f817", + ) + self.assertEqual(recipients["bob@example.com"].status, "sent") + self.assertEqual( + recipients["bob@example.com"].message_id, + "e2ecbbfc-fe12-463d-b933-9fe22915106d", + ) def test_merge_data_single_recipient(self): self.set_mock_response(raw=self._mock_batch_response) message = AnymailMessage( - from_email='from@example.com', - template_id=1234567, # Postmark only supports merge_data content in a template - to=['alice@example.com'], + from_email="from@example.com", + # Postmark only supports merge_data content in a template: + template_id=1234567, + to=["alice@example.com"], merge_data={ - 'alice@example.com': {'name': "Alice", 'group': "Developers"}, - 'nobody@example.com': {'name': "Not a recipient for this message"}, + "alice@example.com": {"name": "Alice", "group": "Developers"}, + "nobody@example.com": {"name": "Not a recipient for this message"}, }, - merge_global_data={'group': "Users", 'site': "ExampleCo"} + merge_global_data={"group": "Users", "site": "ExampleCo"}, ) message.send() # because merge_data is set, it's treated as a batch send - self.assert_esp_called('/email/batchWithTemplates') + self.assert_esp_called("/email/batchWithTemplates") data = self.get_api_call_json() - self.assertEqual(data, { - 'Messages': [{ - "From": "from@example.com", - "To": "alice@example.com", - "TemplateId": 1234567, - "TemplateModel": {"name": "Alice", "group": "Developers", "site": "ExampleCo"}, - }] - }) + self.assertEqual( + data, + { + "Messages": [ + { + "From": "from@example.com", + "To": "alice@example.com", + "TemplateId": 1234567, + "TemplateModel": { + "name": "Alice", + "group": "Developers", + "site": "ExampleCo", + }, + } + ] + }, + ) recipients = message.anymail_status.recipients - self.assertEqual(recipients['alice@example.com'].status, 'sent') - self.assertEqual(recipients['alice@example.com'].message_id, 'b7bc2f4a-e38e-4336-af7d-e6c392c2f817') + self.assertEqual(recipients["alice@example.com"].status, "sent") + self.assertEqual( + recipients["alice@example.com"].message_id, + "b7bc2f4a-e38e-4336-af7d-e6c392c2f817", + ) def test_merge_data_no_template(self): # merge_data={} can be used to force batch sending without a template self.set_mock_response(raw=self._mock_batch_response) message = AnymailMessage( - from_email='from@example.com', - to=['alice@example.com', 'Bob '], + from_email="from@example.com", + to=["alice@example.com", "Bob "], merge_data={}, subject="Test batch send", body="Test body", ) message.send() - self.assert_esp_called('/email/batch') + self.assert_esp_called("/email/batch") data = self.get_api_call_json() self.assertEqual(len(data), 2) - self.assertEqual(data[0], { - "From": "from@example.com", - "To": "alice@example.com", - "Subject": "Test batch send", - "TextBody": "Test body", - }) - self.assertEqual(data[1], { - "From": "from@example.com", - "To": "Bob ", - "Subject": "Test batch send", - "TextBody": "Test body", - }) + self.assertEqual( + data[0], + { + "From": "from@example.com", + "To": "alice@example.com", + "Subject": "Test batch send", + "TextBody": "Test body", + }, + ) + self.assertEqual( + data[1], + { + "From": "from@example.com", + "To": "Bob ", + "Subject": "Test batch send", + "TextBody": "Test body", + }, + ) recipients = message.anymail_status.recipients - self.assertEqual(recipients['alice@example.com'].status, 'sent') - self.assertEqual(recipients['alice@example.com'].message_id, 'b7bc2f4a-e38e-4336-af7d-e6c392c2f817') - self.assertEqual(recipients['bob@example.com'].status, 'sent') - self.assertEqual(recipients['bob@example.com'].message_id, 'e2ecbbfc-fe12-463d-b933-9fe22915106d') + self.assertEqual(recipients["alice@example.com"].status, "sent") + self.assertEqual( + recipients["alice@example.com"].message_id, + "b7bc2f4a-e38e-4336-af7d-e6c392c2f817", + ) + self.assertEqual(recipients["bob@example.com"].status, "sent") + self.assertEqual( + recipients["bob@example.com"].message_id, + "e2ecbbfc-fe12-463d-b933-9fe22915106d", + ) def test_merge_metadata(self): self.set_mock_response(raw=self._mock_batch_response) - self.message.to = ['alice@example.com', 'Bob '] + self.message.to = ["alice@example.com", "Bob "] self.message.merge_metadata = { - 'alice@example.com': {'order_id': 123, 'tier': 'premium'}, - 'bob@example.com': {'order_id': 678}, + "alice@example.com": {"order_id": 123, "tier": "premium"}, + "bob@example.com": {"order_id": 678}, } - self.message.metadata = {'notification_batch': 'zx912'} + self.message.metadata = {"notification_batch": "zx912"} self.message.send() - self.assert_esp_called('/email/batch') + self.assert_esp_called("/email/batch") data = self.get_api_call_json() self.assertEqual(len(data), 2) self.assertEqual(data[0]["To"], "alice@example.com") # metadata and merge_metadata[recipient] are combined: - self.assertEqual(data[0]["Metadata"], {'order_id': 123, 'tier': 'premium', 'notification_batch': 'zx912'}) + self.assertEqual( + data[0]["Metadata"], + {"order_id": 123, "tier": "premium", "notification_batch": "zx912"}, + ) self.assertEqual(data[1]["To"], "Bob ") - self.assertEqual(data[1]["Metadata"], {'order_id': 678, 'notification_batch': 'zx912'}) + self.assertEqual( + data[1]["Metadata"], {"order_id": 678, "notification_batch": "zx912"} + ) def test_merge_metadata_with_template(self): self.set_mock_response(raw=self._mock_batch_response) - self.message.to = ['alice@example.com', 'Bob '] + self.message.to = ["alice@example.com", "Bob "] self.message.template_id = 1234567 self.message.merge_metadata = { - 'alice@example.com': {'order_id': 123}, - 'bob@example.com': {'order_id': 678, 'tier': 'premium'}, + "alice@example.com": {"order_id": 123}, + "bob@example.com": {"order_id": 678, "tier": "premium"}, } self.message.send() - self.assert_esp_called('/email/batchWithTemplates') + self.assert_esp_called("/email/batchWithTemplates") data = self.get_api_call_json() messages = data["Messages"] self.assertEqual(len(messages), 2) self.assertEqual(messages[0]["To"], "alice@example.com") # metadata and merge_metadata[recipient] are combined: - self.assertEqual(messages[0]["Metadata"], {'order_id': 123}) + self.assertEqual(messages[0]["Metadata"], {"order_id": 123}) self.assertEqual(messages[1]["To"], "Bob ") - self.assertEqual(messages[1]["Metadata"], {'order_id': 678, 'tier': 'premium'}) + self.assertEqual(messages[1]["Metadata"], {"order_id": 678, "tier": "premium"}) def test_default_omits_options(self): """Make sure by default we don't send any ESP-specific options. @@ -576,35 +721,37 @@ class PostmarkBackendAnymailFeatureTests(PostmarkBackendMockAPITestCase): """ self.message.send() data = self.get_api_call_json() - self.assertNotIn('Metadata', data) - self.assertNotIn('Tag', data) - self.assertNotIn('TemplateId', data) - self.assertNotIn('TemplateModel', data) - self.assertNotIn('TrackOpens', data) - self.assertNotIn('TrackLinks', data) + self.assertNotIn("Metadata", data) + self.assertNotIn("Tag", data) + self.assertNotIn("TemplateId", data) + self.assertNotIn("TemplateModel", data) + self.assertNotIn("TrackOpens", data) + self.assertNotIn("TrackLinks", data) def test_esp_extra(self): self.message.esp_extra = { - 'FuturePostmarkOption': 'some-value', + "FuturePostmarkOption": "some-value", } self.message.send() data = self.get_api_call_json() - self.assertEqual(data['FuturePostmarkOption'], 'some-value') + self.assertEqual(data["FuturePostmarkOption"], "some-value") def test_message_server_token(self): # Can override server-token on a per-message basis: self.message.esp_extra = { - 'server_token': 'token_for_this_message_only', + "server_token": "token_for_this_message_only", } self.message.send() headers = self.get_api_call_headers() - self.assertEqual(headers["X-Postmark-Server-Token"], "token_for_this_message_only") + self.assertEqual( + headers["X-Postmark-Server-Token"], "token_for_this_message_only" + ) data = self.get_api_call_json() - self.assertNotIn('server_token', data) # not in the json + self.assertNotIn("server_token", data) # not in the json # noinspection PyUnresolvedReferences def test_send_attaches_anymail_status(self): - """ The anymail_status should be attached to the message when it is sent """ + """The anymail_status should be attached to the message when it is sent""" response_content = b"""{ "MessageID":"abcdef01-2345-6789-0123-456789abcdef", "ErrorCode":0, @@ -612,20 +759,32 @@ class PostmarkBackendAnymailFeatureTests(PostmarkBackendMockAPITestCase): "Message":"OK" }""" self.set_mock_response(raw=response_content) - msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['Recipient '],) + msg = mail.EmailMessage( + "Subject", + "Message", + "from@example.com", + ["Recipient "], + ) 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['to1@example.com'].status, 'sent') - self.assertEqual(msg.anymail_status.recipients['to1@example.com'].message_id, - 'abcdef01-2345-6789-0123-456789abcdef') + 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["to1@example.com"].status, "sent" + ) + self.assertEqual( + msg.anymail_status.recipients["to1@example.com"].message_id, + "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. + # 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", @@ -633,19 +792,28 @@ class PostmarkBackendAnymailFeatureTests(PostmarkBackendMockAPITestCase): "Message":"OK" }""" self.set_mock_response(raw=response_content) - msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', cc=['cc@example.com'],) + 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.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""" + """If the send fails, anymail_status should contain initial values""" self.set_mock_response(status_code=500) sent = self.message.send(fail_silently=True) self.assertEqual(sent, 0) @@ -656,9 +824,12 @@ class PostmarkBackendAnymailFeatureTests(PostmarkBackendMockAPITestCase): # noinspection PyUnresolvedReferences def test_send_unparsable_response(self): - """If the send succeeds, but a non-JSON API response, should raise an API exception""" - mock_response = self.set_mock_response(status_code=200, - raw=b"yikes, this isn't a real response") + """ + If the send succeeds, but a non-JSON API response, should raise an API exception + """ + mock_response = self.set_mock_response( + status_code=200, raw=b"yikes, this isn't a real response" + ) with self.assertRaises(AnymailAPIError): self.message.send() self.assertIsNone(self.message.anymail_status.status) @@ -668,46 +839,61 @@ class PostmarkBackendAnymailFeatureTests(PostmarkBackendMockAPITestCase): def test_json_serialization_errors(self): """Try to provide more information about non-json-serializable data""" - self.message.tags = [Decimal('19.99')] # yeah, don't do this + self.message.tags = [Decimal("19.99")] # yeah, don't do this with self.assertRaises(AnymailSerializationError) as cm: self.message.send() print(self.get_api_call_json()) err = cm.exception self.assertIsInstance(err, TypeError) # compatibility with json.dumps - self.assertIn("Don't know how to send this data to Postmark", str(err)) # our added context - self.assertRegex(str(err), r"Decimal.*is not JSON serializable") # original message + # our added context: + self.assertIn("Don't know how to send this data to Postmark", str(err)) + # original message: + self.assertRegex(str(err), r"Decimal.*is not JSON serializable") -@tag('postmark') +@tag("postmark") class PostmarkBackendRecipientsRefusedTests(PostmarkBackendMockAPITestCase): - """Should raise AnymailRecipientsRefused when *all* recipients are rejected or invalid""" + """ + Should raise AnymailRecipientsRefused when *all* recipients are rejected or invalid + """ def test_recipients_inactive(self): self.set_mock_response( status_code=422, raw=b'{"ErrorCode":406,' - b'"Message":"You tried to send to a recipient that has been marked as inactive.\\n' - b'Found inactive addresses: hardbounce@example.com, spam@example.com.\\n' - b'Inactive recipients are ones that have generated a hard bounce or a spam complaint."}' + b'"Message":"You tried to send to a recipient' + b" that has been marked as inactive.\\n" + b"Found inactive addresses: hardbounce@example.com, spam@example.com.\\n" + b"Inactive recipients are ones that have generated" + b' a hard bounce or a spam complaint."}', + ) + msg = mail.EmailMessage( + "Subject", + "Body", + "from@example.com", + ["HardBounce@example.com", "Hates Spam "], ) - msg = mail.EmailMessage('Subject', 'Body', 'from@example.com', - ['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['spam@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): self.set_mock_response( status_code=422, - raw=b"""{"ErrorCode":300,"Message":"Invalid 'To' address: 'invalid@localhost'."}""" + 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_recipients_parse_error(self): self.set_mock_response( @@ -715,21 +901,27 @@ class PostmarkBackendRecipientsRefusedTests(PostmarkBackendMockAPITestCase): raw=b"""{ "ErrorCode": 300, "Message": "Error parsing 'Cc': Illegal email domain '+' in address 'user@+'." - }""") - msg = mail.EmailMessage('Subject', 'Body', 'from@example.com', cc=["user@+"]) + }""", # NOQA: E501 + ) + msg = mail.EmailMessage("Subject", "Body", "from@example.com", cc=["user@+"]) with self.assertRaises(AnymailRecipientsRefused): msg.send() status = msg.anymail_status - self.assertEqual(status.recipients['user@+'].status, 'invalid') + self.assertEqual(status.recipients["user@+"].status, "invalid") def test_from_email_invalid(self): # Invalid 'From' address generates same Postmark ErrorCode 300 as invalid 'To', # but should raise a different Anymail error self.set_mock_response( status_code=422, - raw=b"""{"ErrorCode":300,"Message":"Invalid 'From' address: 'invalid@localhost'."}""" + raw=b"""{ + "ErrorCode":300, + "Message":"Invalid 'From' address: 'invalid@localhost'." + }""", + ) + msg = mail.EmailMessage( + "Subject", "Body", "invalid@localhost", ["to@example.com"] ) - msg = mail.EmailMessage('Subject', 'Body', 'invalid@localhost', ['to@example.com']) with self.assertRaises(AnymailAPIError): msg.send() @@ -740,9 +932,15 @@ class PostmarkBackendRecipientsRefusedTests(PostmarkBackendMockAPITestCase): raw=b"""{ "ErrorCode": 300, "Message": "Error parsing 'Reply-To': Illegal email domain '+' in address 'invalid@+'."} - """) - msg = mail.EmailMessage('Subject', 'Body', 'from@example.com', ['to@example.com'], - reply_to=["invalid@+"]) + """, # NOQA: E501 + ) + msg = mail.EmailMessage( + "Subject", + "Body", + "from@example.com", + ["to@example.com"], + reply_to=["invalid@+"], + ) with self.assertRaisesMessage(AnymailAPIError, "Error parsing 'Reply-To'"): msg.send() @@ -754,8 +952,11 @@ class PostmarkBackendRecipientsRefusedTests(PostmarkBackendMockAPITestCase): raw=b"""{ "ErrorCode": 300, "Message": "Invalid metadata content. Field names are limited to 20 characters..." - }""") - msg = mail.EmailMessage('Subject', 'Body', 'from@example.com', ['to@example.com']) + }""", # NOQA: E501 + ) + msg = mail.EmailMessage( + "Subject", "Body", "from@example.com", ["to@example.com"] + ) msg.metadata = {"this-key-name-is-too-long": "data"} with self.assertRaisesMessage(AnymailAPIError, "Invalid metadata content"): msg.send() @@ -764,69 +965,95 @@ class PostmarkBackendRecipientsRefusedTests(PostmarkBackendMockAPITestCase): self.set_mock_response( status_code=422, raw=b'{"ErrorCode":406,' - b'"Message":"You tried to send to a recipient that has been marked as inactive.\\n' - b'Found inactive addresses: hardbounce@example.com, spam@example.com.\\n' - b'Inactive recipients are ones that have generated a hard bounce or a spam complaint."}' + b'"Message":"You tried to send to a recipient' + b" that has been marked as inactive.\\n" + b"Found inactive addresses: hardbounce@example.com, spam@example.com.\\n" + b"Inactive recipients are ones that have generated" + b' a hard bounce or a spam complaint."}', + ) + msg = mail.EmailMessage( + "Subject", + "Body", + "from@example.com", + ["HardBounce@example.com", "Hates Spam "], ) - msg = mail.EmailMessage('Subject', 'Body', 'from@example.com', - ['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['spam@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) def test_ignore_recipient_status(self): self.set_mock_response( status_code=422, raw=b'{"ErrorCode":406,' - b'"Message":"You tried to send to a recipient that has been marked as inactive.\\n' - b'Found inactive addresses: hardbounce@example.com, spam@example.com.\\n' - b'Inactive recipients are ones that have generated a hard bounce or a spam complaint. "}' + b'"Message":"You tried to send to a recipient' + b" that has been marked as inactive.\\n" + b"Found inactive addresses: hardbounce@example.com, spam@example.com.\\n" + b"Inactive recipients are ones that have generated" + b' a hard bounce or a spam complaint. "}', + ) + msg = mail.EmailMessage( + "Subject", + "Body", + "from@example.com", + ["HardBounce@example.com", "Hates Spam "], ) - msg = mail.EmailMessage('Subject', 'Body', 'from@example.com', - ['HardBounce@example.com', 'Hates Spam ']) msg.send() status = msg.anymail_status - self.assertEqual(status.recipients['HardBounce@example.com'].status, 'rejected') - self.assertEqual(status.recipients['spam@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): """If *any* recipients are valid or queued, no exception is raised""" self.set_mock_response( status_code=200, - raw=b'{"To":"hardbounce@example.com, valid@example.com, Hates Spam ",' - b'"SubmittedAt":"2016-03-12T22:59:06.2505871-05:00",' - b'"MessageID":"089dce03-feee-408e-9f0c-ee69bf1c5f35",' - b'"ErrorCode":0,' - b'"Message":"Message OK, but will not deliver to these inactive addresses:' - b' hardbounce@example.com, spam@example.com.' - b' Inactive recipients are ones that have generated a hard bounce or a spam complaint."}' + raw=b'{"To":"hardbounce@example.com, valid@example.com,' + b' Hates Spam ",' + b'"SubmittedAt":"2016-03-12T22:59:06.2505871-05:00",' + b'"MessageID":"089dce03-feee-408e-9f0c-ee69bf1c5f35",' + b'"ErrorCode":0,' + b'"Message":"Message OK, but will not deliver to these inactive addresses:' + b" hardbounce@example.com, spam@example.com." + b" Inactive recipients are ones that have generated" + b' a hard bounce or a spam complaint."}', + ) + msg = mail.EmailMessage( + "Subject", + "Body", + "from@example.com", + [ + "HardBounce@example.com", + "valid@example.com", + "Hates Spam ", + ], ) - msg = mail.EmailMessage('Subject', 'Body', 'from@example.com', - ['HardBounce@example.com', 'valid@example.com', 'Hates Spam ']) sent = msg.send() - self.assertEqual(sent, 1) # one message sent, successfully, to 1 of 3 recipients + # one message sent, successfully, to 1 of 3 recipients: + self.assertEqual(sent, 1) status = msg.anymail_status - 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') + 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") -@tag('postmark') -class PostmarkBackendSessionSharingTestCase(SessionSharingTestCases, PostmarkBackendMockAPITestCase): +@tag("postmark") +class PostmarkBackendSessionSharingTestCase( + SessionSharingTestCases, PostmarkBackendMockAPITestCase +): """Requests session sharing tests""" + pass # tests are defined in SessionSharingTestCases -@tag('postmark') +@tag("postmark") @override_settings(EMAIL_BACKEND="anymail.backends.postmark.EmailBackend") class PostmarkBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase): """Test ESP backend without required settings in place""" def test_missing_api_key(self): with self.assertRaises(ImproperlyConfigured) as cm: - mail.send_mail('Subject', 'Message', 'from@example.com', ['to@example.com']) + mail.send_mail("Subject", "Message", "from@example.com", ["to@example.com"]) errmsg = str(cm.exception) - self.assertRegex(errmsg, r'\bPOSTMARK_SERVER_TOKEN\b') - self.assertRegex(errmsg, r'\bANYMAIL_POSTMARK_SERVER_TOKEN\b') + self.assertRegex(errmsg, r"\bPOSTMARK_SERVER_TOKEN\b") + self.assertRegex(errmsg, r"\bANYMAIL_POSTMARK_SERVER_TOKEN\b") diff --git a/tests/test_postmark_inbound.py b/tests/test_postmark_inbound.py index 91ee1e2..18fa395 100644 --- a/tests/test_postmark_inbound.py +++ b/tests/test_postmark_inbound.py @@ -9,38 +9,35 @@ from anymail.inbound import AnymailInboundMessage from anymail.signals import AnymailInboundEvent from anymail.webhooks.postmark import PostmarkInboundWebhookView -from .utils import sample_image_content, sample_email_content +from .utils import sample_email_content, sample_image_content from .webhook_cases import WebhookTestCase -@tag('postmark') +@tag("postmark") class PostmarkInboundTestCase(WebhookTestCase): def test_inbound_basics(self): raw_event = { "FromFull": { "Email": "from+test@example.org", "Name": "Displayed From", - "MailboxHash": "test" + "MailboxHash": "test", }, - "ToFull": [{ - "Email": "test@inbound.example.com", - "Name": "Test Inbound", - "MailboxHash": "" - }, { - "Email": "other@example.com", - "Name": "", - "MailboxHash": "" - }], - "CcFull": [{ - "Email": "cc@example.com", - "Name": "", - "MailboxHash": "" - }], - "BccFull": [{ - "Email": "bcc@example.com", - "Name": "Postmark documents blind cc on inbound email (?)", - "MailboxHash": "" - }], + "ToFull": [ + { + "Email": "test@inbound.example.com", + "Name": "Test Inbound", + "MailboxHash": "", + }, + {"Email": "other@example.com", "Name": "", "MailboxHash": ""}, + ], + "CcFull": [{"Email": "cc@example.com", "Name": "", "MailboxHash": ""}], + "BccFull": [ + { + "Email": "bcc@example.com", + "Name": "Postmark documents blind cc on inbound email (?)", + "MailboxHash": "", + } + ], "OriginalRecipient": "test@inbound.example.com", "ReplyTo": "from+test@milter.example.org", "Subject": "Test subject", @@ -50,51 +47,59 @@ class PostmarkInboundTestCase(WebhookTestCase): "HtmlBody": "
Test body html
", "StrippedTextReply": "stripped plaintext body", "Tag": "", - "Headers": [{ - "Name": "Received", - "Value": "from mail.example.org by inbound.postmarkapp.com ..." - }, { - "Name": "X-Spam-Checker-Version", - "Value": "SpamAssassin 3.4.0 (2014-02-07) onp-pm-smtp-inbound01b-aws-useast2b" - }, { - "Name": "X-Spam-Status", - "Value": "No" - }, { - "Name": "X-Spam-Score", - "Value": "1.7" - }, { - "Name": "X-Spam-Tests", - "Value": "SPF_PASS" - }, { - "Name": "Received-SPF", - "Value": "Pass (sender SPF authorized) identity=mailfrom; client-ip=333.3.3.3;" - " helo=mail-02.example.org; envelope-from=envelope-from@example.org;" - " receiver=test@inbound.example.com" - }, { - "Name": "Received", - "Value": "by mail.example.org for ..." - }, { - "Name": "Received", - "Value": "by 10.10.1.71 with HTTP; Wed, 11 Oct 2017 18:31:04 -0700 (PDT)" - }, { - "Name": "MIME-Version", - "Value": "1.0" - }, { - "Name": "Message-ID", - "Value": "" - }], + "Headers": [ + { + "Name": "Received", + "Value": "from mail.example.org by inbound.postmarkapp.com ...", + }, + { + "Name": "X-Spam-Checker-Version", + "Value": "SpamAssassin 3.4.0 (2014-02-07)" + " onp-pm-smtp-inbound01b-aws-useast2b", + }, + {"Name": "X-Spam-Status", "Value": "No"}, + {"Name": "X-Spam-Score", "Value": "1.7"}, + {"Name": "X-Spam-Tests", "Value": "SPF_PASS"}, + { + "Name": "Received-SPF", + "Value": "Pass (sender SPF authorized) identity=mailfrom;" + " client-ip=333.3.3.3;" + " helo=mail-02.example.org;" + " envelope-from=envelope-from@example.org;" + " receiver=test@inbound.example.com", + }, + { + "Name": "Received", + "Value": "by mail.example.org for ...", + }, + { + "Name": "Received", + "Value": "by 10.10.1.71 with HTTP;" + " Wed, 11 Oct 2017 18:31:04 -0700 (PDT)", + }, + {"Name": "MIME-Version", "Value": "1.0"}, + {"Name": "Message-ID", "Value": ""}, + ], } - response = self.client.post('/anymail/postmark/inbound/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/postmark/inbound/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.inbound_handler, sender=PostmarkInboundWebhookView, - event=ANY, esp_name='Postmark') + kwargs = self.assert_handler_called_once_with( + self.inbound_handler, + sender=PostmarkInboundWebhookView, + event=ANY, + esp_name="Postmark", + ) # AnymailInboundEvent - event = kwargs['event'] + event = kwargs["event"] self.assertIsInstance(event, AnymailInboundEvent) - self.assertEqual(event.event_type, 'inbound') - self.assertIsNone(event.timestamp) # Postmark doesn't provide inbound event timestamp + self.assertEqual(event.event_type, "inbound") + # Postmark doesn't provide inbound event timestamp: + self.assertIsNone(event.timestamp) self.assertEqual(event.event_id, "22c74902-a0c1-4511-804f2-341342852c90") self.assertIsInstance(event.message, AnymailInboundMessage) self.assertEqual(event.esp_event, raw_event) @@ -102,132 +107,220 @@ class PostmarkInboundTestCase(WebhookTestCase): # AnymailInboundMessage - convenience properties message = event.message - self.assertEqual(message.from_email.display_name, 'Displayed From') - self.assertEqual(message.from_email.addr_spec, 'from+test@example.org') - self.assertEqual([str(e) for e in message.to], - ['Test Inbound ', 'other@example.com']) - self.assertEqual([str(e) for e in message.cc], - ['cc@example.com']) - self.assertEqual(message.subject, 'Test subject') + self.assertEqual(message.from_email.display_name, "Displayed From") + self.assertEqual(message.from_email.addr_spec, "from+test@example.org") + self.assertEqual( + [str(e) for e in message.to], + ["Test Inbound ", "other@example.com"], + ) + self.assertEqual([str(e) for e in message.cc], ["cc@example.com"]) + self.assertEqual(message.subject, "Test subject") self.assertEqual(message.date.isoformat(" "), "2017-10-11 18:31:04-07:00") - self.assertEqual(message.text, 'Test body plain') - self.assertEqual(message.html, '
Test body html
') + self.assertEqual(message.text, "Test body plain") + self.assertEqual(message.html, "
Test body html
") - self.assertEqual(message.envelope_sender, 'envelope-from@example.org') - self.assertEqual(message.envelope_recipient, 'test@inbound.example.com') - self.assertEqual(message.stripped_text, 'stripped plaintext body') - self.assertIsNone(message.stripped_html) # Postmark doesn't provide stripped html + self.assertEqual(message.envelope_sender, "envelope-from@example.org") + self.assertEqual(message.envelope_recipient, "test@inbound.example.com") + self.assertEqual(message.stripped_text, "stripped plaintext body") + # Postmark doesn't provide stripped html: + self.assertIsNone(message.stripped_html) self.assertIs(message.spam_detected, False) self.assertEqual(message.spam_score, 1.7) # AnymailInboundMessage - other headers - self.assertEqual(message['Message-ID'], "") - self.assertEqual(message['Reply-To'], "from+test@milter.example.org") - self.assertEqual(message.get_all('Received'), [ - "from mail.example.org by inbound.postmarkapp.com ...", - "by mail.example.org for ...", - "by 10.10.1.71 with HTTP; Wed, 11 Oct 2017 18:31:04 -0700 (PDT)", - ]) + self.assertEqual(message["Message-ID"], "") + self.assertEqual(message["Reply-To"], "from+test@milter.example.org") + self.assertEqual( + message.get_all("Received"), + [ + "from mail.example.org by inbound.postmarkapp.com ...", + "by mail.example.org for ...", + "by 10.10.1.71 with HTTP; Wed, 11 Oct 2017 18:31:04 -0700 (PDT)", + ], + ) def test_attachments(self): image_content = sample_image_content() email_content = sample_email_content() raw_event = { - "Attachments": [{ - "Name": "test.txt", - "Content": b64encode('test attachment'.encode('utf-8')).decode('ascii'), - "ContentType": "text/plain", - "ContentLength": len('test attachment') - }, { - "Name": "image.png", - "Content": b64encode(image_content).decode('ascii'), - "ContentType": "image/png", - "ContentID": "abc123", - "ContentLength": len(image_content) - }, { - "Name": "bounce.txt", - "Content": b64encode(email_content).decode('ascii'), - "ContentType": 'message/rfc822; charset="us-ascii"', - "ContentLength": len(email_content) - }] + "Attachments": [ + { + "Name": "test.txt", + "Content": b64encode("test attachment".encode("utf-8")).decode( + "ascii" + ), + "ContentType": "text/plain", + "ContentLength": len("test attachment"), + }, + { + "Name": "image.png", + "Content": b64encode(image_content).decode("ascii"), + "ContentType": "image/png", + "ContentID": "abc123", + "ContentLength": len(image_content), + }, + { + "Name": "bounce.txt", + "Content": b64encode(email_content).decode("ascii"), + "ContentType": 'message/rfc822; charset="us-ascii"', + "ContentLength": len(email_content), + }, + ] } - response = self.client.post('/anymail/postmark/inbound/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/postmark/inbound/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.inbound_handler, sender=PostmarkInboundWebhookView, - event=ANY, esp_name='Postmark') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.inbound_handler, + sender=PostmarkInboundWebhookView, + event=ANY, + esp_name="Postmark", + ) + event = kwargs["event"] message = event.message attachments = message.attachments # AnymailInboundMessage convenience accessor self.assertEqual(len(attachments), 2) - self.assertEqual(attachments[0].get_filename(), 'test.txt') - self.assertEqual(attachments[0].get_content_type(), 'text/plain') - self.assertEqual(attachments[0].get_content_text(), 'test attachment') - self.assertEqual(attachments[1].get_content_type(), 'message/rfc822') - self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content) + self.assertEqual(attachments[0].get_filename(), "test.txt") + self.assertEqual(attachments[0].get_content_type(), "text/plain") + self.assertEqual(attachments[0].get_content_text(), "test attachment") + self.assertEqual(attachments[1].get_content_type(), "message/rfc822") + self.assertEqualIgnoringHeaderFolding( + attachments[1].get_content_bytes(), email_content + ) inlines = message.inline_attachments self.assertEqual(len(inlines), 1) - inline = inlines['abc123'] - self.assertEqual(inline.get_filename(), 'image.png') - self.assertEqual(inline.get_content_type(), 'image/png') + inline = inlines["abc123"] + self.assertEqual(inline.get_filename(), "image.png") + self.assertEqual(inline.get_content_type(), "image/png") self.assertEqual(inline.get_content_bytes(), image_content) def test_envelope_sender(self): # Anymail extracts envelope-sender from Postmark Received-SPF header raw_event = { - "Headers": [{ - "Name": "Received-SPF", - "Value": "Pass (sender SPF authorized) identity=mailfrom; client-ip=333.3.3.3;" - " helo=mail-02.example.org; envelope-from=envelope-from@example.org;" - " receiver=test@inbound.example.com" - }], + "Headers": [ + { + "Name": "Received-SPF", + "Value": "Pass (sender SPF authorized) identity=mailfrom;" + " client-ip=333.3.3.3;" + " helo=mail-02.example.org;" + " envelope-from=envelope-from@example.org;" + " receiver=test@inbound.example.com", + } + ], } - response = self.client.post('/anymail/postmark/inbound/', content_type='application/json', - data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/postmark/inbound/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - self.assertEqual(self.get_kwargs(self.inbound_handler)['event'].message.envelope_sender, - "envelope-from@example.org") + self.assertEqual( + self.get_kwargs(self.inbound_handler)["event"].message.envelope_sender, + "envelope-from@example.org", + ) # Allow neutral SPF response self.client.post( - '/anymail/postmark/inbound/', content_type='application/json', data=json.dumps({"Headers": [{ - "Name": "Received-SPF", - "Value": "Neutral (no SPF record exists) identity=mailfrom; envelope-from=envelope-from@example.org" - }]})) - self.assertEqual(self.get_kwargs(self.inbound_handler)['event'].message.envelope_sender, - "envelope-from@example.org") + "/anymail/postmark/inbound/", + content_type="application/json", + data=json.dumps( + { + "Headers": [ + { + "Name": "Received-SPF", + "Value": "Neutral (no SPF record exists)" + " identity=mailfrom;" + " envelope-from=envelope-from@example.org", + } + ] + } + ), + ) + self.assertEqual( + self.get_kwargs(self.inbound_handler)["event"].message.envelope_sender, + "envelope-from@example.org", + ) # Ignore fail/softfail self.client.post( - '/anymail/postmark/inbound/', content_type='application/json', data=json.dumps({"Headers": [{ - "Name": "Received-SPF", - "Value": "Fail (sender not SPF authorized) identity=mailfrom; envelope-from=spoofed@example.org" - }]})) - self.assertIsNone(self.get_kwargs(self.inbound_handler)['event'].message.envelope_sender) + "/anymail/postmark/inbound/", + content_type="application/json", + data=json.dumps( + { + "Headers": [ + { + "Name": "Received-SPF", + "Value": "Fail (sender not SPF authorized)" + " identity=mailfrom;" + " envelope-from=spoofed@example.org", + } + ] + } + ), + ) + self.assertIsNone( + self.get_kwargs(self.inbound_handler)["event"].message.envelope_sender + ) # Ignore garbage self.client.post( - '/anymail/postmark/inbound/', content_type='application/json', data=json.dumps({"Headers": [{ - "Name": "Received-SPF", - "Value": "ThisIsNotAValidReceivedSPFHeader@example.org" - }]})) - self.assertIsNone(self.get_kwargs(self.inbound_handler)['event'].message.envelope_sender) + "/anymail/postmark/inbound/", + content_type="application/json", + data=json.dumps( + { + "Headers": [ + { + "Name": "Received-SPF", + "Value": "ThisIsNotAValidReceivedSPFHeader@example.org", + } + ] + } + ), + ) + self.assertIsNone( + self.get_kwargs(self.inbound_handler)["event"].message.envelope_sender + ) # Ignore multiple Received-SPF headers self.client.post( - '/anymail/postmark/inbound/', content_type='application/json', data=json.dumps({"Headers": [{ - "Name": "Received-SPF", - "Value": "Fail (sender not SPF authorized) identity=mailfrom; envelope-from=spoofed@example.org" - }, { - "Name": "Received-SPF", - "Value": "Pass (malicious sender added this) identity=mailfrom; envelope-from=spoofed@example.org" - }]})) - self.assertIsNone(self.get_kwargs(self.inbound_handler)['event'].message.envelope_sender) + "/anymail/postmark/inbound/", + content_type="application/json", + data=json.dumps( + { + "Headers": [ + { + "Name": "Received-SPF", + "Value": "Fail (sender not SPF authorized)" + " identity=mailfrom;" + " envelope-from=spoofed@example.org", + }, + { + "Name": "Received-SPF", + "Value": "Pass (malicious sender added this)" + " identity=mailfrom;" + " envelope-from=spoofed@example.org", + }, + ] + } + ), + ) + self.assertIsNone( + self.get_kwargs(self.inbound_handler)["event"].message.envelope_sender + ) def test_misconfigured_tracking(self): - errmsg = "You seem to have set Postmark's *Delivery* webhook to Anymail's Postmark *inbound* webhook URL." + errmsg = ( + "You seem to have set Postmark's *Delivery* webhook" + " to Anymail's Postmark *inbound* webhook URL." + ) with self.assertRaisesMessage(AnymailConfigurationError, errmsg): - self.client.post('/anymail/postmark/inbound/', content_type='application/json', - data=json.dumps({"RecordType": "Delivery"})) + self.client.post( + "/anymail/postmark/inbound/", + content_type="application/json", + data=json.dumps({"RecordType": "Delivery"}), + ) diff --git a/tests/test_postmark_integration.py b/tests/test_postmark_integration.py index 2364840..b2712ae 100644 --- a/tests/test_postmark_integration.py +++ b/tests/test_postmark_integration.py @@ -9,20 +9,23 @@ from anymail.message import AnymailMessage from .utils import AnymailTestMixin, sample_image_path - # For most integration tests, Postmark's sandboxed "POSTMARK_API_TEST" token is used. # But to test template sends, a real Postmark server token and template id are needed: -ANYMAIL_TEST_POSTMARK_SERVER_TOKEN = os.getenv('ANYMAIL_TEST_POSTMARK_SERVER_TOKEN') -ANYMAIL_TEST_POSTMARK_TEMPLATE_ID = os.getenv('ANYMAIL_TEST_POSTMARK_TEMPLATE_ID') -ANYMAIL_TEST_POSTMARK_DOMAIN = os.getenv('ANYMAIL_TEST_POSTMARK_DOMAIN') +ANYMAIL_TEST_POSTMARK_SERVER_TOKEN = os.getenv("ANYMAIL_TEST_POSTMARK_SERVER_TOKEN") +ANYMAIL_TEST_POSTMARK_TEMPLATE_ID = os.getenv("ANYMAIL_TEST_POSTMARK_TEMPLATE_ID") +ANYMAIL_TEST_POSTMARK_DOMAIN = os.getenv("ANYMAIL_TEST_POSTMARK_DOMAIN") -@tag('postmark', 'live') -@unittest.skipUnless(ANYMAIL_TEST_POSTMARK_DOMAIN, - "Set ANYMAIL_TEST_POSTMARK_DOMAIN environment variable " - "to run Postmark template integration tests") -@override_settings(ANYMAIL_POSTMARK_SERVER_TOKEN="POSTMARK_API_TEST", - EMAIL_BACKEND="anymail.backends.postmark.EmailBackend") +@tag("postmark", "live") +@unittest.skipUnless( + ANYMAIL_TEST_POSTMARK_DOMAIN, + "Set ANYMAIL_TEST_POSTMARK_DOMAIN environment variable " + "to run Postmark template integration tests", +) +@override_settings( + ANYMAIL_POSTMARK_SERVER_TOKEN="POSTMARK_API_TEST", + EMAIL_BACKEND="anymail.backends.postmark.EmailBackend", +) class PostmarkBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): """Postmark API integration tests @@ -32,10 +35,14 @@ class PostmarkBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): def setUp(self): super().setUp() - self.from_email = 'from@%s' % ANYMAIL_TEST_POSTMARK_DOMAIN - self.message = AnymailMessage('Anymail Postmark integration test', 'Text content', - self.from_email, ['test+to1@anymail.dev']) - self.message.attach_alternative('

HTML content

', "text/html") + self.from_email = "from@%s" % ANYMAIL_TEST_POSTMARK_DOMAIN + self.message = AnymailMessage( + "Anymail Postmark integration test", + "Text content", + self.from_email, + ["test+to1@anymail.dev"], + ) + self.message.attach_alternative("

HTML content

", "text/html") def test_simple_send(self): # Example of getting the Postmark send status and message id from the message @@ -43,12 +50,13 @@ class PostmarkBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): self.assertEqual(sent_count, 1) anymail_status = self.message.anymail_status - sent_status = anymail_status.recipients['test+to1@anymail.dev'].status - message_id = anymail_status.recipients['test+to1@anymail.dev'].message_id + sent_status = anymail_status.recipients["test+to1@anymail.dev"].status + message_id = anymail_status.recipients["test+to1@anymail.dev"].message_id - self.assertEqual(sent_status, 'sent') + self.assertEqual(sent_status, "sent") self.assertGreater(len(message_id), 0) # non-empty string - self.assertEqual(anymail_status.status, {sent_status}) # set of all recipient statuses + # set of all recipient statuses: + self.assertEqual(anymail_status.status, {sent_status}) self.assertEqual(anymail_status.message_id, message_id) def test_all_options(self): @@ -61,7 +69,6 @@ class PostmarkBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): bcc=["test+bcc1@anymail.dev", "Blind Copy 2 "], reply_to=["reply1@example.com", "Reply 2 "], headers={"X-Anymail-Test": "value"}, - # no send_at support metadata={"meta1": "simple string", "meta2": 2}, tags=["tag 1"], # max one tag @@ -75,18 +82,25 @@ class PostmarkBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): message.attach_alternative( "

HTML: with link" "and image: " % cid, - "text/html") + "text/html", + ) message.send() - self.assertEqual(message.anymail_status.status, {'sent'}) - self.assertEqual(message.anymail_status.recipients['test+to1@anymail.dev'].status, 'sent') - self.assertEqual(message.anymail_status.recipients['test+to2@anymail.dev'].status, 'sent') + self.assertEqual(message.anymail_status.status, {"sent"}) + self.assertEqual( + message.anymail_status.recipients["test+to1@anymail.dev"].status, "sent" + ) + self.assertEqual( + message.anymail_status.recipients["test+to2@anymail.dev"].status, "sent" + ) # distinct messages should have different message_ids: - self.assertNotEqual(message.anymail_status.recipients['test+to1@anymail.dev'].message_id, - message.anymail_status.recipients['test+to2@anymail.dev'].message_id) + self.assertNotEqual( + message.anymail_status.recipients["test+to1@anymail.dev"].message_id, + message.anymail_status.recipients["test+to2@anymail.dev"].message_id, + ) def test_invalid_from(self): - self.message.from_email = 'webmaster@localhost' # Django's default From + self.message.from_email = "webmaster@localhost" # Django's default From with self.assertRaises(AnymailAPIError) as cm: self.message.send() err = cm.exception @@ -98,7 +112,9 @@ class PostmarkBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): and ANYMAIL_TEST_POSTMARK_TEMPLATE_ID and ANYMAIL_TEST_POSTMARK_DOMAIN, "Set ANYMAIL_TEST_POSTMARK_SERVER_TOKEN and ANYMAIL_TEST_POSTMARK_TEMPLATE_ID " - "and ANYMAIL_TEST_POSTMARK_DOMAIN environment variables to run Postmark template integration tests") + "and ANYMAIL_TEST_POSTMARK_DOMAIN environment variables to run Postmark " + "template integration tests", + ) @override_settings(ANYMAIL_POSTMARK_SERVER_TOKEN=ANYMAIL_TEST_POSTMARK_SERVER_TOKEN) def test_template(self): message = AnymailMessage( @@ -112,7 +128,7 @@ class PostmarkBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): merge_global_data={"name": "Valued Customer"}, ) message.send() - self.assertEqual(message.anymail_status.status, {'sent'}) + self.assertEqual(message.anymail_status.status, {"sent"}) @override_settings(ANYMAIL_POSTMARK_SERVER_TOKEN="Hey, that's not a server token!") def test_invalid_server_token(self): diff --git a/tests/test_postmark_webhooks.py b/tests/test_postmark_webhooks.py index 8128e3b..9aa0dc1 100644 --- a/tests/test_postmark_webhooks.py +++ b/tests/test_postmark_webhooks.py @@ -8,19 +8,23 @@ from django.utils.timezone import get_fixed_timezone from anymail.exceptions import AnymailConfigurationError from anymail.signals import AnymailTrackingEvent from anymail.webhooks.postmark import PostmarkTrackingWebhookView + from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase -@tag('postmark') +@tag("postmark") class PostmarkWebhookSecurityTestCase(WebhookBasicAuthTestCase): def call_webhook(self): - return self.client.post('/anymail/postmark/tracking/', - content_type='application/json', data=json.dumps({})) + return self.client.post( + "/anymail/postmark/tracking/", + content_type="application/json", + data=json.dumps({}), + ) # Actual tests are in WebhookBasicAuthTestCase -@tag('postmark') +@tag("postmark") class PostmarkDeliveryTestCase(WebhookTestCase): def test_bounce_event(self): raw_event = { @@ -31,8 +35,10 @@ class PostmarkDeliveryTestCase(WebhookTestCase): "ServerID": 23, "Name": "Hard bounce", "MessageID": "2706ee8a-737c-4285-b032-ccd317af53ed", - "Description": "The server was unable to deliver your message (ex: unknown user, mailbox not found).", - "Details": "smtp;550 5.1.1 The email account that you tried to reach does not exist.", + "Description": "The server was unable to deliver your message" + " (ex: unknown user, mailbox not found).", + "Details": "smtp;550 5.1.1 The email account that you tried to reach" + " does not exist.", "Email": "bounce@example.com", "From": "sender@example.com", "BouncedAt": "2016-04-27T16:28:50.3963933-04:00", @@ -40,27 +46,50 @@ class PostmarkDeliveryTestCase(WebhookTestCase): "Inactive": True, "CanActivate": True, "Subject": "Postmark event test", - "Content": "..." + "Content": "...", } - response = self.client.post('/anymail/postmark/tracking/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/postmark/tracking/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=PostmarkTrackingWebhookView, - event=ANY, esp_name='Postmark') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=PostmarkTrackingWebhookView, + event=ANY, + esp_name="Postmark", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "bounced") self.assertEqual(event.esp_event, raw_event) - self.assertEqual(event.timestamp, datetime(2016, 4, 27, 16, 28, 50, microsecond=396393, - tzinfo=get_fixed_timezone(-4*60))) + self.assertEqual( + event.timestamp, + datetime( + 2016, + 4, + 27, + 16, + 28, + 50, + microsecond=396393, + tzinfo=get_fixed_timezone(-4 * 60), + ), + ) self.assertEqual(event.message_id, "2706ee8a-737c-4285-b032-ccd317af53ed") self.assertEqual(event.event_id, "901542550") self.assertEqual(event.recipient, "bounce@example.com") self.assertEqual(event.reject_reason, "bounced") - self.assertEqual(event.description, - "The server was unable to deliver your message (ex: unknown user, mailbox not found).") - self.assertEqual(event.mta_response, - "smtp;550 5.1.1 The email account that you tried to reach does not exist.") + self.assertEqual( + event.description, + "The server was unable to deliver your message" + " (ex: unknown user, mailbox not found).", + ) + self.assertEqual( + event.mta_response, + "smtp;550 5.1.1 The email account that you tried to reach does not exist.", + ) def test_delivered_event(self): raw_event = { @@ -74,19 +103,37 @@ class PostmarkDeliveryTestCase(WebhookTestCase): "userid": "12345", # Postmark metadata is always converted to string }, "DeliveredAt": "2014-08-01T13:28:10.2735393-04:00", - "Details": "Test delivery webhook details" + "Details": "Test delivery webhook details", } - response = self.client.post('/anymail/postmark/tracking/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/postmark/tracking/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=PostmarkTrackingWebhookView, - event=ANY, esp_name='Postmark') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=PostmarkTrackingWebhookView, + event=ANY, + esp_name="Postmark", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "delivered") self.assertEqual(event.esp_event, raw_event) - self.assertEqual(event.timestamp, datetime(2014, 8, 1, 13, 28, 10, microsecond=273539, - tzinfo=get_fixed_timezone(-4*60))) + self.assertEqual( + event.timestamp, + datetime( + 2014, + 8, + 1, + 13, + 28, + 10, + microsecond=273539, + tzinfo=get_fixed_timezone(-4 * 60), + ), + ) self.assertEqual(event.message_id, "883953f4-6105-42a2-a16a-77a8eac79483") self.assertEqual(event.recipient, "recipient@example.com") self.assertEqual(event.tags, ["welcome-email"]) @@ -104,22 +151,42 @@ class PostmarkDeliveryTestCase(WebhookTestCase): "Geo": {}, "MessageID": "f4830d10-9c35-4f0c-bca3-3d9b459821f8", "ReceivedAt": "2016-04-27T16:21:41.2493688-04:00", - "Recipient": "recipient@example.com" + "Recipient": "recipient@example.com", } - response = self.client.post('/anymail/postmark/tracking/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/postmark/tracking/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=PostmarkTrackingWebhookView, - event=ANY, esp_name='Postmark') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=PostmarkTrackingWebhookView, + event=ANY, + esp_name="Postmark", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "opened") self.assertEqual(event.esp_event, raw_event) - self.assertEqual(event.timestamp, datetime(2016, 4, 27, 16, 21, 41, microsecond=249368, - tzinfo=get_fixed_timezone(-4*60))) + self.assertEqual( + event.timestamp, + datetime( + 2016, + 4, + 27, + 16, + 21, + 41, + microsecond=249368, + tzinfo=get_fixed_timezone(-4 * 60), + ), + ) self.assertEqual(event.message_id, "f4830d10-9c35-4f0c-bca3-3d9b459821f8") self.assertEqual(event.recipient, "recipient@example.com") - self.assertEqual(event.user_agent, "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0") + self.assertEqual( + event.user_agent, "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0" + ) self.assertEqual(event.tags, []) self.assertEqual(event.metadata, {}) @@ -130,12 +197,12 @@ class PostmarkDeliveryTestCase(WebhookTestCase): "Client": { "Name": "Chrome 35.0.1916.153", "Company": "Google", - "Family": "Chrome" + "Family": "Chrome", }, "OS": { "Name": "OS X 10.7 Lion", "Company": "Apple Computer, Inc.", - "Family": "OS X 10" + "Family": "OS X 10", }, "Platform": "Desktop", "UserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_5) etc.", @@ -148,27 +215,38 @@ class PostmarkDeliveryTestCase(WebhookTestCase): "City": "Novi Sad", "Zip": "21000", "Coords": "45.2517,19.8369", - "IP": "8.8.8.8" + "IP": "8.8.8.8", }, "MessageID": "f4830d10-9c35-4f0c-bca3-3d9b459821f8", "ReceivedAt": "2017-10-25T15:21:11.9065619Z", "Tag": "welcome-email", - "Recipient": "recipient@example.com" + "Recipient": "recipient@example.com", } - response = self.client.post('/anymail/postmark/tracking/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/postmark/tracking/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=PostmarkTrackingWebhookView, - event=ANY, esp_name='Postmark') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=PostmarkTrackingWebhookView, + event=ANY, + esp_name="Postmark", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "clicked") self.assertEqual(event.esp_event, raw_event) - self.assertEqual(event.timestamp, datetime(2017, 10, 25, 15, 21, 11, microsecond=906561, - tzinfo=timezone.utc)) + self.assertEqual( + event.timestamp, + datetime(2017, 10, 25, 15, 21, 11, microsecond=906561, tzinfo=timezone.utc), + ) self.assertEqual(event.message_id, "f4830d10-9c35-4f0c-bca3-3d9b459821f8") self.assertEqual(event.recipient, "recipient@example.com") - self.assertEqual(event.user_agent, "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_5) etc.") + self.assertEqual( + event.user_agent, "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_5) etc." + ) self.assertEqual(event.click_url, "https://example.com/click/me") self.assertEqual(event.tags, ["welcome-email"]) self.assertEqual(event.metadata, {}) @@ -191,19 +269,37 @@ class PostmarkDeliveryTestCase(WebhookTestCase): "DumpAvailable": True, "Inactive": True, "CanActivate": False, - "Subject": "Postmark event test" + "Subject": "Postmark event test", } - response = self.client.post('/anymail/postmark/tracking/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/postmark/tracking/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=PostmarkTrackingWebhookView, - event=ANY, esp_name='Postmark') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=PostmarkTrackingWebhookView, + event=ANY, + esp_name="Postmark", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "complained") self.assertEqual(event.esp_event, raw_event) - self.assertEqual(event.timestamp, datetime(2016, 4, 27, 16, 28, 50, microsecond=396393, - tzinfo=get_fixed_timezone(-4*60))) + self.assertEqual( + event.timestamp, + datetime( + 2016, + 4, + 27, + 16, + 28, + 50, + microsecond=396393, + tzinfo=get_fixed_timezone(-4 * 60), + ), + ) self.assertEqual(event.message_id, "2706ee8a-737c-4285-b032-ccd317af53ed") self.assertEqual(event.event_id, "901542550") self.assertEqual(event.recipient, "spam@example.com") @@ -212,14 +308,23 @@ class PostmarkDeliveryTestCase(WebhookTestCase): self.assertEqual(event.mta_response, "Test spam complaint details") def test_misconfigured_inbound(self): - errmsg = "You seem to have set Postmark's *inbound* webhook to Anymail's Postmark *tracking* webhook URL." + errmsg = ( + "You seem to have set Postmark's *inbound* webhook" + " to Anymail's Postmark *tracking* webhook URL." + ) with self.assertRaisesMessage(AnymailConfigurationError, errmsg): - self.client.post('/anymail/postmark/tracking/', content_type='application/json', - data=json.dumps({"FromFull": {"Email": "from@example.org"}})) + self.client.post( + "/anymail/postmark/tracking/", + content_type="application/json", + data=json.dumps({"FromFull": {"Email": "from@example.org"}}), + ) with self.assertRaisesMessage(AnymailConfigurationError, errmsg): - self.client.post('/anymail/postmark/tracking/', content_type='application/json', - data=json.dumps({"RecordType": "Inbound"})) + self.client.post( + "/anymail/postmark/tracking/", + content_type="application/json", + data=json.dumps({"RecordType": "Inbound"}), + ) def test_unsubscribe(self): raw_event = { @@ -233,23 +338,32 @@ class PostmarkDeliveryTestCase(WebhookTestCase): "SuppressSending": True, "SuppressionReason": "ManualSuppression", "Tag": "welcome-email", - "Metadata": { - "example": "value", - "example_2": "value" - } + "Metadata": {"example": "value", "example_2": "value"}, } - response = self.client.post('/anymail/postmark/tracking/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/postmark/tracking/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=PostmarkTrackingWebhookView, - event=ANY, esp_name='Postmark') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=PostmarkTrackingWebhookView, + event=ANY, + esp_name="Postmark", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "unsubscribed") self.assertEqual(event.esp_event, raw_event) - self.assertEqual(event.timestamp, datetime(2022, 6, 5, 17, 17, 32, tzinfo=timezone.utc)) + self.assertEqual( + event.timestamp, datetime(2022, 6, 5, 17, 17, 32, tzinfo=timezone.utc) + ) self.assertEqual(event.message_id, "a4909a96-73d7-4c49-b148-a54522d3f7ac") - self.assertEqual(event.recipient, "john@example.com",) + self.assertEqual( + event.recipient, + "john@example.com", + ) self.assertEqual(event.reject_reason, "unsubscribed") def test_resubscribe(self): @@ -264,23 +378,32 @@ class PostmarkDeliveryTestCase(WebhookTestCase): "SuppressSending": False, "SuppressionReason": None, "Tag": "welcome-email", - "Metadata": { - "example": "value", - "example_2": "value" - } + "Metadata": {"example": "value", "example_2": "value"}, } - response = self.client.post('/anymail/postmark/tracking/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/postmark/tracking/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=PostmarkTrackingWebhookView, - event=ANY, esp_name='Postmark') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=PostmarkTrackingWebhookView, + event=ANY, + esp_name="Postmark", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "subscribed") self.assertEqual(event.esp_event, raw_event) - self.assertEqual(event.timestamp, datetime(2022, 6, 5, 17, 17, 32, tzinfo=timezone.utc)) + self.assertEqual( + event.timestamp, datetime(2022, 6, 5, 17, 17, 32, tzinfo=timezone.utc) + ) self.assertEqual(event.message_id, "a4909a96-73d7-4c49-b148-a54522d3f7ac") - self.assertEqual(event.recipient, "john@example.com",) + self.assertEqual( + event.recipient, + "john@example.com", + ) self.assertEqual(event.reject_reason, None) def test_subscription_change_bounce(self): @@ -295,21 +418,30 @@ class PostmarkDeliveryTestCase(WebhookTestCase): "SuppressSending": True, "SuppressionReason": "HardBounce", "Tag": "my-tag", - "Metadata": { - "example": "value", - "example_2": "value" - } + "Metadata": {"example": "value", "example_2": "value"}, } - response = self.client.post('/anymail/postmark/tracking/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/postmark/tracking/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=PostmarkTrackingWebhookView, - event=ANY, esp_name='Postmark') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=PostmarkTrackingWebhookView, + event=ANY, + esp_name="Postmark", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "bounced") self.assertEqual(event.esp_event, raw_event) - self.assertEqual(event.timestamp, datetime(2022, 6, 5, 17, 17, 32, tzinfo=timezone.utc)) + self.assertEqual( + event.timestamp, datetime(2022, 6, 5, 17, 17, 32, tzinfo=timezone.utc) + ) self.assertEqual(event.message_id, "b4cb783d-78ed-43f2-983b-63f55c712dc8") - self.assertEqual(event.recipient, "john@example.com",) + self.assertEqual( + event.recipient, + "john@example.com", + ) self.assertEqual(event.reject_reason, "bounced") diff --git a/tests/test_send_signals.py b/tests/test_send_signals.py index dddd526..9ab3818 100644 --- a/tests/test_send_signals.py +++ b/tests/test_send_signals.py @@ -3,7 +3,7 @@ from django.dispatch import receiver from anymail.backends.test import EmailBackend as TestEmailBackend from anymail.exceptions import AnymailCancelSend, AnymailRecipientsRefused from anymail.message import AnymailRecipientStatus -from anymail.signals import pre_send, post_send +from anymail.signals import post_send, pre_send from .test_general_backend import TestBackendTestCase @@ -13,13 +13,16 @@ class TestPreSendSignal(TestBackendTestCase): def test_pre_send(self): """Pre-send receivers invoked for each message, before sending""" + @receiver(pre_send, weak=False) def handle_pre_send(sender, message, esp_name, **kwargs): self.assertEqual(self.get_send_count(), 0) # not sent yet self.assertEqual(sender, TestEmailBackend) self.assertEqual(message, self.message) - self.assertEqual(esp_name, "Test") # the TestEmailBackend's ESP is named "Test" + # the TestEmailBackend's ESP is named "Test": + self.assertEqual(esp_name, "Test") self.receiver_called = True + self.addCleanup(pre_send.disconnect, receiver=handle_pre_send) self.receiver_called = False @@ -29,25 +32,34 @@ class TestPreSendSignal(TestBackendTestCase): def test_modify_message_in_pre_send(self): """Pre-send receivers can modify message""" + @receiver(pre_send, weak=False) def handle_pre_send(sender, message, esp_name, **kwargs): - message.to = [email for email in message.to if not email.startswith('bad')] + message.to = [email for email in message.to if not email.startswith("bad")] message.body += "\nIf you have received this message in error, ignore it" + self.addCleanup(pre_send.disconnect, receiver=handle_pre_send) - self.message.to = ['legit@example.com', 'bad@example.com'] + self.message.to = ["legit@example.com", "bad@example.com"] self.message.send() params = self.get_send_params() - self.assertEqual([email.addr_spec for email in params['to']], # params['to'] is EmailAddress list - ['legit@example.com']) - self.assertRegex(params['text_body'], - r"If you have received this message in error, ignore it$") + self.assertEqual( + # params['to'] is EmailAddress list: + [email.addr_spec for email in params["to"]], + ["legit@example.com"], + ) + self.assertRegex( + params["text_body"], + r"If you have received this message in error, ignore it$", + ) def test_cancel_in_pre_send(self): """Pre-send receiver can cancel the send""" + @receiver(pre_send, weak=False) def cancel_pre_send(sender, message, esp_name, **kwargs): raise AnymailCancelSend("whoa there") + self.addCleanup(pre_send.disconnect, receiver=cancel_pre_send) self.message.send() @@ -59,17 +71,20 @@ class TestPostSendSignal(TestBackendTestCase): def test_post_send(self): """Post-send receiver called for each message, after sending""" + @receiver(post_send, weak=False) def handle_post_send(sender, message, status, esp_name, **kwargs): self.assertEqual(self.get_send_count(), 1) # already sent self.assertEqual(sender, TestEmailBackend) self.assertEqual(message, self.message) - self.assertEqual(status.status, {'sent'}) + self.assertEqual(status.status, {"sent"}) self.assertEqual(status.message_id, 0) - self.assertEqual(status.recipients['to@example.com'].status, 'sent') - self.assertEqual(status.recipients['to@example.com'].message_id, 0) - self.assertEqual(esp_name, "Test") # the TestEmailBackend's ESP is named "Test" + self.assertEqual(status.recipients["to@example.com"].status, "sent") + self.assertEqual(status.recipients["to@example.com"].message_id, 0) + # the TestEmailBackend's ESP is named "Test": + self.assertEqual(esp_name, "Test") self.receiver_called = True + self.addCleanup(post_send.disconnect, receiver=handle_post_send) self.receiver_called = False @@ -78,14 +93,17 @@ class TestPostSendSignal(TestBackendTestCase): def test_post_send_exception(self): """All post-send receivers called, even if one throws""" + @receiver(post_send, weak=False) def handler_1(sender, message, status, esp_name, **kwargs): raise ValueError("oops") + self.addCleanup(post_send.disconnect, receiver=handler_1) @receiver(post_send, weak=False) def handler_2(sender, message, status, esp_name, **kwargs): self.handler_2_called = True + self.addCleanup(post_send.disconnect, receiver=handler_2) self.handler_2_called = False @@ -95,14 +113,18 @@ class TestPostSendSignal(TestBackendTestCase): def test_rejected_recipients(self): """Post-send receiver even if AnymailRecipientsRefused is raised""" + @receiver(post_send, weak=False) def handle_post_send(sender, message, status, esp_name, **kwargs): self.receiver_called = True + self.addCleanup(post_send.disconnect, receiver=handle_post_send) self.message.anymail_test_response = { - 'recipient_status': { - 'to@example.com': AnymailRecipientStatus(message_id=None, status='rejected') + "recipient_status": { + "to@example.com": AnymailRecipientStatus( + message_id=None, status="rejected" + ) } } diff --git a/tests/test_sendgrid_backend.py b/tests/test_sendgrid_backend.py index 3305814..b85673f 100644 --- a/tests/test_sendgrid_backend.py +++ b/tests/test_sendgrid_backend.py @@ -1,4 +1,4 @@ -from base64 import b64encode, b64decode +from base64 import b64decode, b64encode from calendar import timegm from datetime import date, datetime from decimal import Decimal @@ -8,58 +8,95 @@ from unittest.mock import patch from django.core import mail from django.test import SimpleTestCase, override_settings, tag -from django.utils.timezone import get_fixed_timezone, override as override_current_timezone +from django.utils.timezone import ( + get_fixed_timezone, + override as override_current_timezone, +) -from anymail.exceptions import (AnymailAPIError, AnymailConfigurationError, AnymailSerializationError, - AnymailUnsupportedFeature, AnymailWarning) +from anymail.exceptions import ( + AnymailAPIError, + AnymailConfigurationError, + AnymailSerializationError, + AnymailUnsupportedFeature, + AnymailWarning, +) from anymail.message import attach_inline_image_file -from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCases -from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin +from .mock_requests_backend import ( + RequestsBackendMockAPITestCase, + SessionSharingTestCases, +) +from .utils import ( + SAMPLE_IMAGE_FILENAME, + AnymailTestMixin, + sample_image_content, + sample_image_path, +) -@tag('sendgrid') -@override_settings(EMAIL_BACKEND='anymail.backends.sendgrid.EmailBackend', - ANYMAIL={'SENDGRID_API_KEY': 'test_api_key'}) +@tag("sendgrid") +@override_settings( + EMAIL_BACKEND="anymail.backends.sendgrid.EmailBackend", + ANYMAIL={"SENDGRID_API_KEY": "test_api_key"}, +) class SendGridBackendMockAPITestCase(RequestsBackendMockAPITestCase): - DEFAULT_RAW_RESPONSE = b"" # SendGrid v3 success responses are empty - DEFAULT_STATUS_CODE = 202 # SendGrid v3 uses '202 Accepted' for success (in most cases) + # SendGrid v3 success responses are empty: + DEFAULT_RAW_RESPONSE = b"" + # SendGrid v3 uses '202 Accepted' for success (in most cases): + DEFAULT_STATUS_CODE = 202 def setUp(self): super().setUp() # Patch uuid4 to generate predictable anymail_ids for testing - patch_uuid4 = patch('anymail.backends.sendgrid.uuid.uuid4', - side_effect=["mocked-uuid-%d" % n for n in range(1, 5)]) + patch_uuid4 = patch( + "anymail.backends.sendgrid.uuid.uuid4", + side_effect=["mocked-uuid-%d" % n for n in range(1, 5)], + ) patch_uuid4.start() self.addCleanup(patch_uuid4.stop) # Simple message useful for many tests - self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com']) + self.message = mail.EmailMultiAlternatives( + "Subject", "Text Body", "from@example.com", ["to@example.com"] + ) -@tag('sendgrid') +@tag("sendgrid") class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase): """Test backend support for Django standard email features""" def test_send_mail(self): """Test basic API for simple send""" - mail.send_mail('Subject here', 'Here is the message.', - 'from@sender.example.com', ['to@example.com'], fail_silently=False) - self.assert_esp_called('https://api.sendgrid.com/v3/mail/send') + mail.send_mail( + "Subject here", + "Here is the message.", + "from@sender.example.com", + ["to@example.com"], + fail_silently=False, + ) + self.assert_esp_called("https://api.sendgrid.com/v3/mail/send") http_headers = self.get_api_call_headers() self.assertEqual(http_headers["Authorization"], "Bearer test_api_key") self.assertEqual(http_headers["Content-Type"], "application/json") data = self.get_api_call_json() - self.assertEqual(data['subject'], "Subject here") - self.assertEqual(data['content'], [{'type': "text/plain", 'value': "Here is the message."}]) - self.assertEqual(data['from'], {'email': "from@sender.example.com"}) - self.assertEqual(data['personalizations'], [{ - 'to': [{'email': "to@example.com"}], - # make sure the backend assigned the anymail_id for event tracking and notification - 'custom_args': {'anymail_id': 'mocked-uuid-1'}, - }]) + self.assertEqual(data["subject"], "Subject here") + self.assertEqual( + data["content"], [{"type": "text/plain", "value": "Here is the message."}] + ) + self.assertEqual(data["from"], {"email": "from@sender.example.com"}) + self.assertEqual( + data["personalizations"], + [ + { + "to": [{"email": "to@example.com"}], + # make sure the backend assigned the anymail_id + # for event tracking and notification + "custom_args": {"anymail_id": "mocked-uuid-1"}, + } + ], + ) def test_name_addr(self): """Make sure RFC2822 name-addr format (with display-name) is allowed @@ -67,98 +104,150 @@ class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase): (Test both sender and recipient addresses) """ msg = mail.EmailMessage( - 'Subject', 'Message', 'From Name ', - ['Recipient #1 ', 'to2@example.com'], - cc=['Carbon Copy ', 'cc2@example.com'], - bcc=['Blind Copy ', 'bcc2@example.com']) + "Subject", + "Message", + "From Name ", + ["Recipient #1 ", "to2@example.com"], + cc=["Carbon Copy ", "cc2@example.com"], + bcc=["Blind Copy ", "bcc2@example.com"], + ) msg.send() data = self.get_api_call_json() - self.assertEqual(data['from'], {'email': "from@example.com", 'name': "From Name"}) + self.assertEqual( + data["from"], {"email": "from@example.com", "name": "From Name"} + ) # single message (single "personalization") sent to all those recipients - # (note workaround for SendGrid v3 API bug quoting display-name in personalizations) - self.assertEqual(len(data['personalizations']), 1) - self.assertEqual(data['personalizations'][0]['to'], [ - {'name': '"Recipient #1"', 'email': 'to1@example.com'}, - {'email': 'to2@example.com'} - ]) - self.assertEqual(data['personalizations'][0]['cc'], [ - {'name': '"Carbon Copy"', 'email': 'cc1@example.com'}, - {'email': 'cc2@example.com'} - ]) - self.assertEqual(data['personalizations'][0]['bcc'], [ - {'name': '"Blind Copy"', 'email': 'bcc1@example.com'}, - {'email': 'bcc2@example.com'} - ]) + # (note workaround for SendGrid v3 API bug quoting display-name + # in personalizations) + self.assertEqual(len(data["personalizations"]), 1) + self.assertEqual( + data["personalizations"][0]["to"], + [ + {"name": '"Recipient #1"', "email": "to1@example.com"}, + {"email": "to2@example.com"}, + ], + ) + self.assertEqual( + data["personalizations"][0]["cc"], + [ + {"name": '"Carbon Copy"', "email": "cc1@example.com"}, + {"email": "cc2@example.com"}, + ], + ) + self.assertEqual( + data["personalizations"][0]["bcc"], + [ + {"name": '"Blind Copy"', "email": "bcc1@example.com"}, + {"email": "bcc2@example.com"}, + ], + ) def test_email_message(self): email = mail.EmailMessage( - 'Subject', 'Body goes here', 'from@example.com', - ['to1@example.com', 'Also To '], - bcc=['bcc1@example.com', 'Also BCC '], - cc=['cc1@example.com', 'Also CC '], - headers={'Reply-To': 'another@example.com', - 'X-MyHeader': 'my value', - 'Message-ID': ''}) # should override backend msgid + "Subject", + "Body goes here", + "from@example.com", + ["to1@example.com", "Also To "], + bcc=["bcc1@example.com", "Also BCC "], + cc=["cc1@example.com", "Also CC "], + headers={ + "Reply-To": "another@example.com", + "X-MyHeader": "my value", + # should override backend msgid: + "Message-ID": "", + }, + ) email.send() data = self.get_api_call_json() - self.assertEqual(data['personalizations'], [{ - 'to': [{'email': "to1@example.com"}, - {'email': "to2@example.com", 'name': '"Also To"'}], - 'cc': [{'email': "cc1@example.com"}, - {'email': "cc2@example.com", 'name': '"Also CC"'}], - 'bcc': [{'email': "bcc1@example.com"}, - {'email': "bcc2@example.com", 'name': '"Also BCC"'}], - # make sure custom Message-ID also added to custom_args - 'custom_args': {'anymail_id': 'mocked-uuid-1'}, - }]) + self.assertEqual( + data["personalizations"], + [ + { + "to": [ + {"email": "to1@example.com"}, + {"email": "to2@example.com", "name": '"Also To"'}, + ], + "cc": [ + {"email": "cc1@example.com"}, + {"email": "cc2@example.com", "name": '"Also CC"'}, + ], + "bcc": [ + {"email": "bcc1@example.com"}, + {"email": "bcc2@example.com", "name": '"Also BCC"'}, + ], + # make sure custom Message-ID also added to custom_args + "custom_args": {"anymail_id": "mocked-uuid-1"}, + } + ], + ) - self.assertEqual(data['from'], {'email': "from@example.com"}) - self.assertEqual(data['subject'], "Subject") - self.assertEqual(data['content'], [{'type': "text/plain", 'value': "Body goes here"}]) - self.assertEqual(data['reply_to'], {'email': "another@example.com"}) - self.assertEqual(data['headers'], { - 'X-MyHeader': "my value", - 'Message-ID': "", - }) + self.assertEqual(data["from"], {"email": "from@example.com"}) + self.assertEqual(data["subject"], "Subject") + self.assertEqual( + data["content"], [{"type": "text/plain", "value": "Body goes here"}] + ) + self.assertEqual(data["reply_to"], {"email": "another@example.com"}) + self.assertEqual( + data["headers"], + { + "X-MyHeader": "my value", + "Message-ID": "", + }, + ) def test_html_message(self): - text_content = 'This is an important message.' - html_content = '

This is an important message.

' - email = mail.EmailMultiAlternatives('Subject', text_content, - 'from@example.com', ['to@example.com']) + text_content = "This is an important message." + html_content = "

This is an important message.

" + email = mail.EmailMultiAlternatives( + "Subject", text_content, "from@example.com", ["to@example.com"] + ) email.attach_alternative(html_content, "text/html") email.send() data = self.get_api_call_json() # SendGrid requires content in text, html order: - self.assertEqual(len(data['content']), 2) - self.assertEqual(data['content'][0], {'type': "text/plain", 'value': text_content}) - self.assertEqual(data['content'][1], {'type': "text/html", 'value': html_content}) + self.assertEqual(len(data["content"]), 2) + self.assertEqual( + data["content"][0], {"type": "text/plain", "value": text_content} + ) + self.assertEqual( + data["content"][1], {"type": "text/html", "value": html_content} + ) # Don't accidentally send the html part as an attachment: - self.assertNotIn('attachments', data) + self.assertNotIn("attachments", data) def test_html_only_message(self): - html_content = '

This is an important message.

' - email = mail.EmailMessage('Subject', html_content, 'from@example.com', ['to@example.com']) + html_content = "

This is an important message.

" + email = mail.EmailMessage( + "Subject", html_content, "from@example.com", ["to@example.com"] + ) email.content_subtype = "html" # Main content is now text/html email.send() data = self.get_api_call_json() - self.assertEqual(len(data['content']), 1) - self.assertEqual(data['content'][0], {'type': "text/html", 'value': html_content}) + self.assertEqual(len(data["content"]), 1) + self.assertEqual( + data["content"][0], {"type": "text/html", "value": html_content} + ) def test_extra_headers(self): - self.message.extra_headers = {'X-Custom': 'string', 'X-Num': 123, - 'Reply-To': '"Do Not Reply" '} + self.message.extra_headers = { + "X-Custom": "string", + "X-Num": 123, + "Reply-To": '"Do Not Reply" ', + } self.message.send() data = self.get_api_call_json() - self.assertEqual(data['headers']['X-Custom'], 'string') - self.assertEqual(data['headers']['X-Num'], '123') # converted to string (undoc'd SendGrid requirement) + self.assertEqual(data["headers"]["X-Custom"], "string") + # number converted to string (undocumented SendGrid requirement): + self.assertEqual(data["headers"]["X-Num"], "123") # Reply-To must be moved to separate param - self.assertNotIn('Reply-To', data['headers']) - self.assertEqual(data['reply_to'], {'name': "Do Not Reply", 'email': "noreply@example.com"}) + self.assertNotIn("Reply-To", data["headers"]) + self.assertEqual( + data["reply_to"], {"name": "Do Not Reply", "email": "noreply@example.com"} + ) def test_extra_headers_serialization_error(self): - self.message.extra_headers = {'X-Custom': Decimal(12.5)} + self.message.extra_headers = {"X-Custom": Decimal(12.5)} with self.assertRaisesMessage(AnymailSerializationError, "Decimal"): self.message.send() @@ -166,25 +255,38 @@ class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase): self.message.reply_to = ['"Reply recipient" \u2019

', mimetype='text/html') + self.message.attach( + "Une pièce jointe.html", "

\u2019

", mimetype="text/html" + ) self.message.send() - attachment = self.get_api_call_json()['attachments'][0] - self.assertEqual(attachment['filename'], 'Une pièce jointe.html') - self.assertEqual(b64decode(attachment['content']).decode('utf-8'), '

\u2019

') + attachment = self.get_api_call_json()["attachments"][0] + self.assertEqual(attachment["filename"], "Une pièce jointe.html") + self.assertEqual( + b64decode(attachment["content"]).decode("utf-8"), "

\u2019

" + ) def test_embedded_images(self): image_filename = SAMPLE_IMAGE_FILENAME @@ -227,44 +345,57 @@ class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase): image_data = sample_image_content(image_filename) cid = attach_inline_image_file(self.message, image_path) # Read from a png file - html_content = '

This has an inline image.

' % cid + html_content = ( + '

This has an inline image.

' % cid + ) self.message.attach_alternative(html_content, "text/html") self.message.send() data = self.get_api_call_json() - self.assertEqual(data['attachments'][0], { - 'filename': image_filename, - 'content': b64encode(image_data).decode('ascii'), - 'type': "image/png", # type inferred from filename - 'disposition': "inline", - 'content_id': cid, - }) + self.assertEqual( + data["attachments"][0], + { + "filename": image_filename, + "content": b64encode(image_data).decode("ascii"), + "type": "image/png", # (type inferred from filename) + "disposition": "inline", + "content_id": cid, + }, + ) def test_attached_images(self): image_filename = SAMPLE_IMAGE_FILENAME image_path = sample_image_path(image_filename) image_data = sample_image_content(image_filename) - self.message.attach_file(image_path) # option 1: attach as a file + # option 1: attach as a file + self.message.attach_file(image_path) - image = MIMEImage(image_data) # option 2: construct the MIMEImage and attach it directly + # option 2: construct the MIMEImage and attach it directly + image = MIMEImage(image_data) self.message.attach(image) self.message.send() - image_data_b64 = b64encode(image_data).decode('ascii') + image_data_b64 = b64encode(image_data).decode("ascii") data = self.get_api_call_json() - self.assertEqual(data['attachments'][0], { - 'filename': image_filename, # the named one - 'content': image_data_b64, - 'type': "image/png", - }) - self.assertEqual(data['attachments'][1], { - 'filename': '', # the unnamed one - 'content': image_data_b64, - 'type': "image/png", - }) + self.assertEqual( + data["attachments"][0], + { + "filename": image_filename, # the named one + "content": image_data_b64, + "type": "image/png", + }, + ) + self.assertEqual( + data["attachments"][1], + { + "filename": "", # the unnamed one + "content": image_data_b64, + "type": "image/png", + }, + ) def test_multiple_html_alternatives(self): # SendGrid's v3 API allows all kinds of content alternatives. @@ -275,41 +406,56 @@ class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase): # limitations to the ESP. self.message.body = "Text body" self.message.attach_alternative("

First html is OK

", "text/html") - self.message.attach_alternative("

And maybe second html, too

", "text/html") + self.message.attach_alternative( + "

And maybe second html, too

", "text/html" + ) self.message.send() data = self.get_api_call_json() - self.assertEqual(data['content'], [ - {'type': "text/plain", 'value': "Text body"}, - {'type': "text/html", 'value': "

First html is OK

"}, - {'type': "text/html", 'value': "

And maybe second html, too

"}, - ]) + self.assertEqual( + data["content"], + [ + {"type": "text/plain", "value": "Text body"}, + {"type": "text/html", "value": "

First html is OK

"}, + {"type": "text/html", "value": "

And maybe second html, too

"}, + ], + ) def test_non_html_alternative(self): self.message.body = "Text body" self.message.attach_alternative("{'maybe': 'allowed'}", "application/json") self.message.send() data = self.get_api_call_json() - self.assertEqual(data['content'], [ - {'type': "text/plain", 'value': "Text body"}, - {'type': "application/json", 'value': "{'maybe': 'allowed'}"}, - ]) + self.assertEqual( + data["content"], + [ + {"type": "text/plain", "value": "Text body"}, + {"type": "application/json", "value": "{'maybe': 'allowed'}"}, + ], + ) def test_api_failure(self): self.set_mock_response(status_code=400) with self.assertRaisesMessage(AnymailAPIError, "SendGrid API response 400"): - mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com']) + mail.send_mail("Subject", "Body", "from@example.com", ["to@example.com"]) # Make sure fail_silently is respected self.set_mock_response(status_code=400) - sent = mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com'], fail_silently=True) + sent = mail.send_mail( + "Subject", + "Body", + "from@example.com", + ["to@example.com"], + fail_silently=True, + ) self.assertEqual(sent, 0) def test_api_error_includes_details(self): """AnymailAPIError should include ESP's error message""" # JSON error response: error_response = b"""{"errors":[ - {"message":"Helpful explanation from SendGrid","field":"subject","help":null}, + {"message":"Helpful explanation from SendGrid", + "field":"subject","help":null}, {"message":"Another error","field":null,"help":null} ]}""" self.set_mock_response(status_code=400, raw=error_response) @@ -330,25 +476,30 @@ class SendGridBackendStandardEmailTests(SendGridBackendMockAPITestCase): self.message.send() -@tag('sendgrid') +@tag("sendgrid") class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase): """Test backend support for Anymail added features""" def test_envelope_sender(self): # SendGrid does not have a way to change envelope sender. self.message.envelope_sender = "anything@bounces.example.com" - with self.assertRaisesMessage(AnymailUnsupportedFeature, 'envelope_sender'): + with self.assertRaisesMessage(AnymailUnsupportedFeature, "envelope_sender"): self.message.send() def test_metadata(self): - self.message.metadata = {'user_id': "12345", 'items': 6, 'float': 98.6} + self.message.metadata = {"user_id": "12345", "items": 6, "float": 98.6} self.message.send() data = self.get_api_call_json() - data['custom_args'].pop('anymail_id', None) # remove anymail_id we added for tracking - self.assertEqual(data['custom_args'], {'user_id': "12345", - 'items': "6", # int converted to a string, - 'float': "98.6", # float converted to a string (watch binary rounding!) - }) + # remove anymail_id we added for tracking: + data["custom_args"].pop("anymail_id", None) + self.assertEqual( + data["custom_args"], + { + "user_id": "12345", + "items": "6", # int converted to a string, + "float": "98.6", # float converted to a string (watch binary rounding!) + }, + ) def test_send_at(self): utc_plus_6 = get_fixed_timezone(6 * 60) @@ -359,31 +510,39 @@ class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase): self.message.send_at = datetime(2016, 3, 4, 5, 6, 7, tzinfo=utc_minus_8) self.message.send() data = self.get_api_call_json() - self.assertEqual(data['send_at'], timegm((2016, 3, 4, 13, 6, 7))) # 05:06 UTC-8 == 13:06 UTC + self.assertEqual( + data["send_at"], timegm((2016, 3, 4, 13, 6, 7)) + ) # 05:06 UTC-8 == 13:06 UTC # Timezone-naive datetime assumed to be Django current_timezone - self.message.send_at = datetime(2022, 10, 11, 12, 13, 14, 567) # microseconds should get stripped + self.message.send_at = datetime( + 2022, 10, 11, 12, 13, 14, 567 + ) # microseconds should get stripped self.message.send() data = self.get_api_call_json() - self.assertEqual(data['send_at'], timegm((2022, 10, 11, 6, 13, 14))) # 12:13 UTC+6 == 06:13 UTC + self.assertEqual( + data["send_at"], timegm((2022, 10, 11, 6, 13, 14)) + ) # 12:13 UTC+6 == 06:13 UTC # Date-only treated as midnight in current timezone self.message.send_at = date(2022, 10, 22) self.message.send() data = self.get_api_call_json() - self.assertEqual(data['send_at'], timegm((2022, 10, 21, 18, 0, 0))) # 00:00 UTC+6 == 18:00-1d UTC + self.assertEqual( + data["send_at"], timegm((2022, 10, 21, 18, 0, 0)) + ) # 00:00 UTC+6 == 18:00-1d UTC # POSIX timestamp self.message.send_at = 1651820889 # 2022-05-06 07:08:09 UTC self.message.send() data = self.get_api_call_json() - self.assertEqual(data['send_at'], 1651820889) + self.assertEqual(data["send_at"], 1651820889) def test_tags(self): self.message.tags = ["receipt", "repeat-user"] self.message.send() data = self.get_api_call_json() - self.assertCountEqual(data['categories'], ["receipt", "repeat-user"]) + self.assertCountEqual(data["categories"], ["receipt", "repeat-user"]) def test_tracking(self): # Test one way... @@ -391,100 +550,136 @@ class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase): self.message.track_opens = True self.message.send() data = self.get_api_call_json() - self.assertEqual(data['tracking_settings']['click_tracking'], {'enable': False}) - self.assertEqual(data['tracking_settings']['open_tracking'], {'enable': True}) + self.assertEqual(data["tracking_settings"]["click_tracking"], {"enable": False}) + self.assertEqual(data["tracking_settings"]["open_tracking"], {"enable": True}) # ...and the opposite way self.message.track_clicks = True self.message.track_opens = False self.message.send() data = self.get_api_call_json() - self.assertEqual(data['tracking_settings']['click_tracking'], {'enable': True}) - self.assertEqual(data['tracking_settings']['open_tracking'], {'enable': False}) + self.assertEqual(data["tracking_settings"]["click_tracking"], {"enable": True}) + self.assertEqual(data["tracking_settings"]["open_tracking"], {"enable": False}) def test_template_id(self): self.message.template_id = "5997fcf6-2b9f-484d-acd5-7e9a99f0dc1f" self.message.send() data = self.get_api_call_json() - self.assertEqual(data['template_id'], "5997fcf6-2b9f-484d-acd5-7e9a99f0dc1f") + self.assertEqual(data["template_id"], "5997fcf6-2b9f-484d-acd5-7e9a99f0dc1f") def test_template_id_with_empty_body(self): - # v2 API required *some* text and html in message to render those template bodies, - # so the v2 backend set those to " " when necessary. + # v2 API required *some* text and html in message to render those template + # bodies, so the v2 backend set those to " " when necessary. # But per v3 docs: - # "If you use a template that contains content and a subject (either text or html), - # you do not need to specify those in the respective personalizations or message - # level parameters." + # "If you use a template that contains content and a subject (either text or + # html), you do not need to specify those in the respective personalizations + # or message level parameters." # So make sure we aren't adding body content where not needed: - message = mail.EmailMessage(from_email='from@example.com', to=['to@example.com']) + message = mail.EmailMessage( + from_email="from@example.com", to=["to@example.com"] + ) message.template_id = "5997fcf6-2b9f-484d-acd5-7e9a99f0dc1f" message.send() data = self.get_api_call_json() - self.assertNotIn('content', data) # neither text nor html body - self.assertNotIn('subject', data) + self.assertNotIn("content", data) # neither text nor html body + self.assertNotIn("subject", data) def test_merge_data(self): # A template_id starting with "d-" indicates you are using SendGrid's newer # (non-legacy) "dynamic" transactional templates self.message.template_id = "d-5a963add2ec84305813ff860db277d7a" - self.message.from_email = 'from@example.com' - self.message.to = ['alice@example.com', 'Bob ', 'celia@example.com'] - self.message.cc = ['cc@example.com'] # gets applied to *each* recipient in a merge + self.message.from_email = "from@example.com" + self.message.to = [ + "alice@example.com", + "Bob ", + "celia@example.com", + ] + # cc gets applied to *each* recipient in a merge: + self.message.cc = ["cc@example.com"] self.message.merge_data = { - 'alice@example.com': {'name': "Alice", 'group': "Developers"}, - 'bob@example.com': {'name': "Bob"}, # and leave group undefined + "alice@example.com": {"name": "Alice", "group": "Developers"}, + "bob@example.com": {"name": "Bob"}, # and leave group undefined # and no data for celia@example.com } self.message.merge_global_data = { - 'group': "Users", - 'site': "ExampleCo", + "group": "Users", + "site": "ExampleCo", } self.message.send() data = self.get_api_call_json() - self.assertEqual(data['personalizations'], [ - {'to': [{'email': 'alice@example.com'}], - 'cc': [{'email': 'cc@example.com'}], # all recipients get the cc - 'custom_args': {'anymail_id': 'mocked-uuid-1'}, - 'dynamic_template_data': { - 'name': "Alice", 'group': "Developers", 'site': "ExampleCo"}}, - {'to': [{'email': 'bob@example.com', 'name': '"Bob"'}], - 'cc': [{'email': 'cc@example.com'}], - 'custom_args': {'anymail_id': 'mocked-uuid-2'}, - 'dynamic_template_data': { - 'name': "Bob", 'group': "Users", 'site': "ExampleCo"}}, - {'to': [{'email': 'celia@example.com'}], - 'cc': [{'email': 'cc@example.com'}], - 'custom_args': {'anymail_id': 'mocked-uuid-3'}, - 'dynamic_template_data': { - 'group': "Users", 'site': "ExampleCo"}}, - ]) - self.assertNotIn('sections', data) # 'sections' not used with dynamic templates + self.assertEqual( + data["personalizations"], + [ + { + "to": [{"email": "alice@example.com"}], + "cc": [{"email": "cc@example.com"}], # all recipients get the cc + "custom_args": {"anymail_id": "mocked-uuid-1"}, + "dynamic_template_data": { + "name": "Alice", + "group": "Developers", + "site": "ExampleCo", + }, + }, + { + "to": [{"email": "bob@example.com", "name": '"Bob"'}], + "cc": [{"email": "cc@example.com"}], + "custom_args": {"anymail_id": "mocked-uuid-2"}, + "dynamic_template_data": { + "name": "Bob", + "group": "Users", + "site": "ExampleCo", + }, + }, + { + "to": [{"email": "celia@example.com"}], + "cc": [{"email": "cc@example.com"}], + "custom_args": {"anymail_id": "mocked-uuid-3"}, + "dynamic_template_data": {"group": "Users", "site": "ExampleCo"}, + }, + ], + ) + self.assertNotIn("sections", data) # 'sections' not used with dynamic templates def test_explicit_dynamic_template(self): - # undocumented esp_extra['use_dynamic_template'] can be used to force dynamic/legacy params - self.message.merge_data = {'to@example.com': {"test": "data"}} + # undocumented esp_extra['use_dynamic_template'] + # can be used to force dynamic/legacy params + self.message.merge_data = {"to@example.com": {"test": "data"}} self.message.template_id = "apparently-not-dynamic" # doesn't start with "d-" self.message.esp_extra = {"use_dynamic_template": True} self.message.send() data = self.get_api_call_json() - self.assertEqual(data['personalizations'], [ - {'to': [{'email': 'to@example.com'}], - 'custom_args': {'anymail_id': 'mocked-uuid-1'}, - 'dynamic_template_data': {"test": "data"}}]) + self.assertEqual( + data["personalizations"], + [ + { + "to": [{"email": "to@example.com"}], + "custom_args": {"anymail_id": "mocked-uuid-1"}, + "dynamic_template_data": {"test": "data"}, + } + ], + ) self.message.template_id = "d-apparently-not-legacy" - self.message.esp_extra = {"use_dynamic_template": False, - "merge_field_format": "<%{}%>"} + self.message.esp_extra = { + "use_dynamic_template": False, + "merge_field_format": "<%{}%>", + } self.message.send() data = self.get_api_call_json() - self.assertEqual(data['personalizations'], [ - {'to': [{'email': 'to@example.com'}], - 'custom_args': {'anymail_id': 'mocked-uuid-2'}, - 'substitutions': {"<%test%>": "data"}}]) + self.assertEqual( + data["personalizations"], + [ + { + "to": [{"email": "to@example.com"}], + "custom_args": {"anymail_id": "mocked-uuid-2"}, + "substitutions": {"<%test%>": "data"}, + } + ], + ) def test_merge_data_global_only(self): # a template with only global data can be used to send the same message @@ -495,220 +690,349 @@ class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase): self.message.send() data = self.get_api_call_json() - self.assertEqual(data['personalizations'], [ - {'to': [{'email': 'one@example.com'}, {'email': 'two@example.com'}], # not batch - 'custom_args': {'anymail_id': 'mocked-uuid-1'}, - 'dynamic_template_data': {"test": "data"}}]) + self.assertEqual( + data["personalizations"], + [ + { + "to": [ # not batch + {"email": "one@example.com"}, + {"email": "two@example.com"}, + ], + "custom_args": {"anymail_id": "mocked-uuid-1"}, + "dynamic_template_data": {"test": "data"}, + } + ], + ) def test_legacy_merge_data(self): # unless a new "dynamic template" is specified, Anymail assumes the legacy # "substitutions" format for merge data - self.message.from_email = 'from@example.com' - self.message.to = ['alice@example.com', 'Bob ', 'celia@example.com'] - self.message.cc = ['cc@example.com'] # gets applied to *each* recipient in a merge + self.message.from_email = "from@example.com" + self.message.to = [ + "alice@example.com", + "Bob ", + "celia@example.com", + ] + # cc gets applied to *each* recipient in a merge: + self.message.cc = ["cc@example.com"] # SendGrid template_id is not required to use merge. # You can just supply (legacy) template content as the message (e.g.): self.message.body = "Hi :name. Welcome to :group at :site." self.message.merge_data = { - # You must either include merge field delimiters in the keys (':name' rather than just 'name') - # as shown here, or use one of the merge_field_format options shown in the test cases below - 'alice@example.com': {':name': "Alice", ':group': "Developers"}, - 'bob@example.com': {':name': "Bob"}, # and leave :group undefined + # You must either include merge field delimiters in the keys + # (':name' rather than just 'name') as shown here, or use one of the + # merge_field_format options shown in the test cases below + "alice@example.com": {":name": "Alice", ":group": "Developers"}, + "bob@example.com": {":name": "Bob"}, # and leave :group undefined # and no data for celia@example.com } self.message.merge_global_data = { - ':group': "Users", - ':site': "ExampleCo", + ":group": "Users", + ":site": "ExampleCo", } self.message.send() data = self.get_api_call_json() - self.assertEqual(data['personalizations'], [ - {'to': [{'email': 'alice@example.com'}], - 'cc': [{'email': 'cc@example.com'}], # all recipients get the cc - 'custom_args': {'anymail_id': 'mocked-uuid-1'}, - 'substitutions': {':name': "Alice", ':group': "Developers", - ':site': "ExampleCo"}}, # merge_global_data merged - {'to': [{'email': 'bob@example.com', 'name': '"Bob"'}], - 'cc': [{'email': 'cc@example.com'}], - 'custom_args': {'anymail_id': 'mocked-uuid-2'}, - 'substitutions': {':name': "Bob", ':group': "Users", ':site': "ExampleCo"}}, - {'to': [{'email': 'celia@example.com'}], - 'cc': [{'email': 'cc@example.com'}], - 'custom_args': {'anymail_id': 'mocked-uuid-3'}, - 'substitutions': {':group': "Users", ':site': "ExampleCo"}}, - ]) - self.assertNotIn('sections', data) # 'sections' no longer used for merge_global_data + self.assertEqual( + data["personalizations"], + [ + { + "to": [{"email": "alice@example.com"}], + "cc": [{"email": "cc@example.com"}], # all recipients get the cc + "custom_args": {"anymail_id": "mocked-uuid-1"}, + "substitutions": { + ":name": "Alice", + ":group": "Developers", + ":site": "ExampleCo", # merge_global_data merged + }, + }, + { + "to": [{"email": "bob@example.com", "name": '"Bob"'}], + "cc": [{"email": "cc@example.com"}], + "custom_args": {"anymail_id": "mocked-uuid-2"}, + "substitutions": { + ":name": "Bob", + ":group": "Users", + ":site": "ExampleCo", + }, + }, + { + "to": [{"email": "celia@example.com"}], + "cc": [{"email": "cc@example.com"}], + "custom_args": {"anymail_id": "mocked-uuid-3"}, + "substitutions": {":group": "Users", ":site": "ExampleCo"}, + }, + ], + ) + # 'sections' no longer used for merge_global_data: + self.assertNotIn("sections", data) - @override_settings(ANYMAIL_SENDGRID_MERGE_FIELD_FORMAT=":{}") # :field as shown in SG examples + @override_settings( + ANYMAIL_SENDGRID_MERGE_FIELD_FORMAT=":{}" # :field as shown in SG examples + ) def test_legacy_merge_field_format_setting(self): # Provide merge field delimiters in settings.py - self.message.to = ['alice@example.com', 'Bob '] + self.message.to = ["alice@example.com", "Bob "] self.message.merge_data = { - 'alice@example.com': {'name': "Alice", 'group': "Developers"}, - 'bob@example.com': {'name': "Bob"}, # and leave group undefined + "alice@example.com": {"name": "Alice", "group": "Developers"}, + "bob@example.com": {"name": "Bob"}, # and leave group undefined } - self.message.merge_global_data = {'site': "ExampleCo"} + self.message.merge_global_data = {"site": "ExampleCo"} self.message.send() data = self.get_api_call_json() - self.assertEqual(data['personalizations'], [ - {'to': [{'email': 'alice@example.com'}], - 'custom_args': {'anymail_id': 'mocked-uuid-1'}, - 'substitutions': {':name': "Alice", ':group': "Developers", # keys changed to :field - ':site': "ExampleCo"}}, - {'to': [{'email': 'bob@example.com', 'name': '"Bob"'}], - 'custom_args': {'anymail_id': 'mocked-uuid-2'}, - 'substitutions': {':name': "Bob", ':site': "ExampleCo"}} - ]) + self.assertEqual( + data["personalizations"], + [ + { + "to": [{"email": "alice@example.com"}], + "custom_args": {"anymail_id": "mocked-uuid-1"}, + "substitutions": { + ":name": "Alice", + ":group": "Developers", # keys changed to :field + ":site": "ExampleCo", + }, + }, + { + "to": [{"email": "bob@example.com", "name": '"Bob"'}], + "custom_args": {"anymail_id": "mocked-uuid-2"}, + "substitutions": {":name": "Bob", ":site": "ExampleCo"}, + }, + ], + ) def test_legacy_merge_field_format_esp_extra(self): # Provide merge field delimiters for an individual message - self.message.to = ['alice@example.com', 'Bob '] + self.message.to = ["alice@example.com", "Bob "] self.message.merge_data = { - 'alice@example.com': {'name': "Alice", 'group': "Developers"}, - 'bob@example.com': {'name': "Bob"}, # and leave group undefined + "alice@example.com": {"name": "Alice", "group": "Developers"}, + "bob@example.com": {"name": "Bob"}, # and leave group undefined } - self.message.merge_global_data = {'site': "ExampleCo"} - self.message.esp_extra = {'merge_field_format': '*|{}|*'} # match Mandrill/MailChimp delimiters + self.message.merge_global_data = {"site": "ExampleCo"} + # match Mandrill/MailChimp *|field|* delimiters: + self.message.esp_extra = {"merge_field_format": "*|{}|*"} self.message.send() data = self.get_api_call_json() - self.assertEqual(data['personalizations'], [ - {'to': [{'email': 'alice@example.com'}], - 'custom_args': {'anymail_id': 'mocked-uuid-1'}, - 'substitutions': {'*|name|*': "Alice", '*|group|*': "Developers", '*|site|*': "ExampleCo"}}, - {'to': [{'email': 'bob@example.com', 'name': '"Bob"'}], - 'custom_args': {'anymail_id': 'mocked-uuid-2'}, - 'substitutions': {'*|name|*': "Bob", '*|site|*': "ExampleCo"}} - ]) + self.assertEqual( + data["personalizations"], + [ + { + "to": [{"email": "alice@example.com"}], + "custom_args": {"anymail_id": "mocked-uuid-1"}, + "substitutions": { + "*|name|*": "Alice", + "*|group|*": "Developers", + "*|site|*": "ExampleCo", + }, + }, + { + "to": [{"email": "bob@example.com", "name": '"Bob"'}], + "custom_args": {"anymail_id": "mocked-uuid-2"}, + "substitutions": {"*|name|*": "Bob", "*|site|*": "ExampleCo"}, + }, + ], + ) # Make sure our esp_extra merge_field_format doesn't get sent to SendGrid API: - self.assertNotIn('merge_field_format', data) + self.assertNotIn("merge_field_format", data) def test_legacy_warn_if_no_merge_field_delimiters(self): - self.message.to = ['alice@example.com'] + self.message.to = ["alice@example.com"] self.message.merge_data = { - 'alice@example.com': {'name': "Alice", 'group': "Developers"}, + "alice@example.com": {"name": "Alice", "group": "Developers"}, } - with self.assertWarnsRegex(AnymailWarning, r'SENDGRID_MERGE_FIELD_FORMAT'): + with self.assertWarnsRegex(AnymailWarning, r"SENDGRID_MERGE_FIELD_FORMAT"): self.message.send() def test_legacy_warn_if_no_global_merge_field_delimiters(self): - self.message.merge_global_data = {'site': "ExampleCo"} - with self.assertWarnsRegex(AnymailWarning, r'SENDGRID_MERGE_FIELD_FORMAT'): + self.message.merge_global_data = {"site": "ExampleCo"} + with self.assertWarnsRegex(AnymailWarning, r"SENDGRID_MERGE_FIELD_FORMAT"): self.message.send() def test_merge_metadata(self): - self.message.to = ['alice@example.com', 'Bob '] + self.message.to = ["alice@example.com", "Bob "] self.message.merge_metadata = { - 'alice@example.com': {'order_id': 123}, - 'bob@example.com': {'order_id': 678, 'tier': 'premium'}, + "alice@example.com": {"order_id": 123}, + "bob@example.com": {"order_id": 678, "tier": "premium"}, } self.message.send() data = self.get_api_call_json() - self.assertEqual(data['personalizations'], [ - {'to': [{'email': 'alice@example.com'}], - # anymail_id added to other custom_args - 'custom_args': {'anymail_id': 'mocked-uuid-1', 'order_id': '123'}}, - {'to': [{'email': 'bob@example.com', 'name': '"Bob"'}], - 'custom_args': {'anymail_id': 'mocked-uuid-2', 'order_id': '678', 'tier': 'premium'}}, - ]) + self.assertEqual( + data["personalizations"], + [ + { + "to": [{"email": "alice@example.com"}], + # anymail_id added to other custom_args + "custom_args": {"anymail_id": "mocked-uuid-1", "order_id": "123"}, + }, + { + "to": [{"email": "bob@example.com", "name": '"Bob"'}], + "custom_args": { + "anymail_id": "mocked-uuid-2", + "order_id": "678", + "tier": "premium", + }, + }, + ], + ) def test_metadata_with_merge_metadata(self): # Per SendGrid docs: "personalizations[x].custom_args will be merged # with message level custom_args, overriding any conflicting keys." # So there's no need to merge global metadata with per-recipient merge_metadata # (like we have to for template merge_global_data and merge_data). - self.message.to = ['alice@example.com', 'Bob '] - self.message.metadata = {'tier': 'basic', 'batch': 'ax24'} + self.message.to = ["alice@example.com", "Bob "] + self.message.metadata = {"tier": "basic", "batch": "ax24"} self.message.merge_metadata = { - 'alice@example.com': {'order_id': 123}, - 'bob@example.com': {'order_id': 678, 'tier': 'premium'}, + "alice@example.com": {"order_id": 123}, + "bob@example.com": {"order_id": 678, "tier": "premium"}, } self.message.send() data = self.get_api_call_json() - self.assertEqual(data['personalizations'], [ - {'to': [{'email': 'alice@example.com'}], - 'custom_args': {'anymail_id': 'mocked-uuid-1', 'order_id': '123'}}, - {'to': [{'email': 'bob@example.com', 'name': '"Bob"'}], - 'custom_args': {'anymail_id': 'mocked-uuid-2', 'order_id': '678', 'tier': 'premium'}}, - ]) - self.assertEqual(data['custom_args'], {'tier': 'basic', 'batch': 'ax24'}) + self.assertEqual( + data["personalizations"], + [ + { + "to": [{"email": "alice@example.com"}], + "custom_args": {"anymail_id": "mocked-uuid-1", "order_id": "123"}, + }, + { + "to": [{"email": "bob@example.com", "name": '"Bob"'}], + "custom_args": { + "anymail_id": "mocked-uuid-2", + "order_id": "678", + "tier": "premium", + }, + }, + ], + ) + self.assertEqual(data["custom_args"], {"tier": "basic", "batch": "ax24"}) def test_merge_metadata_with_merge_data(self): # (using dynamic templates) - self.message.to = ['alice@example.com', 'Bob ', 'celia@example.com'] - self.message.cc = ['cc@example.com'] # gets applied to *each* recipient in a merge + self.message.to = [ + "alice@example.com", + "Bob ", + "celia@example.com", + ] + # cc gets applied to *each* recipient in a merge: + self.message.cc = ["cc@example.com"] self.message.template_id = "d-5a963add2ec84305813ff860db277d7a" self.message.merge_data = { - 'alice@example.com': {'name': "Alice", 'group': "Developers"}, - 'bob@example.com': {'name': "Bob"} + "alice@example.com": {"name": "Alice", "group": "Developers"}, + "bob@example.com": {"name": "Bob"} # and no data for celia@example.com } self.message.merge_global_data = { - 'group': "Users", - 'site': "ExampleCo", + "group": "Users", + "site": "ExampleCo", } self.message.merge_metadata = { - 'alice@example.com': {'order_id': 123}, - 'bob@example.com': {'order_id': 678, 'tier': 'premium'}, + "alice@example.com": {"order_id": 123}, + "bob@example.com": {"order_id": 678, "tier": "premium"}, # and no metadata for celia@example.com } self.message.send() data = self.get_api_call_json() - self.assertEqual(data['personalizations'], [ - {'to': [{'email': 'alice@example.com'}], - 'cc': [{'email': 'cc@example.com'}], # all recipients get the cc - 'dynamic_template_data': { - 'name': "Alice", 'group': "Developers", 'site': "ExampleCo"}, - 'custom_args': {'anymail_id': 'mocked-uuid-1', 'order_id': '123'}}, - {'to': [{'email': 'bob@example.com', 'name': '"Bob"'}], - 'cc': [{'email': 'cc@example.com'}], - 'dynamic_template_data': { - 'name': "Bob", 'group': "Users", 'site': "ExampleCo"}, - 'custom_args': {'anymail_id': 'mocked-uuid-2', 'order_id': '678', 'tier': 'premium'}}, - {'to': [{'email': 'celia@example.com'}], - 'cc': [{'email': 'cc@example.com'}], - 'dynamic_template_data': { - 'group': "Users", 'site': "ExampleCo"}, - 'custom_args': {'anymail_id': 'mocked-uuid-3'}}, - ]) + self.assertEqual( + data["personalizations"], + [ + { + "to": [{"email": "alice@example.com"}], + "cc": [{"email": "cc@example.com"}], # all recipients get the cc + "dynamic_template_data": { + "name": "Alice", + "group": "Developers", + "site": "ExampleCo", + }, + "custom_args": {"anymail_id": "mocked-uuid-1", "order_id": "123"}, + }, + { + "to": [{"email": "bob@example.com", "name": '"Bob"'}], + "cc": [{"email": "cc@example.com"}], + "dynamic_template_data": { + "name": "Bob", + "group": "Users", + "site": "ExampleCo", + }, + "custom_args": { + "anymail_id": "mocked-uuid-2", + "order_id": "678", + "tier": "premium", + }, + }, + { + "to": [{"email": "celia@example.com"}], + "cc": [{"email": "cc@example.com"}], + "dynamic_template_data": {"group": "Users", "site": "ExampleCo"}, + "custom_args": {"anymail_id": "mocked-uuid-3"}, + }, + ], + ) def test_merge_metadata_with_legacy_template(self): - self.message.to = ['alice@example.com', 'Bob ', 'celia@example.com'] - self.message.cc = ['cc@example.com'] # gets applied to *each* recipient in a merge + self.message.to = [ + "alice@example.com", + "Bob ", + "celia@example.com", + ] + # cc gets applied to *each* recipient in a merge: + self.message.cc = ["cc@example.com"] self.message.template_id = "5a963add2ec84305813ff860db277d7a" - self.message.esp_extra = {'merge_field_format': ':{}'} + self.message.esp_extra = {"merge_field_format": ":{}"} self.message.merge_data = { - 'alice@example.com': {'name': "Alice", 'group': "Developers"}, - 'bob@example.com': {'name': "Bob"} + "alice@example.com": {"name": "Alice", "group": "Developers"}, + "bob@example.com": {"name": "Bob"} # and no data for celia@example.com } self.message.merge_global_data = { - 'group': "Users", - 'site': "ExampleCo", + "group": "Users", + "site": "ExampleCo", } self.message.merge_metadata = { - 'alice@example.com': {'order_id': 123}, - 'bob@example.com': {'order_id': 678, 'tier': 'premium'}, + "alice@example.com": {"order_id": 123}, + "bob@example.com": {"order_id": 678, "tier": "premium"}, # and no metadata for celia@example.com } self.message.send() data = self.get_api_call_json() - self.assertEqual(data['personalizations'], [ - {'to': [{'email': 'alice@example.com'}], - 'cc': [{'email': 'cc@example.com'}], # all recipients get the cc - 'custom_args': {'anymail_id': 'mocked-uuid-1', 'order_id': '123'}, - 'substitutions': {':name': "Alice", ':group': "Developers", ':site': "ExampleCo"}}, - {'to': [{'email': 'bob@example.com', 'name': '"Bob"'}], - 'cc': [{'email': 'cc@example.com'}], - 'custom_args': {'anymail_id': 'mocked-uuid-2', 'order_id': '678', 'tier': 'premium'}, - 'substitutions': {':name': "Bob", ':group': "Users", ':site': "ExampleCo"}}, - {'to': [{'email': 'celia@example.com'}], - 'cc': [{'email': 'cc@example.com'}], - 'custom_args': {'anymail_id': 'mocked-uuid-3'}, - 'substitutions': {':group': "Users", ':site': "ExampleCo"}}, - ]) + self.assertEqual( + data["personalizations"], + [ + { + "to": [{"email": "alice@example.com"}], + "cc": [{"email": "cc@example.com"}], # all recipients get the cc + "custom_args": {"anymail_id": "mocked-uuid-1", "order_id": "123"}, + "substitutions": { + ":name": "Alice", + ":group": "Developers", + ":site": "ExampleCo", + }, + }, + { + "to": [{"email": "bob@example.com", "name": '"Bob"'}], + "cc": [{"email": "cc@example.com"}], + "custom_args": { + "anymail_id": "mocked-uuid-2", + "order_id": "678", + "tier": "premium", + }, + "substitutions": { + ":name": "Bob", + ":group": "Users", + ":site": "ExampleCo", + }, + }, + { + "to": [{"email": "celia@example.com"}], + "cc": [{"email": "cc@example.com"}], + "custom_args": {"anymail_id": "mocked-uuid-3"}, + "substitutions": {":group": "Users", ":site": "ExampleCo"}, + }, + ], + ) - @override_settings(ANYMAIL_SENDGRID_GENERATE_MESSAGE_ID=False) # else we force custom_args + @override_settings( + ANYMAIL_SENDGRID_GENERATE_MESSAGE_ID=False # else we force custom_args + ) def test_default_omits_options(self): """Make sure by default we don't send any ESP-specific options. @@ -718,120 +1042,165 @@ class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase): """ self.message.send() data = self.get_api_call_json() - self.assertNotIn('asm', data) - self.assertNotIn('attachments', data) - self.assertNotIn('batch_id', data) - self.assertNotIn('categories', data) - self.assertNotIn('custom_args', data) - self.assertNotIn('headers', data) - self.assertNotIn('ip_pool_name', data) - self.assertNotIn('mail_settings', data) - self.assertNotIn('sections', data) - self.assertNotIn('send_at', data) - self.assertNotIn('template_id', data) - self.assertNotIn('tracking_settings', data) + self.assertNotIn("asm", data) + self.assertNotIn("attachments", data) + self.assertNotIn("batch_id", data) + self.assertNotIn("categories", data) + self.assertNotIn("custom_args", data) + self.assertNotIn("headers", data) + self.assertNotIn("ip_pool_name", data) + self.assertNotIn("mail_settings", data) + self.assertNotIn("sections", data) + self.assertNotIn("send_at", data) + self.assertNotIn("template_id", data) + self.assertNotIn("tracking_settings", data) - for personalization in data['personalizations']: - self.assertNotIn('custom_args', personalization) - self.assertNotIn('dynamic_template_data', personalization) - self.assertNotIn('headers', personalization) - self.assertNotIn('send_at', personalization) - self.assertNotIn('substitutions', personalization) + for personalization in data["personalizations"]: + self.assertNotIn("custom_args", personalization) + self.assertNotIn("dynamic_template_data", personalization) + self.assertNotIn("headers", personalization) + self.assertNotIn("send_at", personalization) + self.assertNotIn("substitutions", personalization) def test_esp_extra(self): self.message.tags = ["tag"] self.message.track_clicks = True self.message.esp_extra = { - 'ip_pool_name': "transactional", - 'asm': { # subscription management - 'group_id': 1, + "ip_pool_name": "transactional", + "asm": { # subscription management + "group_id": 1, }, - 'tracking_settings': { - 'subscription_tracking': { - 'enable': True, - 'substitution_tag': '[unsubscribe_url]', + "tracking_settings": { + "subscription_tracking": { + "enable": True, + "substitution_tag": "[unsubscribe_url]", }, }, } self.message.send() data = self.get_api_call_json() # merged from esp_extra: - self.assertEqual(data['ip_pool_name'], "transactional") - self.assertEqual(data['asm'], {'group_id': 1}) - self.assertEqual(data['tracking_settings']['subscription_tracking'], - {'enable': True, 'substitution_tag': "[unsubscribe_url]"}) + self.assertEqual(data["ip_pool_name"], "transactional") + self.assertEqual(data["asm"], {"group_id": 1}) + self.assertEqual( + data["tracking_settings"]["subscription_tracking"], + {"enable": True, "substitution_tag": "[unsubscribe_url]"}, + ) # make sure we didn't overwrite Anymail message options: - self.assertEqual(data['categories'], ["tag"]) - self.assertEqual(data['tracking_settings']['click_tracking'], {'enable': True}) + self.assertEqual(data["categories"], ["tag"]) + self.assertEqual(data["tracking_settings"]["click_tracking"], {"enable": True}) def test_esp_extra_pesonalizations(self): self.message.to = ["First recipient ", "second@example.com"] self.message.merge_data = {} # force separate messages for each 'to' - # esp_extra['personalizations'] dict merges with message-derived personalizations - self.message.esp_extra = { - "personalizations": {"future_feature": "works"}} + # esp_extra['personalizations'] dict + # merges with message-derived personalizations + self.message.esp_extra = {"personalizations": {"future_feature": "works"}} self.message.send() data = self.get_api_call_json() - self.assertEqual(data['personalizations'], [ - {'to': [{'email': 'first@example.com', 'name': '"First recipient"'}], - 'custom_args': {'anymail_id': 'mocked-uuid-1'}, - 'future_feature': "works"}, - {'to': [{'email': 'second@example.com'}], - 'custom_args': {'anymail_id': 'mocked-uuid-2'}, - 'future_feature': "works"}, # merged into *every* recipient - ]) + self.assertEqual( + data["personalizations"], + [ + { + "to": [{"email": "first@example.com", "name": '"First recipient"'}], + "custom_args": {"anymail_id": "mocked-uuid-1"}, + "future_feature": "works", + }, + { + "to": [{"email": "second@example.com"}], + "custom_args": {"anymail_id": "mocked-uuid-2"}, + "future_feature": "works", # merged into *every* recipient + }, + ], + ) # but esp_extra['personalizations'] list just overrides entire personalizations # (for backwards compatibility) self.message.esp_extra = { - "personalizations": [{"to": [{"email": "custom@example.com"}], - "future_feature": "works"}]} + "personalizations": [ + {"to": [{"email": "custom@example.com"}], "future_feature": "works"} + ] + } self.message.send() data = self.get_api_call_json() - self.assertEqual(data['personalizations'], [ - {'to': [{'email': 'custom@example.com'}], - 'custom_args': {'anymail_id': 'mocked-uuid-3'}, - 'future_feature': "works"}, - ]) + self.assertEqual( + data["personalizations"], + [ + { + "to": [{"email": "custom@example.com"}], + "custom_args": {"anymail_id": "mocked-uuid-3"}, + "future_feature": "works", + }, + ], + ) # noinspection PyUnresolvedReferences def test_send_attaches_anymail_status(self): - """ The anymail_status should be attached to the message when it is sent """ - # the DEFAULT_RAW_RESPONSE above is the *only* success response SendGrid returns, - # so no need to override it here - msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],) + """The anymail_status should be attached to the message when it is sent""" + # the DEFAULT_RAW_RESPONSE above is the *only* success response SendGrid + # returns, so no need to override it here + msg = mail.EmailMessage( + "Subject", + "Message", + "from@example.com", + ["to1@example.com"], + ) sent = msg.send() self.assertEqual(sent, 1) - self.assertEqual(msg.anymail_status.status, {'queued'}) - self.assertEqual(msg.anymail_status.message_id, 'mocked-uuid-1') - self.assertEqual(msg.anymail_status.recipients['to1@example.com'].status, 'queued') - self.assertEqual(msg.anymail_status.recipients['to1@example.com'].message_id, 'mocked-uuid-1') - self.assertEqual(msg.anymail_status.esp_response.content, self.DEFAULT_RAW_RESPONSE) + self.assertEqual(msg.anymail_status.status, {"queued"}) + self.assertEqual(msg.anymail_status.message_id, "mocked-uuid-1") + self.assertEqual( + msg.anymail_status.recipients["to1@example.com"].status, "queued" + ) + self.assertEqual( + msg.anymail_status.recipients["to1@example.com"].message_id, "mocked-uuid-1" + ) + self.assertEqual( + msg.anymail_status.esp_response.content, self.DEFAULT_RAW_RESPONSE + ) def test_batch_recipients_get_unique_message_ids(self): """In a batch send, each recipient should get a distinct own message_id""" - msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', - ['to1@example.com', 'Someone Else '], - cc=['cc@example.com']) + msg = mail.EmailMessage( + "Subject", + "Message", + "from@example.com", + ["to1@example.com", "Someone Else "], + cc=["cc@example.com"], + ) msg.merge_data = {} # force batch send msg.send() - self.assertEqual(msg.anymail_status.message_id, {'mocked-uuid-1', 'mocked-uuid-2'}) - self.assertEqual(msg.anymail_status.recipients['to1@example.com'].message_id, 'mocked-uuid-1') - self.assertEqual(msg.anymail_status.recipients['to2@example.com'].message_id, 'mocked-uuid-2') - # cc's (and bcc's) get copied for all batch recipients, but we can only communicate one message_id: - self.assertEqual(msg.anymail_status.recipients['cc@example.com'].message_id, 'mocked-uuid-2') + self.assertEqual( + msg.anymail_status.message_id, {"mocked-uuid-1", "mocked-uuid-2"} + ) + self.assertEqual( + msg.anymail_status.recipients["to1@example.com"].message_id, "mocked-uuid-1" + ) + self.assertEqual( + msg.anymail_status.recipients["to2@example.com"].message_id, "mocked-uuid-2" + ) + # cc's (and bcc's) get copied for all batch recipients, + # but we can only communicate one message_id: + self.assertEqual( + msg.anymail_status.recipients["cc@example.com"].message_id, "mocked-uuid-2" + ) @override_settings(ANYMAIL_SENDGRID_GENERATE_MESSAGE_ID=False) def test_disable_generate_message_id(self): - msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],) + msg = mail.EmailMessage( + "Subject", + "Message", + "from@example.com", + ["to1@example.com"], + ) msg.send() self.assertIsNone(msg.anymail_status.message_id) - self.assertIsNone(msg.anymail_status.recipients['to1@example.com'].message_id) + self.assertIsNone(msg.anymail_status.recipients["to1@example.com"].message_id) # noinspection PyUnresolvedReferences def test_send_failed_anymail_status(self): - """ If the send fails, anymail_status should contain initial values""" + """If the send fails, anymail_status should contain initial values""" self.set_mock_response(status_code=500) sent = self.message.send(fail_silently=True) self.assertEqual(sent, 0) @@ -842,28 +1211,42 @@ class SendGridBackendAnymailFeatureTests(SendGridBackendMockAPITestCase): def test_json_serialization_errors(self): """Try to provide more information about non-json-serializable data""" - self.message.metadata = {'total': Decimal('19.99')} + self.message.metadata = {"total": Decimal("19.99")} with self.assertRaises(AnymailSerializationError) as cm: self.message.send() err = cm.exception self.assertIsInstance(err, TypeError) # compatibility with json.dumps - self.assertIn("Don't know how to send this data to SendGrid", str(err)) # our added context - self.assertRegex(str(err), r"Decimal.*is not JSON serializable") # original message + # our added context: + self.assertIn("Don't know how to send this data to SendGrid", str(err)) + # original message: + self.assertRegex(str(err), r"Decimal.*is not JSON serializable") @override_settings(ANYMAIL_SENDGRID_WORKAROUND_NAME_QUOTE_BUG=False) def test_undocumented_workaround_name_quote_bug_setting(self): - mail.send_mail("Subject", "Body", '"Sender, Inc." ']) + mail.send_mail( + "Subject", + "Body", + '"Sender, Inc." '], + ) data = self.get_api_call_json() - self.assertEqual(data["personalizations"][0]["to"][0], - {"email": "to@example.com", "name": "Recipient, Ltd."}) # no extra quotes on name - self.assertEqual(data["from"], - {"email": "from@example.com", "name": "Sender, Inc."}) + self.assertEqual( + data["personalizations"][0]["to"][0], + { + "email": "to@example.com", + "name": "Recipient, Ltd.", # no extra quotes on name + }, + ) + self.assertEqual( + data["from"], {"email": "from@example.com", "name": "Sender, Inc."} + ) -@tag('sendgrid') +@tag("sendgrid") class SendGridBackendRecipientsRefusedTests(SendGridBackendMockAPITestCase): - """Should raise AnymailRecipientsRefused when *all* recipients are rejected or invalid""" + """ + Should raise AnymailRecipientsRefused when *all* recipients are rejected or invalid + """ # SendGrid doesn't check email bounce or complaint lists at time of send -- # it always just queues the message. You'll need to listen for the "rejected" @@ -871,44 +1254,52 @@ class SendGridBackendRecipientsRefusedTests(SendGridBackendMockAPITestCase): pass # not applicable to this backend -@tag('sendgrid') -class SendGridBackendSessionSharingTestCase(SessionSharingTestCases, SendGridBackendMockAPITestCase): +@tag("sendgrid") +class SendGridBackendSessionSharingTestCase( + SessionSharingTestCases, SendGridBackendMockAPITestCase +): """Requests session sharing tests""" + pass # tests are defined in SessionSharingTestCases -@tag('sendgrid') +@tag("sendgrid") @override_settings(EMAIL_BACKEND="anymail.backends.sendgrid.EmailBackend") class SendGridBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase): """Test ESP backend without required settings in place""" def test_missing_auth(self): - with self.assertRaisesRegex(AnymailConfigurationError, r'\bSENDGRID_API_KEY\b'): - mail.send_mail('Subject', 'Message', 'from@example.com', ['to@example.com']) + with self.assertRaisesRegex(AnymailConfigurationError, r"\bSENDGRID_API_KEY\b"): + mail.send_mail("Subject", "Message", "from@example.com", ["to@example.com"]) -@tag('sendgrid') +@tag("sendgrid") @override_settings(EMAIL_BACKEND="anymail.backends.sendgrid.EmailBackend") class SendGridBackendDisallowsV2Tests(AnymailTestMixin, SimpleTestCase): """Using v2-API-only features should cause errors with v3 backend""" - @override_settings(ANYMAIL={'SENDGRID_USERNAME': 'sg_username', 'SENDGRID_PASSWORD': 'sg_password'}) + @override_settings( + ANYMAIL={"SENDGRID_USERNAME": "sg_username", "SENDGRID_PASSWORD": "sg_password"} + ) def test_user_pass_auth(self): """Make sure v2-only USERNAME/PASSWORD auth raises error""" with self.assertRaisesMessage( AnymailConfigurationError, - "SendGrid v3 API doesn't support username/password auth; Please change to API key." + "SendGrid v3 API doesn't support username/password auth;" + " Please change to API key.", ): - mail.send_mail('Subject', 'Message', 'from@example.com', ['to@example.com']) + mail.send_mail("Subject", "Message", "from@example.com", ["to@example.com"]) - @override_settings(ANYMAIL={'SENDGRID_API_KEY': 'test_api_key'}) + @override_settings(ANYMAIL={"SENDGRID_API_KEY": "test_api_key"}) def test_esp_extra_smtpapi(self): """x-smtpapi in the esp_extra indicates a desire to use the v2 api""" - message = mail.EmailMessage('Subject', 'Body', 'from@example.com', ['to@example.com']) - message.esp_extra = {'x-smtpapi': {'asm_group_id': 1}} + message = mail.EmailMessage( + "Subject", "Body", "from@example.com", ["to@example.com"] + ) + message.esp_extra = {"x-smtpapi": {"asm_group_id": 1}} with self.assertRaisesMessage( AnymailConfigurationError, - "You are attempting to use SendGrid v2 API-style x-smtpapi params with the SendGrid v3 API." - " Please update your `esp_extra` to the new API." + "You are attempting to use SendGrid v2 API-style x-smtpapi params with the" + " SendGrid v3 API. Please update your `esp_extra` to the new API.", ): message.send() diff --git a/tests/test_sendgrid_inbound.py b/tests/test_sendgrid_inbound.py index 5389959..8181d58 100644 --- a/tests/test_sendgrid_inbound.py +++ b/tests/test_sendgrid_inbound.py @@ -9,15 +9,22 @@ from anymail.inbound import AnymailInboundMessage from anymail.signals import AnymailInboundEvent from anymail.webhooks.sendgrid import SendGridInboundWebhookView -from .utils import dedent_bytes, sample_image_content, sample_email_content, encode_multipart, make_fileobj +from .utils import ( + dedent_bytes, + encode_multipart, + make_fileobj, + sample_email_content, + sample_image_content, +) from .webhook_cases import WebhookTestCase -@tag('sendgrid') +@tag("sendgrid") class SendgridInboundTestCase(WebhookTestCase): def test_inbound_basics(self): raw_event = { - 'headers': dedent("""\ + "headers": dedent( + """\ Received: from mail.example.org by mx987654321.sendgrid.net ... Received: by mail.example.org for ... DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.org; ... @@ -30,109 +37,144 @@ class SendgridInboundTestCase(WebhookTestCase): To: "Test Inbound" , other@example.com Cc: cc@example.com Content-Type: multipart/mixed; boundary="94eb2c115edcf35387055b61f849" - """), - 'from': 'Displayed From ', - 'to': 'Test Inbound , other@example.com', - 'subject': "Test subject", - 'text': "Test body plain", - 'html': "
Test body html
", - 'attachments': "0", - 'charsets': '{"to":"UTF-8","html":"UTF-8","subject":"UTF-8","from":"UTF-8","text":"UTF-8"}', - 'envelope': '{"to":["test@inbound.example.com"],"from":"envelope-from@example.org"}', - 'sender_ip': "10.10.1.71", - 'dkim': "{@example.org : pass}", # yep, SendGrid uses not-exactly-json for this field - 'SPF': "pass", - 'spam_score': "1.7", - 'spam_report': 'Spam detection software, running on the system "mx987654321.sendgrid.net", ' - 'has identified this incoming email as possible spam...', + """ + ), + "from": "Displayed From ", + "to": "Test Inbound , other@example.com", + "subject": "Test subject", + "text": "Test body plain", + "html": "
Test body html
", + "attachments": "0", + "charsets": '{"to":"UTF-8","html":"UTF-8",' + '"subject":"UTF-8","from":"UTF-8","text":"UTF-8"}', + "envelope": '{"to":["test@inbound.example.com"],' + '"from":"envelope-from@example.org"}', + "sender_ip": "10.10.1.71", + # yep, SendGrid uses not-exactly-json for this field: + "dkim": "{@example.org : pass}", + "SPF": "pass", + "spam_score": "1.7", + "spam_report": "Spam detection software, running on the system" + ' "mx987654321.sendgrid.net", ' + "has identified this incoming email as possible spam...", } - response = self.client.post('/anymail/sendgrid/inbound/', data=raw_event) + response = self.client.post("/anymail/sendgrid/inbound/", data=raw_event) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.inbound_handler, sender=SendGridInboundWebhookView, - event=ANY, esp_name='SendGrid') + kwargs = self.assert_handler_called_once_with( + self.inbound_handler, + sender=SendGridInboundWebhookView, + event=ANY, + esp_name="SendGrid", + ) # AnymailInboundEvent - event = kwargs['event'] + event = kwargs["event"] self.assertIsInstance(event, AnymailInboundEvent) - self.assertEqual(event.event_type, 'inbound') + self.assertEqual(event.event_type, "inbound") self.assertIsNone(event.timestamp) self.assertIsNone(event.event_id) self.assertIsInstance(event.message, AnymailInboundMessage) - self.assertEqual(event.esp_event.POST.dict(), raw_event) # esp_event is a Django HttpRequest + # esp_event is a Django HttpRequest: + self.assertEqual(event.esp_event.POST.dict(), raw_event) # AnymailInboundMessage - convenience properties message = event.message - self.assertEqual(message.from_email.display_name, 'Displayed From') - self.assertEqual(message.from_email.addr_spec, 'from+test@example.org') - self.assertEqual([str(e) for e in message.to], - ['Test Inbound ', 'other@example.com']) - self.assertEqual([str(e) for e in message.cc], - ['cc@example.com']) - self.assertEqual(message.subject, 'Test subject') + self.assertEqual(message.from_email.display_name, "Displayed From") + self.assertEqual(message.from_email.addr_spec, "from+test@example.org") + self.assertEqual( + [str(e) for e in message.to], + ["Test Inbound ", "other@example.com"], + ) + self.assertEqual([str(e) for e in message.cc], ["cc@example.com"]) + self.assertEqual(message.subject, "Test subject") self.assertEqual(message.date.isoformat(" "), "2017-10-11 18:31:04-07:00") - self.assertEqual(message.text, 'Test body plain') - self.assertEqual(message.html, '
Test body html
') + self.assertEqual(message.text, "Test body plain") + self.assertEqual(message.html, "
Test body html
") - self.assertEqual(message.envelope_sender, 'envelope-from@example.org') - self.assertEqual(message.envelope_recipient, 'test@inbound.example.com') + self.assertEqual(message.envelope_sender, "envelope-from@example.org") + self.assertEqual(message.envelope_recipient, "test@inbound.example.com") self.assertIsNone(message.stripped_text) self.assertIsNone(message.stripped_html) - self.assertIsNone(message.spam_detected) # SendGrid doesn't give a simple yes/no; check the score yourself + # SendGrid doesn't give a simple spam yes/no; check the score yourself: + self.assertIsNone(message.spam_detected) self.assertEqual(message.spam_score, 1.7) # AnymailInboundMessage - other headers - self.assertEqual(message['Message-ID'], "") - self.assertEqual(message.get_all('Received'), [ - "from mail.example.org by mx987654321.sendgrid.net ...", - "by mail.example.org for ...", - "by 10.10.1.71 with HTTP; Wed, 11 Oct 2017 18:31:04 -0700 (PDT)", - ]) + self.assertEqual(message["Message-ID"], "") + self.assertEqual( + message.get_all("Received"), + [ + "from mail.example.org by mx987654321.sendgrid.net ...", + "by mail.example.org for ...", + "by 10.10.1.71 with HTTP; Wed, 11 Oct 2017 18:31:04 -0700 (PDT)", + ], + ) def test_attachments(self): - att1 = BytesIO('test attachment'.encode('utf-8')) - att1.name = 'test.txt' + att1 = BytesIO("test attachment".encode("utf-8")) + att1.name = "test.txt" image_content = sample_image_content() att2 = BytesIO(image_content) - att2.name = 'image.png' + att2.name = "image.png" email_content = sample_email_content() att3 = BytesIO(email_content) - att3.name = '\\share\\mail\\forwarded.msg' + att3.name = "\\share\\mail\\forwarded.msg" att3.content_type = 'message/rfc822; charset="us-ascii"' raw_event = { - 'headers': '', - 'attachments': '3', - 'attachment-info': json.dumps({ - "attachment3": {"filename": "\\share\\mail\\forwarded.msg", - "charset": "US-ASCII", "type": "message/rfc822"}, - "attachment2": {"filename": "image.png", "type": "image/png", "content-id": "abc123"}, - "attachment1": {"filename": "test.txt", "charset": "UTF-8", "type": "text/plain"}, - }), - 'content-ids': '{"abc123": "attachment2"}', - 'attachment1': att1, - 'attachment2': att2, # inline - 'attachment3': att3, + "headers": "", + "attachments": "3", + "attachment-info": json.dumps( + { + "attachment3": { + "filename": "\\share\\mail\\forwarded.msg", + "charset": "US-ASCII", + "type": "message/rfc822", + }, + "attachment2": { + "filename": "image.png", + "type": "image/png", + "content-id": "abc123", + }, + "attachment1": { + "filename": "test.txt", + "charset": "UTF-8", + "type": "text/plain", + }, + } + ), + "content-ids": '{"abc123": "attachment2"}', + "attachment1": att1, + "attachment2": att2, # inline + "attachment3": att3, } - response = self.client.post('/anymail/sendgrid/inbound/', data=raw_event) + response = self.client.post("/anymail/sendgrid/inbound/", data=raw_event) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.inbound_handler, sender=SendGridInboundWebhookView, - event=ANY, esp_name='SendGrid') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.inbound_handler, + sender=SendGridInboundWebhookView, + event=ANY, + esp_name="SendGrid", + ) + event = kwargs["event"] message = event.message attachments = message.attachments # AnymailInboundMessage convenience accessor self.assertEqual(len(attachments), 2) - self.assertEqual(attachments[0].get_filename(), 'test.txt') - self.assertEqual(attachments[0].get_content_type(), 'text/plain') - self.assertEqual(attachments[0].get_content_text(), 'test attachment') - self.assertEqual(attachments[1].get_filename(), 'forwarded.msg') # Django strips path - self.assertEqual(attachments[1].get_content_type(), 'message/rfc822') - self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content) + self.assertEqual(attachments[0].get_filename(), "test.txt") + self.assertEqual(attachments[0].get_content_type(), "text/plain") + self.assertEqual(attachments[0].get_content_text(), "test attachment") + # Django strips path: + self.assertEqual(attachments[1].get_filename(), "forwarded.msg") + self.assertEqual(attachments[1].get_content_type(), "message/rfc822") + self.assertEqualIgnoringHeaderFolding( + attachments[1].get_content_bytes(), email_content + ) inlines = message.inline_attachments self.assertEqual(len(inlines), 1) - inline = inlines['abc123'] - self.assertEqual(inline.get_filename(), 'image.png') - self.assertEqual(inline.get_content_type(), 'image/png') + inline = inlines["abc123"] + self.assertEqual(inline.get_filename(), "image.png") + self.assertEqual(inline.get_content_type(), "image/png") self.assertEqual(inline.get_content_bytes(), image_content) def test_filtered_attachment_filenames(self): @@ -140,44 +182,61 @@ class SendgridInboundTestCase(WebhookTestCase): # Django's multipart/form-data filename filtering. (The attachments are lost, # but shouldn't cause errors in the inbound webhook.) filenames = [ - "", "path\\", "path/" - ".", "path\\.", "path/.", - "..", "path\\..", "path/..", + "", + "path\\", + "path/" ".", + "path\\.", + "path/.", + "..", + "path\\..", + "path/..", ] num_attachments = len(filenames) payload = { - "attachment%d" % (i+1): make_fileobj("content", filename=filenames[i], content_type="text/pdf") + "attachment%d" + % (i + 1): make_fileobj( + "content", filename=filenames[i], content_type="text/pdf" + ) for i in range(num_attachments) } attachment_info = { key: {"filename": value.name, "type": "text/pdf"} for key, value in payload.items() } - payload.update({ - 'headers': '', - 'attachments': str(num_attachments), - 'attachment-info': json.dumps(attachment_info), - }) + payload.update( + { + "headers": "", + "attachments": str(num_attachments), + "attachment-info": json.dumps(attachment_info), + } + ) - # Must do our own form-data encoding to properly test empty attachment filenames. - # Must do our own multipart/form-data encoding for empty filenames: - response = self.client.post('/anymail/sendgrid/inbound/', - data=encode_multipart("BoUnDaRy", payload), - content_type="multipart/form-data; boundary=BoUnDaRy") + # Must do our own form-data encoding to properly test empty attachment + # filenames. Must do our own multipart/form-data encoding for empty filenames: + response = self.client.post( + "/anymail/sendgrid/inbound/", + data=encode_multipart("BoUnDaRy", payload), + content_type="multipart/form-data; boundary=BoUnDaRy", + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.inbound_handler, sender=SendGridInboundWebhookView, - event=ANY, esp_name='SendGrid') + kwargs = self.assert_handler_called_once_with( + self.inbound_handler, + sender=SendGridInboundWebhookView, + event=ANY, + esp_name="SendGrid", + ) # Different Django releases strip different filename patterns. # Just verify that at least some attachments got dropped (so the test is valid) # without causing an error in the inbound webhook: - attachments = kwargs['event'].message.attachments + attachments = kwargs["event"].message.attachments self.assertLess(len(attachments), num_attachments) def test_inbound_mime(self): # SendGrid has an option to send the full, raw MIME message raw_event = { - 'email': dedent("""\ + "email": dedent( + """\ From: A tester Date: Thu, 12 Oct 2017 18:03:30 -0700 Message-ID: @@ -199,36 +258,48 @@ class SendgridInboundTestCase(WebhookTestCase):
It's a body=E2=80=A6
--94eb2c05e174adb140055b6339c5-- - """), - 'from': 'A tester ', - 'to': 'test@inbound.example.com', - 'subject': "Raw MIME test", - 'charsets': '{"to":"UTF-8","subject":"UTF-8","from":"UTF-8"}', - 'envelope': '{"to":["test@inbound.example.com"],"from":"envelope-from@example.org"}', - 'sender_ip': "10.10.1.71", - 'dkim': "{@example.org : pass}", # yep, SendGrid uses not-exactly-json for this field - 'SPF': "pass", - 'spam_score': "1.7", - 'spam_report': 'Spam detection software, running on the system "mx987654321.sendgrid.net", ' - 'has identified this incoming email as possible spam...', + """ # NOQA: E501 + ), + "from": "A tester ", + "to": "test@inbound.example.com", + "subject": "Raw MIME test", + "charsets": '{"to":"UTF-8","subject":"UTF-8","from":"UTF-8"}', + "envelope": '{"to":["test@inbound.example.com"],' + '"from":"envelope-from@example.org"}', + "sender_ip": "10.10.1.71", + # yep, SendGrid uses not-exactly-json for this field: + "dkim": "{@example.org : pass}", + "SPF": "pass", + "spam_score": "1.7", + "spam_report": "Spam detection software, running on the system" + ' "mx987654321.sendgrid.net", ' + "has identified this incoming email as possible spam...", } - response = self.client.post('/anymail/sendgrid/inbound/', data=raw_event) + response = self.client.post("/anymail/sendgrid/inbound/", data=raw_event) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.inbound_handler, sender=SendGridInboundWebhookView, - event=ANY, esp_name='SendGrid') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.inbound_handler, + sender=SendGridInboundWebhookView, + event=ANY, + esp_name="SendGrid", + ) + event = kwargs["event"] message = event.message - self.assertEqual(message.envelope_sender, 'envelope-from@example.org') - self.assertEqual(message.envelope_recipient, 'test@inbound.example.com') - self.assertEqual(message.subject, 'Raw MIME test') + self.assertEqual(message.envelope_sender, "envelope-from@example.org") + self.assertEqual(message.envelope_recipient, "test@inbound.example.com") + self.assertEqual(message.subject, "Raw MIME test") self.assertEqual(message.text, "It's a body\N{HORIZONTAL ELLIPSIS}\n") - self.assertEqual(message.html, """
It's a body\N{HORIZONTAL ELLIPSIS}
\n""") + self.assertEqual( + message.html, + """
It's a body\N{HORIZONTAL ELLIPSIS}
\n""", + ) def test_inbound_charsets(self): # Captured (sanitized) from actual SendGrid inbound webhook payload 7/2020, # using a test message constructed with a variety of charsets: - raw_post = dedent_bytes(b"""\ + raw_post = dedent_bytes( + b"""\ --xYzZY Content-Disposition: form-data; name="headers" @@ -262,14 +333,22 @@ class SendgridInboundTestCase(WebhookTestCase): {"to":"UTF-8","cc":"UTF-8","html":"iso-8859-1","subject":"cp850","from":"UTF-8","text":"windows-1252"} --xYzZY-- - """).replace(b"\n", b"\r\n") + """ + ).replace(b"\n", b"\r\n") - response = self.client.post('/anymail/sendgrid/inbound/', data=raw_post, - content_type="multipart/form-data; boundary=xYzZY") + response = self.client.post( + "/anymail/sendgrid/inbound/", + data=raw_post, + content_type="multipart/form-data; boundary=xYzZY", + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.inbound_handler, sender=SendGridInboundWebhookView, - event=ANY, esp_name='SendGrid') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.inbound_handler, + sender=SendGridInboundWebhookView, + event=ANY, + esp_name="SendGrid", + ) + event = kwargs["event"] message = event.message self.assertEqual(message.from_email.display_name, "Opérateur de test") diff --git a/tests/test_sendgrid_integration.py b/tests/test_sendgrid_integration.py index bc499d3..4ef4d53 100644 --- a/tests/test_sendgrid_integration.py +++ b/tests/test_sendgrid_integration.py @@ -10,40 +10,53 @@ from anymail.message import AnymailMessage from .utils import AnymailTestMixin, sample_image_path -ANYMAIL_TEST_SENDGRID_API_KEY = os.getenv('ANYMAIL_TEST_SENDGRID_API_KEY') -ANYMAIL_TEST_SENDGRID_TEMPLATE_ID = os.getenv('ANYMAIL_TEST_SENDGRID_TEMPLATE_ID') -ANYMAIL_TEST_SENDGRID_DOMAIN = os.getenv('ANYMAIL_TEST_SENDGRID_DOMAIN') +ANYMAIL_TEST_SENDGRID_API_KEY = os.getenv("ANYMAIL_TEST_SENDGRID_API_KEY") +ANYMAIL_TEST_SENDGRID_TEMPLATE_ID = os.getenv("ANYMAIL_TEST_SENDGRID_TEMPLATE_ID") +ANYMAIL_TEST_SENDGRID_DOMAIN = os.getenv("ANYMAIL_TEST_SENDGRID_DOMAIN") -@tag('sendgrid', 'live') -@unittest.skipUnless(ANYMAIL_TEST_SENDGRID_API_KEY and ANYMAIL_TEST_SENDGRID_DOMAIN, - "Set ANYMAIL_TEST_SENDGRID_API_KEY and ANYMAIL_TEST_SENDGRID_DOMAIN " - "environment variables to run SendGrid integration tests") -@override_settings(ANYMAIL_SENDGRID_API_KEY=ANYMAIL_TEST_SENDGRID_API_KEY, - ANYMAIL_SENDGRID_SEND_DEFAULTS={"esp_extra": { - "mail_settings": {"sandbox_mode": {"enable": True}}, - }}, - EMAIL_BACKEND="anymail.backends.sendgrid.EmailBackend") +@tag("sendgrid", "live") +@unittest.skipUnless( + ANYMAIL_TEST_SENDGRID_API_KEY and ANYMAIL_TEST_SENDGRID_DOMAIN, + "Set ANYMAIL_TEST_SENDGRID_API_KEY and ANYMAIL_TEST_SENDGRID_DOMAIN " + "environment variables to run SendGrid integration tests", +) +@override_settings( + ANYMAIL_SENDGRID_API_KEY=ANYMAIL_TEST_SENDGRID_API_KEY, + ANYMAIL_SENDGRID_SEND_DEFAULTS={ + "esp_extra": { + "mail_settings": {"sandbox_mode": {"enable": True}}, + } + }, + EMAIL_BACKEND="anymail.backends.sendgrid.EmailBackend", +) class SendGridBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): - """SendGrid v3 API integration tests + """ + SendGrid v3 API integration tests These tests run against the **live** SendGrid API, using the environment variable `ANYMAIL_TEST_SENDGRID_API_KEY` as the API key If those variables are not set, these tests won't run. The SEND_DEFAULTS above force SendGrid's v3 sandbox mode, which avoids sending mail. - (Sandbox sends also don't show in the activity feed, so disable that for live debugging.) + (Sandbox sends also don't show in the activity feed, so disable that for live + debugging.) - The tests also use SendGrid's "sink domain" @sink.sendgrid.net for recipient addresses. + The tests also use SendGrid's "sink domain" @sink.sendgrid.net for recipient + addresses. https://support.sendgrid.com/hc/en-us/articles/201995663-Safely-Test-Your-Sending-Speed """ def setUp(self): super().setUp() - self.from_email = 'from@%s' % ANYMAIL_TEST_SENDGRID_DOMAIN - self.message = AnymailMessage('Anymail SendGrid integration test', 'Text content', - self.from_email, ['to@sink.sendgrid.net']) - self.message.attach_alternative('

HTML content

', "text/html") + self.from_email = "from@%s" % ANYMAIL_TEST_SENDGRID_DOMAIN + self.message = AnymailMessage( + "Anymail SendGrid integration test", + "Text content", + self.from_email, + ["to@sink.sendgrid.net"], + ) + self.message.attach_alternative("

HTML content

", "text/html") def test_simple_send(self): # Example of getting the SendGrid send status and message id from the message @@ -51,12 +64,13 @@ class SendGridBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): self.assertEqual(sent_count, 1) anymail_status = self.message.anymail_status - sent_status = anymail_status.recipients['to@sink.sendgrid.net'].status - message_id = anymail_status.recipients['to@sink.sendgrid.net'].message_id + sent_status = anymail_status.recipients["to@sink.sendgrid.net"].status + message_id = anymail_status.recipients["to@sink.sendgrid.net"].message_id - self.assertEqual(sent_status, 'queued') # SendGrid always queues + self.assertEqual(sent_status, "queued") # SendGrid always queues self.assertUUIDIsValid(message_id) # Anymail generates a UUID tracking id - self.assertEqual(anymail_status.status, {sent_status}) # set of all recipient statuses + # set of all recipient statuses: + self.assertEqual(anymail_status.status, {sent_status}) self.assertEqual(anymail_status.message_id, message_id) def test_all_options(self): @@ -68,15 +82,16 @@ class SendGridBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): to=["to1@sink.sendgrid.net", '"Recipient 2, OK?" '], cc=["cc1@sink.sendgrid.net", "Copy 2 "], bcc=["bcc1@sink.sendgrid.net", "Blind Copy 2 "], - reply_to=['"Reply, with comma" '], # v3 only supports single reply-to + # v3 only supports single reply-to: + reply_to=['"Reply, with comma" '], headers={"X-Anymail-Test": "value", "X-Anymail-Count": 3}, - metadata={"meta1": "simple string", "meta2": 2}, send_at=send_at, tags=["tag 1", "tag 2"], track_clicks=True, track_opens=True, - # esp_extra={'asm': {'group_id': 1}}, # this breaks activity feed if you don't have an asm group + # this breaks activity feed if you don't have an asm group: + # esp_extra={'asm': {'group_id': 1}}, ) message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain") message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv") @@ -84,10 +99,12 @@ class SendGridBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): message.attach_alternative( "

HTML: with link" "and image: " % cid, - "text/html") + "text/html", + ) message.send() - self.assertEqual(message.anymail_status.status, {'queued'}) # SendGrid always queues + # SendGrid always queues: + self.assertEqual(message.anymail_status.status, {"queued"}) def test_merge_data(self): message = AnymailMessage( @@ -97,37 +114,40 @@ class SendGridBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): to=["to1@sink.sendgrid.net", "Recipient 2 "], reply_to=['"Merge data in reply name: %field%" '], merge_data={ - 'to1@sink.sendgrid.net': {'field': 'one'}, - 'to2@sink.sendgrid.net': {'field': 'two'}, + "to1@sink.sendgrid.net": {"field": "one"}, + "to2@sink.sendgrid.net": {"field": "two"}, }, esp_extra={ - 'merge_field_format': '%{}%', + "merge_field_format": "%{}%", }, ) message.send() recipient_status = message.anymail_status.recipients - self.assertEqual(recipient_status['to1@sink.sendgrid.net'].status, 'queued') - self.assertEqual(recipient_status['to2@sink.sendgrid.net'].status, 'queued') + self.assertEqual(recipient_status["to1@sink.sendgrid.net"].status, "queued") + self.assertEqual(recipient_status["to2@sink.sendgrid.net"].status, "queued") - @unittest.skipUnless(ANYMAIL_TEST_SENDGRID_TEMPLATE_ID, - "Set the ANYMAIL_TEST_SENDGRID_TEMPLATE_ID environment variable " - "to a template in your SendGrid account to test stored templates") + @unittest.skipUnless( + ANYMAIL_TEST_SENDGRID_TEMPLATE_ID, + "Set the ANYMAIL_TEST_SENDGRID_TEMPLATE_ID environment variable " + "to a template in your SendGrid account to test stored templates", + ) def test_stored_template(self): message = AnymailMessage( from_email=formataddr(("Test From", self.from_email)), to=["to@sink.sendgrid.net"], - # Anymail's live test template has merge fields "name", "order_no", and "dept"... + # Anymail's live test template has + # merge fields "name", "order_no", and "dept"... template_id=ANYMAIL_TEST_SENDGRID_TEMPLATE_ID, merge_data={ - 'to@sink.sendgrid.net': { - 'name': "Test Recipient", - 'order_no': "12345", + "to@sink.sendgrid.net": { + "name": "Test Recipient", + "order_no": "12345", }, }, - merge_global_data={'dept': "Fulfillment"}, + merge_global_data={"dept": "Fulfillment"}, ) message.send() - self.assertEqual(message.anymail_status.status, {'queued'}) + self.assertEqual(message.anymail_status.status, {"queued"}) @override_settings(ANYMAIL_SENDGRID_API_KEY="Hey, that's not an API key!") def test_invalid_api_key(self): diff --git a/tests/test_sendgrid_webhooks.py b/tests/test_sendgrid_webhooks.py index f710fb6..6b939d8 100644 --- a/tests/test_sendgrid_webhooks.py +++ b/tests/test_sendgrid_webhooks.py @@ -6,43 +6,58 @@ from django.test import tag from anymail.signals import AnymailTrackingEvent from anymail.webhooks.sendgrid import SendGridTrackingWebhookView + from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase -@tag('sendgrid') +@tag("sendgrid") class SendGridWebhookSecurityTestCase(WebhookBasicAuthTestCase): def call_webhook(self): - return self.client.post('/anymail/sendgrid/tracking/', - content_type='application/json', data=json.dumps([])) + return self.client.post( + "/anymail/sendgrid/tracking/", + content_type="application/json", + data=json.dumps([]), + ) # Actual tests are in WebhookBasicAuthTestCase -@tag('sendgrid') +@tag("sendgrid") class SendGridDeliveryTestCase(WebhookTestCase): - def test_processed_event(self): - raw_events = [{ - "email": "recipient@example.com", - "timestamp": 1461095246, - "anymail_id": "3c2f4df8-c6dd-4cd2-9b91-6582b81a0349", - "smtp-id": "", - "sg_event_id": "ZyjAM5rnQmuI1KFInHQ3Nw", - "sg_message_id": "wrfRRvF7Q0GgwUo2CvDmEA.filter0425p1mdw1.13037.57168B4A1D.0", - "event": "processed", - "category": ["tag1", "tag2"], - "custom1": "value1", - "custom2": "value2", - }] - response = self.client.post('/anymail/sendgrid/tracking/', - content_type='application/json', data=json.dumps(raw_events)) + raw_events = [ + { + "email": "recipient@example.com", + "timestamp": 1461095246, + "anymail_id": "3c2f4df8-c6dd-4cd2-9b91-6582b81a0349", + "smtp-id": "", + "sg_event_id": "ZyjAM5rnQmuI1KFInHQ3Nw", + "sg_message_id": "wrfRRvF7Q0GgwUo2CvDmEA" + ".filter0425p1mdw1.13037.57168B4A1D.0", + "event": "processed", + "category": ["tag1", "tag2"], + "custom1": "value1", + "custom2": "value2", + } + ] + response = self.client.post( + "/anymail/sendgrid/tracking/", + content_type="application/json", + data=json.dumps(raw_events), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendGridTrackingWebhookView, - event=ANY, esp_name='SendGrid') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SendGridTrackingWebhookView, + event=ANY, + esp_name="SendGrid", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "queued") - self.assertEqual(event.timestamp, datetime(2016, 4, 19, 19, 47, 26, tzinfo=timezone.utc)) + self.assertEqual( + event.timestamp, datetime(2016, 4, 19, 19, 47, 26, tzinfo=timezone.utc) + ) self.assertEqual(event.esp_event, raw_events[0]) self.assertEqual(event.message_id, "3c2f4df8-c6dd-4cd2-9b91-6582b81a0349") self.assertEqual(event.event_id, "ZyjAM5rnQmuI1KFInHQ3Nw") @@ -51,52 +66,75 @@ class SendGridDeliveryTestCase(WebhookTestCase): self.assertEqual(event.metadata, {"custom1": "value1", "custom2": "value2"}) def test_delivered_event(self): - raw_events = [{ - "ip": "167.89.17.173", - "response": "250 2.0.0 OK 1461095248 m143si2210036ioe.159 - gsmtp ", - "smtp-id": "", - "sg_event_id": "nOSv8m0eTQ-vxvwNwt3fZQ", - "sg_message_id": "wrfRRvF7Q0GgwUo2CvDmEA.filter0425p1mdw1.13037.57168B4A1D.0", - "tls": 1, - "event": "delivered", - "email": "recipient@example.com", - "timestamp": 1461095250, - "anymail_id": "4ab185c2-0171-492f-9ce0-27de258efc99" - }] - response = self.client.post('/anymail/sendgrid/tracking/', - content_type='application/json', data=json.dumps(raw_events)) + raw_events = [ + { + "ip": "167.89.17.173", + "response": "250 2.0.0 OK 1461095248 m143si2210036ioe.159 - gsmtp ", + "smtp-id": "", + "sg_event_id": "nOSv8m0eTQ-vxvwNwt3fZQ", + "sg_message_id": "wrfRRvF7Q0GgwUo2CvDmEA" + ".filter0425p1mdw1.13037.57168B4A1D.0", + "tls": 1, + "event": "delivered", + "email": "recipient@example.com", + "timestamp": 1461095250, + "anymail_id": "4ab185c2-0171-492f-9ce0-27de258efc99", + } + ] + response = self.client.post( + "/anymail/sendgrid/tracking/", + content_type="application/json", + data=json.dumps(raw_events), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendGridTrackingWebhookView, - event=ANY, esp_name='SendGrid') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SendGridTrackingWebhookView, + event=ANY, + esp_name="SendGrid", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "delivered") - self.assertEqual(event.timestamp, datetime(2016, 4, 19, 19, 47, 30, tzinfo=timezone.utc)) + self.assertEqual( + event.timestamp, datetime(2016, 4, 19, 19, 47, 30, tzinfo=timezone.utc) + ) self.assertEqual(event.esp_event, raw_events[0]) self.assertEqual(event.message_id, "4ab185c2-0171-492f-9ce0-27de258efc99") self.assertEqual(event.event_id, "nOSv8m0eTQ-vxvwNwt3fZQ") self.assertEqual(event.recipient, "recipient@example.com") - self.assertEqual(event.mta_response, "250 2.0.0 OK 1461095248 m143si2210036ioe.159 - gsmtp ") + self.assertEqual( + event.mta_response, "250 2.0.0 OK 1461095248 m143si2210036ioe.159 - gsmtp " + ) self.assertEqual(event.tags, []) self.assertEqual(event.metadata, {}) def test_dropped_invalid_event(self): - raw_events = [{ - "email": "invalid@invalid", - "anymail_id": "c74002d9-7ccb-4f67-8b8c-766cec03c9a6", - "timestamp": 1461095250, - "smtp-id": "", - "sg_event_id": "3NPOePGOTkeM_U3fgWApfg", - "sg_message_id": "filter0093p1las1.9128.5717FB8127.0", - "reason": "Invalid", - "event": "dropped" - }] - response = self.client.post('/anymail/sendgrid/tracking/', - content_type='application/json', data=json.dumps(raw_events)) + raw_events = [ + { + "email": "invalid@invalid", + "anymail_id": "c74002d9-7ccb-4f67-8b8c-766cec03c9a6", + "timestamp": 1461095250, + "smtp-id": "", + "sg_event_id": "3NPOePGOTkeM_U3fgWApfg", + "sg_message_id": "filter0093p1las1.9128.5717FB8127.0", + "reason": "Invalid", + "event": "dropped", + } + ] + response = self.client.post( + "/anymail/sendgrid/tracking/", + content_type="application/json", + data=json.dumps(raw_events), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendGridTrackingWebhookView, - event=ANY, esp_name='SendGrid') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SendGridTrackingWebhookView, + event=ANY, + esp_name="SendGrid", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "rejected") self.assertEqual(event.esp_event, raw_events[0]) @@ -107,22 +145,31 @@ class SendGridDeliveryTestCase(WebhookTestCase): self.assertEqual(event.mta_response, None) def test_dropped_unsubscribed_event(self): - raw_events = [{ - "email": "unsubscribe@example.com", - "anymail_id": "a36ec0f9-aabe-45c7-9a84-3e17afb5cb65", - "timestamp": 1461095250, - "smtp-id": "", - "sg_event_id": "oxy9OLwMTAy5EsuZn1qhIg", - "sg_message_id": "filter0199p1las1.4745.5717FB6F5.0", - "reason": "Unsubscribed Address", - "event": "dropped" - }] - response = self.client.post('/anymail/sendgrid/tracking/', - content_type='application/json', data=json.dumps(raw_events)) + raw_events = [ + { + "email": "unsubscribe@example.com", + "anymail_id": "a36ec0f9-aabe-45c7-9a84-3e17afb5cb65", + "timestamp": 1461095250, + "smtp-id": "", + "sg_event_id": "oxy9OLwMTAy5EsuZn1qhIg", + "sg_message_id": "filter0199p1las1.4745.5717FB6F5.0", + "reason": "Unsubscribed Address", + "event": "dropped", + } + ] + response = self.client.post( + "/anymail/sendgrid/tracking/", + content_type="application/json", + data=json.dumps(raw_events), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendGridTrackingWebhookView, - event=ANY, esp_name='SendGrid') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SendGridTrackingWebhookView, + event=ANY, + esp_name="SendGrid", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "rejected") self.assertEqual(event.esp_event, raw_events[0]) @@ -133,112 +180,170 @@ class SendGridDeliveryTestCase(WebhookTestCase): self.assertEqual(event.mta_response, None) def test_bounce_event(self): - raw_events = [{ - "ip": "167.89.17.173", - "status": "5.1.1", - "smtp-id": "", - "sg_event_id": "lC0Rc-FuQmKbnxCWxX1jRQ", - "reason": "550 5.1.1 The email account that you tried to reach does not exist.", - "sg_message_id": "Lli-03HcQ5-JLybO9fXsJg.filter0077p1las1.21536.5717FC482.0", - "tls": 1, - "event": "bounce", - "email": "noreply@example.com", - "timestamp": 1461095250, - "anymail_id": "de212213-bb66-4302-8f3f-20acdb7a104e", - "type": "bounce" - }] - response = self.client.post('/anymail/sendgrid/tracking/', - content_type='application/json', data=json.dumps(raw_events)) + raw_events = [ + { + "ip": "167.89.17.173", + "status": "5.1.1", + "smtp-id": "", + "sg_event_id": "lC0Rc-FuQmKbnxCWxX1jRQ", + "reason": "550 5.1.1" + " The email account that you tried to reach does not exist.", + "sg_message_id": "Lli-03HcQ5-JLybO9fXsJg" + ".filter0077p1las1.21536.5717FC482.0", + "tls": 1, + "event": "bounce", + "email": "noreply@example.com", + "timestamp": 1461095250, + "anymail_id": "de212213-bb66-4302-8f3f-20acdb7a104e", + "type": "bounce", + } + ] + response = self.client.post( + "/anymail/sendgrid/tracking/", + content_type="application/json", + data=json.dumps(raw_events), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendGridTrackingWebhookView, - event=ANY, esp_name='SendGrid') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SendGridTrackingWebhookView, + event=ANY, + esp_name="SendGrid", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "bounced") self.assertEqual(event.esp_event, raw_events[0]) self.assertEqual(event.message_id, "de212213-bb66-4302-8f3f-20acdb7a104e") self.assertEqual(event.event_id, "lC0Rc-FuQmKbnxCWxX1jRQ") self.assertEqual(event.recipient, "noreply@example.com") - self.assertEqual(event.mta_response, "550 5.1.1 The email account that you tried to reach does not exist.") + self.assertEqual( + event.mta_response, + "550 5.1.1 The email account that you tried to reach does not exist.", + ) def test_deferred_event(self): - raw_events = [{ - "response": "Email was deferred due to the following reason(s): [IPs were throttled by recipient server]", - "smtp-id": "", - "sg_event_id": "b_syL5UiTvWC_Ky5L6Bs5Q", - "sg_message_id": "u9Gvi3mzT6iC2poAb58_qQ.filter0465p1mdw1.8054.5718271B40.0", - "event": "deferred", - "email": "recipient@example.com", - "attempt": "1", - "timestamp": 1461200990, - "anymail_id": "ccf83222-0d7e-4542-8beb-893122afa757", - }] - response = self.client.post('/anymail/sendgrid/tracking/', - content_type='application/json', data=json.dumps(raw_events)) + raw_events = [ + { + "response": "Email was deferred due to the following reason(s):" + " [IPs were throttled by recipient server]", + "smtp-id": "", + "sg_event_id": "b_syL5UiTvWC_Ky5L6Bs5Q", + "sg_message_id": "u9Gvi3mzT6iC2poAb58_qQ.filter0465p1mdw1" + ".8054.5718271B40.0", + "event": "deferred", + "email": "recipient@example.com", + "attempt": "1", + "timestamp": 1461200990, + "anymail_id": "ccf83222-0d7e-4542-8beb-893122afa757", + } + ] + response = self.client.post( + "/anymail/sendgrid/tracking/", + content_type="application/json", + data=json.dumps(raw_events), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendGridTrackingWebhookView, - event=ANY, esp_name='SendGrid') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SendGridTrackingWebhookView, + event=ANY, + esp_name="SendGrid", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "deferred") self.assertEqual(event.esp_event, raw_events[0]) self.assertEqual(event.message_id, "ccf83222-0d7e-4542-8beb-893122afa757") self.assertEqual(event.event_id, "b_syL5UiTvWC_Ky5L6Bs5Q") self.assertEqual(event.recipient, "recipient@example.com") - self.assertEqual(event.mta_response, - "Email was deferred due to the following reason(s): [IPs were throttled by recipient server]") + self.assertEqual( + event.mta_response, + "Email was deferred due to the following reason(s):" + " [IPs were throttled by recipient server]", + ) def test_open_event(self): - raw_events = [{ - "email": "recipient@example.com", - "timestamp": 1461095250, - "ip": "66.102.6.229", - "sg_event_id": "MjIwNDg5NTgtZGE3OC00NDI1LWFiMmMtMDUyZTU2ZmFkOTFm", - "sg_message_id": "wrfRRvF7Q0GgwUo2CvDmEA.filter0425p1mdw1.13037.57168B4A1D.0", - "anymail_id": "44920b35-3e31-478b-bb67-b4f5e0c85ebc", - "useragent": "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0", - "event": "open" - }] - response = self.client.post('/anymail/sendgrid/tracking/', - content_type='application/json', data=json.dumps(raw_events)) + raw_events = [ + { + "email": "recipient@example.com", + "timestamp": 1461095250, + "ip": "66.102.6.229", + "sg_event_id": "MjIwNDg5NTgtZGE3OC00NDI1LWFiMmMtMDUyZTU2ZmFkOTFm", + "sg_message_id": "wrfRRvF7Q0GgwUo2CvDmEA" + ".filter0425p1mdw1.13037.57168B4A1D.0", + "anymail_id": "44920b35-3e31-478b-bb67-b4f5e0c85ebc", + "useragent": "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0", + "event": "open", + } + ] + response = self.client.post( + "/anymail/sendgrid/tracking/", + content_type="application/json", + data=json.dumps(raw_events), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendGridTrackingWebhookView, - event=ANY, esp_name='SendGrid') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SendGridTrackingWebhookView, + event=ANY, + esp_name="SendGrid", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "opened") self.assertEqual(event.esp_event, raw_events[0]) self.assertEqual(event.message_id, "44920b35-3e31-478b-bb67-b4f5e0c85ebc") - self.assertEqual(event.event_id, "MjIwNDg5NTgtZGE3OC00NDI1LWFiMmMtMDUyZTU2ZmFkOTFm") + self.assertEqual( + event.event_id, "MjIwNDg5NTgtZGE3OC00NDI1LWFiMmMtMDUyZTU2ZmFkOTFm" + ) self.assertEqual(event.recipient, "recipient@example.com") - self.assertEqual(event.user_agent, "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0") + self.assertEqual( + event.user_agent, "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0" + ) def test_click_event(self): - raw_events = [{ - "ip": "24.130.34.103", - "sg_event_id": "OTdlOGUzYjctYjc5Zi00OWE4LWE4YWUtNjIxNjk2ZTJlNGVi", - "sg_message_id": "_fjPjuJfRW-IPs5SuvYotg.filter0590p1mdw1.2098.57168CFC4B.0", - "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36", - "anymail_id": "75de5af9-a090-4325-87f9-8c599ad66f60", - "event": "click", - "url_offset": {"index": 0, "type": "html"}, - "email": "recipient@example.com", - "timestamp": 1461095250, - "url": "http://www.example.com" - }] - response = self.client.post('/anymail/sendgrid/tracking/', - content_type='application/json', data=json.dumps(raw_events)) + raw_events = [ + { + "ip": "24.130.34.103", + "sg_event_id": "OTdlOGUzYjctYjc5Zi00OWE4LWE4YWUtNjIxNjk2ZTJlNGVi", + "sg_message_id": "_fjPjuJfRW-IPs5SuvYotg" + ".filter0590p1mdw1.2098.57168CFC4B.0", + "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4)" + " AppleWebKit/537.36", + "anymail_id": "75de5af9-a090-4325-87f9-8c599ad66f60", + "event": "click", + "url_offset": {"index": 0, "type": "html"}, + "email": "recipient@example.com", + "timestamp": 1461095250, + "url": "http://www.example.com", + } + ] + response = self.client.post( + "/anymail/sendgrid/tracking/", + content_type="application/json", + data=json.dumps(raw_events), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendGridTrackingWebhookView, - event=ANY, esp_name='SendGrid') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SendGridTrackingWebhookView, + event=ANY, + esp_name="SendGrid", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "clicked") self.assertEqual(event.esp_event, raw_events[0]) self.assertEqual(event.message_id, "75de5af9-a090-4325-87f9-8c599ad66f60") - self.assertEqual(event.event_id, "OTdlOGUzYjctYjc5Zi00OWE4LWE4YWUtNjIxNjk2ZTJlNGVi") + self.assertEqual( + event.event_id, "OTdlOGUzYjctYjc5Zi00OWE4LWE4YWUtNjIxNjk2ZTJlNGVi" + ) self.assertEqual(event.recipient, "recipient@example.com") - self.assertEqual(event.user_agent, "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36") + self.assertEqual( + event.user_agent, + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36", + ) self.assertEqual(event.click_url, "http://www.example.com") def test_compatibility_message_id_from_smtp_id(self): @@ -246,23 +351,35 @@ class SendGridDeliveryTestCase(WebhookTestCase): # the `message_id`, and relied on SendGrid passing that to webhooks as # 'smtp-id'. Make sure webhooks extract message_id for messages sent # with earlier Anymail versions. (See issue #108.) - raw_events = [{ - "ip": "167.89.17.173", - "response": "250 2.0.0 OK 1461095248 m143si2210036ioe.159 - gsmtp ", - "smtp-id": "<152712433591.85282.8340115595767222398@example.com>", - "sg_event_id": "nOSv8m0eTQ-vxvwNwt3fZQ", - "sg_message_id": "wrfRRvF7Q0GgwUo2CvDmEA.filter0425p1mdw1.13037.57168B4A1D.0", - "tls": 1, - "event": "delivered", - "email": "recipient@example.com", - "timestamp": 1461095250, - }] - response = self.client.post('/anymail/sendgrid/tracking/', - content_type='application/json', data=json.dumps(raw_events)) + raw_events = [ + { + "ip": "167.89.17.173", + "response": "250 2.0.0 OK 1461095248 m143si2210036ioe.159 - gsmtp ", + "smtp-id": "<152712433591.85282.8340115595767222398@example.com>", + "sg_event_id": "nOSv8m0eTQ-vxvwNwt3fZQ", + "sg_message_id": "wrfRRvF7Q0GgwUo2CvDmEA.filter0425p1mdw1" + ".13037.57168B4A1D.0", + "tls": 1, + "event": "delivered", + "email": "recipient@example.com", + "timestamp": 1461095250, + } + ] + response = self.client.post( + "/anymail/sendgrid/tracking/", + content_type="application/json", + data=json.dumps(raw_events), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendGridTrackingWebhookView, - event=ANY, esp_name='SendGrid') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SendGridTrackingWebhookView, + event=ANY, + esp_name="SendGrid", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) - self.assertEqual(event.message_id, "<152712433591.85282.8340115595767222398@example.com>") + self.assertEqual( + event.message_id, "<152712433591.85282.8340115595767222398@example.com>" + ) self.assertEqual(event.metadata, {}) # smtp-id not left in metadata diff --git a/tests/test_sendinblue_backend.py b/tests/test_sendinblue_backend.py index cdcbb32..8f24706 100644 --- a/tests/test_sendinblue_backend.py +++ b/tests/test_sendinblue_backend.py @@ -1,5 +1,5 @@ import json -from base64 import b64encode, b64decode +from base64 import b64decode, b64encode from datetime import date, datetime, timezone from decimal import Decimal from email.mime.base import MIMEBase @@ -7,47 +7,76 @@ from email.mime.image import MIMEImage from django.core import mail from django.test import SimpleTestCase, override_settings, tag -from django.utils.timezone import get_fixed_timezone, override as override_current_timezone +from django.utils.timezone import ( + get_fixed_timezone, + override as override_current_timezone, +) -from anymail.exceptions import (AnymailAPIError, AnymailConfigurationError, AnymailSerializationError, - AnymailUnsupportedFeature) +from anymail.exceptions import ( + AnymailAPIError, + AnymailConfigurationError, + AnymailSerializationError, + AnymailUnsupportedFeature, +) from anymail.message import attach_inline_image_file -from .mock_requests_backend import RequestsBackendMockAPITestCase, SessionSharingTestCases -from .utils import sample_image_content, sample_image_path, SAMPLE_IMAGE_FILENAME, AnymailTestMixin + +from .mock_requests_backend import ( + RequestsBackendMockAPITestCase, + SessionSharingTestCases, +) +from .utils import ( + SAMPLE_IMAGE_FILENAME, + AnymailTestMixin, + sample_image_content, + sample_image_path, +) -@tag('sendinblue') -@override_settings(EMAIL_BACKEND='anymail.backends.sendinblue.EmailBackend', - ANYMAIL={'SENDINBLUE_API_KEY': 'test_api_key'}) +@tag("sendinblue") +@override_settings( + EMAIL_BACKEND="anymail.backends.sendinblue.EmailBackend", + ANYMAIL={"SENDINBLUE_API_KEY": "test_api_key"}, +) class SendinBlueBackendMockAPITestCase(RequestsBackendMockAPITestCase): # SendinBlue v3 success responses are empty - DEFAULT_RAW_RESPONSE = b'{"messageId":"<201801020304.1234567890@smtp-relay.mailin.fr>"}' - DEFAULT_STATUS_CODE = 201 # SendinBlue v3 uses '201 Created' for success (in most cases) + DEFAULT_RAW_RESPONSE = ( + b'{"messageId":"<201801020304.1234567890@smtp-relay.mailin.fr>"}' + ) + DEFAULT_STATUS_CODE = ( + 201 # SendinBlue v3 uses '201 Created' for success (in most cases) + ) def setUp(self): super().setUp() # Simple message useful for many tests - self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', 'from@example.com', ['to@example.com']) + self.message = mail.EmailMultiAlternatives( + "Subject", "Text Body", "from@example.com", ["to@example.com"] + ) -@tag('sendinblue') +@tag("sendinblue") class SendinBlueBackendStandardEmailTests(SendinBlueBackendMockAPITestCase): """Test backend support for Django standard email features""" def test_send_mail(self): """Test basic API for simple send""" - mail.send_mail('Subject here', 'Here is the message.', - 'from@sender.example.com', ['to@example.com'], fail_silently=False) - self.assert_esp_called('https://api.sendinblue.com/v3/smtp/email') + mail.send_mail( + "Subject here", + "Here is the message.", + "from@sender.example.com", + ["to@example.com"], + fail_silently=False, + ) + self.assert_esp_called("https://api.sendinblue.com/v3/smtp/email") http_headers = self.get_api_call_headers() self.assertEqual(http_headers["api-key"], "test_api_key") self.assertEqual(http_headers["Content-Type"], "application/json") data = self.get_api_call_json() - self.assertEqual(data['subject'], "Subject here") - self.assertEqual(data['textContent'], "Here is the message.") - self.assertEqual(data['sender'], {'email': "from@sender.example.com"}) - self.assertEqual(data['to'], [{'email': "to@example.com"}]) + self.assertEqual(data["subject"], "Subject here") + self.assertEqual(data["textContent"], "Here is the message.") + self.assertEqual(data["sender"], {"email": "from@sender.example.com"}) + self.assertEqual(data["to"], [{"email": "to@example.com"}]) def test_name_addr(self): """Make sure RFC2822 name-addr format (with display-name) is allowed @@ -55,77 +84,114 @@ class SendinBlueBackendStandardEmailTests(SendinBlueBackendMockAPITestCase): (Test both sender and recipient addresses) """ msg = mail.EmailMessage( - 'Subject', 'Message', 'From Name ', - ['Recipient #1 ', 'to2@example.com'], - cc=['Carbon Copy ', 'cc2@example.com'], - bcc=['Blind Copy ', 'bcc2@example.com']) + "Subject", + "Message", + "From Name ", + ["Recipient #1 ", "to2@example.com"], + cc=["Carbon Copy ", "cc2@example.com"], + bcc=["Blind Copy ", "bcc2@example.com"], + ) msg.send() data = self.get_api_call_json() - self.assertEqual(data['sender'], {'email': "from@example.com", 'name': "From Name"}) - self.assertEqual(data['to'], [{'email': "to1@example.com", 'name': "Recipient #1"}, - {'email': "to2@example.com"}]) - self.assertEqual(data['cc'], [{'email': "cc1@example.com", 'name': "Carbon Copy"}, - {'email': "cc2@example.com"}]) - self.assertEqual(data['bcc'], [{'email': "bcc1@example.com", 'name': "Blind Copy"}, - {'email': "bcc2@example.com"}]) + self.assertEqual( + data["sender"], {"email": "from@example.com", "name": "From Name"} + ) + self.assertEqual( + data["to"], + [ + {"email": "to1@example.com", "name": "Recipient #1"}, + {"email": "to2@example.com"}, + ], + ) + self.assertEqual( + data["cc"], + [ + {"email": "cc1@example.com", "name": "Carbon Copy"}, + {"email": "cc2@example.com"}, + ], + ) + self.assertEqual( + data["bcc"], + [ + {"email": "bcc1@example.com", "name": "Blind Copy"}, + {"email": "bcc2@example.com"}, + ], + ) def test_email_message(self): email = mail.EmailMessage( - 'Subject', 'Body goes here', 'from@example.com', - ['to1@example.com', 'Also To '], - bcc=['bcc1@example.com', 'Also BCC '], - cc=['cc1@example.com', 'Also CC '], - headers={'Reply-To': 'another@example.com', - 'X-MyHeader': 'my value', - 'Message-ID': ''}) # should override backend msgid + "Subject", + "Body goes here", + "from@example.com", + ["to1@example.com", "Also To "], + bcc=["bcc1@example.com", "Also BCC "], + cc=["cc1@example.com", "Also CC "], + headers={ + "Reply-To": "another@example.com", + "X-MyHeader": "my value", + # should override backend msgid: + "Message-ID": "", + }, + ) email.send() data = self.get_api_call_json() - self.assertEqual(data['sender'], {'email': "from@example.com"}) - self.assertEqual(data['subject'], "Subject") - self.assertEqual(data['textContent'], "Body goes here") - self.assertEqual(data['replyTo'], {'email': "another@example.com"}) - self.assertEqual(data['headers'], { - 'X-MyHeader': "my value", - 'Message-ID': "", - }) + self.assertEqual(data["sender"], {"email": "from@example.com"}) + self.assertEqual(data["subject"], "Subject") + self.assertEqual(data["textContent"], "Body goes here") + self.assertEqual(data["replyTo"], {"email": "another@example.com"}) + self.assertEqual( + data["headers"], + { + "X-MyHeader": "my value", + "Message-ID": "", + }, + ) def test_html_message(self): - text_content = 'This is an important message.' - html_content = '

This is an important message.

' - email = mail.EmailMultiAlternatives('Subject', text_content, - 'from@example.com', ['to@example.com']) + text_content = "This is an important message." + html_content = "

This is an important message.

" + email = mail.EmailMultiAlternatives( + "Subject", text_content, "from@example.com", ["to@example.com"] + ) email.attach_alternative(html_content, "text/html") email.send() data = self.get_api_call_json() - self.assertEqual(data['textContent'], text_content) - self.assertEqual(data['htmlContent'], html_content) + self.assertEqual(data["textContent"], text_content) + self.assertEqual(data["htmlContent"], html_content) # Don't accidentally send the html part as an attachment: - self.assertNotIn('attachments', data) + self.assertNotIn("attachments", data) def test_html_only_message(self): - html_content = '

This is an important message.

' - email = mail.EmailMessage('Subject', html_content, 'from@example.com', ['to@example.com']) + html_content = "

This is an important message.

" + email = mail.EmailMessage( + "Subject", html_content, "from@example.com", ["to@example.com"] + ) email.content_subtype = "html" # Main content is now text/html email.send() data = self.get_api_call_json() - self.assertEqual(data['htmlContent'], html_content) - self.assertNotIn('textContent', data) + self.assertEqual(data["htmlContent"], html_content) + self.assertNotIn("textContent", data) def test_extra_headers(self): - self.message.extra_headers = {'X-Custom': 'string', 'X-Num': 123, - 'Reply-To': '"Do Not Reply" '} + self.message.extra_headers = { + "X-Custom": "string", + "X-Num": 123, + "Reply-To": '"Do Not Reply" ', + } self.message.send() data = self.get_api_call_json() - self.assertEqual(data['headers']['X-Custom'], 'string') + self.assertEqual(data["headers"]["X-Custom"], "string") # Header values must be strings (changed 11/2022) - self.assertEqual(data['headers']['X-Num'], "123") + self.assertEqual(data["headers"]["X-Num"], "123") # Reply-To must be moved to separate param - self.assertNotIn('Reply-To', data['headers']) - self.assertEqual(data['replyTo'], {'name': "Do Not Reply", 'email': "noreply@example.com"}) + self.assertNotIn("Reply-To", data["headers"]) + self.assertEqual( + data["replyTo"], {"name": "Do Not Reply", "email": "noreply@example.com"} + ) def test_extra_headers_serialization_error(self): - self.message.extra_headers = {'X-Custom': Decimal(12.5)} + self.message.extra_headers = {"X-Custom": Decimal(12.5)} with self.assertRaisesMessage(AnymailSerializationError, "Decimal"): self.message.send() @@ -133,25 +199,37 @@ class SendinBlueBackendStandardEmailTests(SendinBlueBackendMockAPITestCase): self.message.reply_to = ['"Reply recipient" \u2019

', mimetype='text/html') + self.message.attach( + "Une pièce jointe.html", "

\u2019

", mimetype="text/html" + ) self.message.send() - attachment = self.get_api_call_json()['attachment'][0] - self.assertEqual(attachment['name'], 'Une pièce jointe.html') - self.assertEqual(b64decode(attachment['content']).decode('utf-8'), '

\u2019

') + attachment = self.get_api_call_json()["attachment"][0] + self.assertEqual(attachment["name"], "Une pièce jointe.html") + self.assertEqual( + b64decode(attachment["content"]).decode("utf-8"), "

\u2019

" + ) def test_embedded_images(self): # SendinBlue doesn't support inline image @@ -193,7 +281,9 @@ class SendinBlueBackendStandardEmailTests(SendinBlueBackendMockAPITestCase): image_path = sample_image_path(image_filename) cid = attach_inline_image_file(self.message, image_path) # Read from a png file - html_content = '

This has an inline image.

' % cid + html_content = ( + '

This has an inline image.

' % cid + ) self.message.attach_alternative(html_content, "text/html") with self.assertRaises(AnymailUnsupportedFeature): @@ -204,28 +294,38 @@ class SendinBlueBackendStandardEmailTests(SendinBlueBackendMockAPITestCase): image_path = sample_image_path(image_filename) image_data = sample_image_content(image_filename) - self.message.attach_file(image_path) # option 1: attach as a file + # option 1: attach as a file + self.message.attach_file(image_path) - image = MIMEImage(image_data) # option 2: construct the MIMEImage and attach it directly + # option 2: construct the MIMEImage and attach it directly + image = MIMEImage(image_data) self.message.attach(image) self.message.send() - image_data_b64 = b64encode(image_data).decode('ascii') + image_data_b64 = b64encode(image_data).decode("ascii") data = self.get_api_call_json() - self.assertEqual(data['attachment'][0], { - 'name': image_filename, # the named one - 'content': image_data_b64, - }) - self.assertEqual(data['attachment'][1], { - 'name': '', # the unnamed one - 'content': image_data_b64, - }) + self.assertEqual( + data["attachment"][0], + { + "name": image_filename, # the named one + "content": image_data_b64, + }, + ) + self.assertEqual( + data["attachment"][1], + { + "name": "", # the unnamed one + "content": image_data_b64, + }, + ) def test_multiple_html_alternatives(self): self.message.body = "Text body" self.message.attach_alternative("

First html is OK

", "text/html") - self.message.attach_alternative("

And maybe second html, too

", "text/html") + self.message.attach_alternative( + "

And maybe second html, too

", "text/html" + ) with self.assertRaises(AnymailUnsupportedFeature): self.message.send() @@ -240,11 +340,17 @@ class SendinBlueBackendStandardEmailTests(SendinBlueBackendMockAPITestCase): def test_api_failure(self): self.set_mock_response(status_code=400) with self.assertRaisesMessage(AnymailAPIError, "SendinBlue API response 400"): - mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com']) + mail.send_mail("Subject", "Body", "from@example.com", ["to@example.com"]) # Make sure fail_silently is respected self.set_mock_response(status_code=400) - sent = mail.send_mail('Subject', 'Body', 'from@example.com', ['to@example.com'], fail_silently=True) + sent = mail.send_mail( + "Subject", + "Body", + "from@example.com", + ["to@example.com"], + fail_silently=True, + ) self.assertEqual(sent, 0) def test_api_error_includes_details(self): @@ -267,26 +373,26 @@ class SendinBlueBackendStandardEmailTests(SendinBlueBackendMockAPITestCase): self.message.send() -@tag('sendinblue') +@tag("sendinblue") class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase): """Test backend support for Anymail added features""" def test_envelope_sender(self): # SendinBlue does not have a way to change envelope sender. self.message.envelope_sender = "anything@bounces.example.com" - with self.assertRaisesMessage(AnymailUnsupportedFeature, 'envelope_sender'): + with self.assertRaisesMessage(AnymailUnsupportedFeature, "envelope_sender"): self.message.send() def test_metadata(self): - self.message.metadata = {'user_id': "12345", 'items': 6, 'float': 98.6} + self.message.metadata = {"user_id": "12345", "items": 6, "float": 98.6} self.message.send() data = self.get_api_call_json() - metadata = json.loads(data['headers']['X-Mailin-custom']) - self.assertEqual(metadata['user_id'], "12345") - self.assertEqual(metadata['items'], 6) - self.assertEqual(metadata['float'], 98.6) + metadata = json.loads(data["headers"]["X-Mailin-custom"]) + self.assertEqual(metadata["user_id"], "12345") + self.assertEqual(metadata["items"], 6) + self.assertEqual(metadata["float"], 98.6) def test_send_at(self): utc_plus_6 = get_fixed_timezone(6 * 60) @@ -294,47 +400,49 @@ class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase): with override_current_timezone(utc_plus_6): # Timezone-aware datetime converted to UTC: - self.message.send_at = datetime(2016, 3, 4, 5, 6, 7, 8000, tzinfo=utc_minus_8) + self.message.send_at = datetime( + 2016, 3, 4, 5, 6, 7, 8000, tzinfo=utc_minus_8 + ) self.message.send() data = self.get_api_call_json() - self.assertEqual(data['scheduledAt'], "2016-03-04T05:06:07.008-08:00") + self.assertEqual(data["scheduledAt"], "2016-03-04T05:06:07.008-08:00") # Explicit UTC: self.message.send_at = datetime(2016, 3, 4, 5, 6, 7, tzinfo=timezone.utc) self.message.send() data = self.get_api_call_json() - self.assertEqual(data['scheduledAt'], "2016-03-04T05:06:07.000+00:00") + self.assertEqual(data["scheduledAt"], "2016-03-04T05:06:07.000+00:00") # Timezone-naive datetime assumed to be Django current_timezone # (also checks stripping microseconds) self.message.send_at = datetime(2022, 10, 11, 12, 13, 14, 567) self.message.send() data = self.get_api_call_json() - self.assertEqual(data['scheduledAt'], "2022-10-11T12:13:14.000+06:00") + self.assertEqual(data["scheduledAt"], "2022-10-11T12:13:14.000+06:00") # Date-only treated as midnight in current timezone self.message.send_at = date(2022, 10, 22) self.message.send() data = self.get_api_call_json() - self.assertEqual(data['scheduledAt'], "2022-10-22T00:00:00.000+06:00") + self.assertEqual(data["scheduledAt"], "2022-10-22T00:00:00.000+06:00") # POSIX timestamp self.message.send_at = 1651820889 # 2022-05-06 07:08:09 UTC self.message.send() data = self.get_api_call_json() - self.assertEqual(data['scheduledAt'], "2022-05-06T07:08:09.000+00:00") + self.assertEqual(data["scheduledAt"], "2022-05-06T07:08:09.000+00:00") # String passed unchanged (this is *not* portable between ESPs) self.message.send_at = "2022-10-13T18:02:00.123-11:30" self.message.send() data = self.get_api_call_json() - self.assertEqual(data['scheduledAt'], "2022-10-13T18:02:00.123-11:30") + self.assertEqual(data["scheduledAt"], "2022-10-13T18:02:00.123-11:30") def test_tag(self): self.message.tags = ["receipt", "multiple"] self.message.send() data = self.get_api_call_json() - self.assertEqual(data['tags'], ["receipt", "multiple"]) + self.assertEqual(data["tags"], ["receipt", "multiple"]) def test_tracking(self): # Test one way... @@ -353,36 +461,36 @@ class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase): def test_template_id(self): # subject, body, and from_email must be None for SendinBlue template send: message = mail.EmailMessage( - subject='My Subject', + subject="My Subject", body=None, - from_email='from@example.com', - to=['Recipient '], # single 'to' recommended (all 'to' get the same message) - cc=['Recipient ', 'Recipient '], - bcc=['Recipient '], - reply_to=['Recipient '], + from_email="from@example.com", + # single 'to' recommended (all 'to' get the same message) + to=["Recipient "], + cc=["Recipient ", "Recipient "], + bcc=["Recipient "], + reply_to=["Recipient "], ) - message.template_id = 12 # SendinBlue uses per-account numeric ID to identify templates + # SendinBlue uses per-account numeric ID to identify templates: + message.template_id = 12 message.send() data = self.get_api_call_json() - self.assertEqual(data['templateId'], 12) - self.assertEqual(data['subject'], 'My Subject') - self.assertEqual(data['to'], [{'email': "to@example.com", 'name': 'Recipient'}]) + self.assertEqual(data["templateId"], 12) + self.assertEqual(data["subject"], "My Subject") + self.assertEqual(data["to"], [{"email": "to@example.com", "name": "Recipient"}]) def test_merge_data(self): self.message.merge_data = { - 'alice@example.com': {':name': "Alice", ':group': "Developers"}, - 'bob@example.com': {':name': "Bob"}, # and leave :group undefined + "alice@example.com": {":name": "Alice", ":group": "Developers"}, + "bob@example.com": {":name": "Bob"}, # and leave :group undefined } with self.assertRaises(AnymailUnsupportedFeature): self.message.send() def test_merge_global_data(self): - self.message.merge_global_data = { - 'a': 'b' - } + self.message.merge_global_data = {"a": "b"} self.message.send() data = self.get_api_call_json() - self.assertEqual(data['params'], {'a': 'b'}) + self.assertEqual(data["params"], {"a": "b"}) def test_default_omits_options(self): """Make sure by default we don't send any ESP-specific options. @@ -393,59 +501,74 @@ class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase): """ self.message.send() data = self.get_api_call_json() - self.assertNotIn('attachment', data) - self.assertNotIn('tag', data) - self.assertNotIn('headers', data) - self.assertNotIn('replyTo', data) - self.assertNotIn('atributes', data) + self.assertNotIn("attachment", data) + self.assertNotIn("tag", data) + self.assertNotIn("headers", data) + self.assertNotIn("replyTo", data) + self.assertNotIn("atributes", data) def test_esp_extra(self): # SendinBlue doesn't offer any esp-extra but we will test # with some extra of SendGrid to see if it's work in the future self.message.esp_extra = { - 'ip_pool_name': "transactional", - 'asm': { # subscription management - 'group_id': 1, + "ip_pool_name": "transactional", + "asm": { # subscription management + "group_id": 1, }, - 'tracking_settings': { - 'subscription_tracking': { - 'enable': True, - 'substitution_tag': '[unsubscribe_url]', + "tracking_settings": { + "subscription_tracking": { + "enable": True, + "substitution_tag": "[unsubscribe_url]", }, }, } self.message.send() data = self.get_api_call_json() # merged from esp_extra: - self.assertEqual(data['ip_pool_name'], "transactional") - self.assertEqual(data['asm'], {'group_id': 1}) - self.assertEqual(data['tracking_settings']['subscription_tracking'], - {'enable': True, 'substitution_tag': "[unsubscribe_url]"}) + self.assertEqual(data["ip_pool_name"], "transactional") + self.assertEqual(data["asm"], {"group_id": 1}) + self.assertEqual( + data["tracking_settings"]["subscription_tracking"], + {"enable": True, "substitution_tag": "[unsubscribe_url]"}, + ) # noinspection PyUnresolvedReferences def test_send_attaches_anymail_status(self): - """ The anymail_status should be attached to the message when it is sent """ - # the DEFAULT_RAW_RESPONSE above is the *only* success response SendinBlue returns, - # so no need to override it here - msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'], ) + """The anymail_status should be attached to the message when it is sent""" + # the DEFAULT_RAW_RESPONSE above is the *only* success response SendinBlue + # returns, so no need to override it here + msg = mail.EmailMessage( + "Subject", + "Message", + "from@example.com", + ["to1@example.com"], + ) sent = msg.send() self.assertEqual(sent, 1) - self.assertEqual(msg.anymail_status.status, {'queued'}) - self.assertEqual(msg.anymail_status.recipients['to1@example.com'].status, 'queued') - self.assertEqual(msg.anymail_status.esp_response.content, self.DEFAULT_RAW_RESPONSE) + self.assertEqual(msg.anymail_status.status, {"queued"}) + self.assertEqual( + msg.anymail_status.recipients["to1@example.com"].status, "queued" + ) + self.assertEqual( + msg.anymail_status.esp_response.content, self.DEFAULT_RAW_RESPONSE + ) self.assertEqual( msg.anymail_status.message_id, - json.loads(msg.anymail_status.esp_response.content.decode('utf-8'))['messageId'] + json.loads(msg.anymail_status.esp_response.content.decode("utf-8"))[ + "messageId" + ], ) self.assertEqual( - msg.anymail_status.recipients['to1@example.com'].message_id, - json.loads(msg.anymail_status.esp_response.content.decode('utf-8'))['messageId'] + msg.anymail_status.recipients["to1@example.com"].message_id, + json.loads(msg.anymail_status.esp_response.content.decode("utf-8"))[ + "messageId" + ], ) # noinspection PyUnresolvedReferences def test_send_failed_anymail_status(self): - """ If the send fails, anymail_status should contain initial values""" + """If the send fails, anymail_status should contain initial values""" self.set_mock_response(status_code=500) sent = self.message.send(fail_silently=True) self.assertEqual(sent, 0) @@ -455,18 +578,22 @@ class SendinBlueBackendAnymailFeatureTests(SendinBlueBackendMockAPITestCase): def test_json_serialization_errors(self): """Try to provide more information about non-json-serializable data""" - self.message.esp_extra = {'total': Decimal('19.99')} + self.message.esp_extra = {"total": Decimal("19.99")} with self.assertRaises(AnymailSerializationError) as cm: self.message.send() err = cm.exception self.assertIsInstance(err, TypeError) # compatibility with json.dumps - self.assertIn("Don't know how to send this data to SendinBlue", str(err)) # our added context - self.assertRegex(str(err), r"Decimal.*is not JSON serializable") # original message + # our added context: + self.assertIn("Don't know how to send this data to SendinBlue", str(err)) + # original message + self.assertRegex(str(err), r"Decimal.*is not JSON serializable") -@tag('sendinblue') +@tag("sendinblue") class SendinBlueBackendRecipientsRefusedTests(SendinBlueBackendMockAPITestCase): - """Should raise AnymailRecipientsRefused when *all* recipients are rejected or invalid""" + """ + Should raise AnymailRecipientsRefused when *all* recipients are rejected or invalid + """ # SendinBlue doesn't check email bounce or complaint lists at time of send -- # it always just queues the message. You'll need to listen for the "rejected" @@ -474,17 +601,22 @@ class SendinBlueBackendRecipientsRefusedTests(SendinBlueBackendMockAPITestCase): pass # not applicable to this backend -@tag('sendinblue') -class SendinBlueBackendSessionSharingTestCase(SessionSharingTestCases, SendinBlueBackendMockAPITestCase): +@tag("sendinblue") +class SendinBlueBackendSessionSharingTestCase( + SessionSharingTestCases, SendinBlueBackendMockAPITestCase +): """Requests session sharing tests""" + pass # tests are defined in SessionSharingTestCases -@tag('sendinblue') +@tag("sendinblue") @override_settings(EMAIL_BACKEND="anymail.backends.sendinblue.EmailBackend") class SendinBlueBackendImproperlyConfiguredTests(AnymailTestMixin, SimpleTestCase): """Test ESP backend without required settings in place""" def test_missing_auth(self): - with self.assertRaisesRegex(AnymailConfigurationError, r'\bSENDINBLUE_API_KEY\b'): - mail.send_mail('Subject', 'Message', 'from@example.com', ['to@example.com']) + with self.assertRaisesRegex( + AnymailConfigurationError, r"\bSENDINBLUE_API_KEY\b" + ): + mail.send_mail("Subject", "Message", "from@example.com", ["to@example.com"]) diff --git a/tests/test_sendinblue_integration.py b/tests/test_sendinblue_integration.py index 1934cd5..ff643b6 100644 --- a/tests/test_sendinblue_integration.py +++ b/tests/test_sendinblue_integration.py @@ -10,17 +10,21 @@ from anymail.message import AnymailMessage from .utils import AnymailTestMixin -ANYMAIL_TEST_SENDINBLUE_API_KEY = os.getenv('ANYMAIL_TEST_SENDINBLUE_API_KEY') -ANYMAIL_TEST_SENDINBLUE_DOMAIN = os.getenv('ANYMAIL_TEST_SENDINBLUE_DOMAIN') +ANYMAIL_TEST_SENDINBLUE_API_KEY = os.getenv("ANYMAIL_TEST_SENDINBLUE_API_KEY") +ANYMAIL_TEST_SENDINBLUE_DOMAIN = os.getenv("ANYMAIL_TEST_SENDINBLUE_DOMAIN") -@tag('sendinblue', 'live') -@unittest.skipUnless(ANYMAIL_TEST_SENDINBLUE_API_KEY and ANYMAIL_TEST_SENDINBLUE_DOMAIN, - "Set ANYMAIL_TEST_SENDINBLUE_API_KEY and ANYMAIL_TEST_SENDINBLUE_DOMAIN " - "environment variables to run SendinBlue integration tests") -@override_settings(ANYMAIL_SENDINBLUE_API_KEY=ANYMAIL_TEST_SENDINBLUE_API_KEY, - ANYMAIL_SENDINBLUE_SEND_DEFAULTS=dict(), - EMAIL_BACKEND="anymail.backends.sendinblue.EmailBackend") +@tag("sendinblue", "live") +@unittest.skipUnless( + ANYMAIL_TEST_SENDINBLUE_API_KEY and ANYMAIL_TEST_SENDINBLUE_DOMAIN, + "Set ANYMAIL_TEST_SENDINBLUE_API_KEY and ANYMAIL_TEST_SENDINBLUE_DOMAIN " + "environment variables to run SendinBlue integration tests", +) +@override_settings( + ANYMAIL_SENDINBLUE_API_KEY=ANYMAIL_TEST_SENDINBLUE_API_KEY, + ANYMAIL_SENDINBLUE_SEND_DEFAULTS=dict(), + EMAIL_BACKEND="anymail.backends.sendinblue.EmailBackend", +) class SendinBlueBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): """SendinBlue v3 API integration tests @@ -36,10 +40,14 @@ class SendinBlueBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): def setUp(self): super().setUp() - self.from_email = 'from@%s' % ANYMAIL_TEST_SENDINBLUE_DOMAIN - self.message = AnymailMessage('Anymail SendinBlue integration test', 'Text content', - self.from_email, ['test+to1@anymail.dev']) - self.message.attach_alternative('

HTML content

', "text/html") + self.from_email = "from@%s" % ANYMAIL_TEST_SENDINBLUE_DOMAIN + self.message = AnymailMessage( + "Anymail SendinBlue integration test", + "Text content", + self.from_email, + ["test+to1@anymail.dev"], + ) + self.message.attach_alternative("

HTML content

", "text/html") def test_simple_send(self): # Example of getting the SendinBlue send status and message id from the message @@ -47,12 +55,14 @@ class SendinBlueBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): self.assertEqual(sent_count, 1) anymail_status = self.message.anymail_status - sent_status = anymail_status.recipients['test+to1@anymail.dev'].status - message_id = anymail_status.recipients['test+to1@anymail.dev'].message_id + sent_status = anymail_status.recipients["test+to1@anymail.dev"].status + message_id = anymail_status.recipients["test+to1@anymail.dev"].message_id - self.assertEqual(sent_status, 'queued') # SendinBlue always queues - self.assertRegex(message_id, r'\<.+@.+\>') # Message-ID can be ...@smtp-relay.mail.fr or .sendinblue.com - self.assertEqual(anymail_status.status, {sent_status}) # set of all recipient statuses + self.assertEqual(sent_status, "queued") # SendinBlue always queues + # Message-ID can be ...@smtp-relay.mail.fr or .sendinblue.com: + self.assertRegex(message_id, r"\<.+@.+\>") + # set of all recipient statuses: + self.assertEqual(anymail_status.status, {sent_status}) self.assertEqual(anymail_status.message_id, message_id) def test_all_options(self): @@ -64,27 +74,32 @@ class SendinBlueBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): to=["test+to1@anymail.dev", '"Recipient 2, OK?" '], cc=["test+cc1@anymail.dev", "Copy 2 "], bcc=["test+bcc1@anymail.dev", "Blind Copy 2 "], - reply_to=['"Reply, with comma" '], # SendinBlue API v3 only supports single reply-to + # SendinBlue API v3 only supports single reply-to + reply_to=['"Reply, with comma" '], headers={"X-Anymail-Test": "value", "X-Anymail-Count": 3}, - metadata={"meta1": "simple string", "meta2": 2}, send_at=send_at, tags=["tag 1", "tag 2"], ) - message.attach_alternative('

HTML content

', "text/html") # SendinBlue requires an HTML body + # SendinBlue requires an HTML body: + message.attach_alternative("

HTML content

", "text/html") message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain") message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv") message.send() - self.assertEqual(message.anymail_status.status, {'queued'}) # SendinBlue always queues - self.assertRegex(message.anymail_status.message_id, r'\<.+@.+\>') + # SendinBlue always queues: + self.assertEqual(message.anymail_status.status, {"queued"}) + self.assertRegex(message.anymail_status.message_id, r"\<.+@.+\>") def test_template(self): message = AnymailMessage( - template_id=5, # There is a *new-style* template with this id in the Anymail test account - from_email=formataddr(('Sender', self.from_email)), # Override template sender - to=["Recipient "], # No batch send (so max one recipient suggested) + # There is a *new-style* template with this id in the Anymail test account: + template_id=5, + # Override template sender: + from_email=formataddr("Sender", self.from_email), + # No batch send (so max one recipient suggested): + to=["Recipient "], reply_to=["Do not reply "], tags=["using-template"], headers={"X-Anymail-Test": "group: A, variation: C"}, @@ -98,20 +113,25 @@ class SendinBlueBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): ) # Normal attachments don't work with Sendinblue templates: - # message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain") + # message.attach("attachment1.txt", "Here is some\ntext", "text/plain") # If you can host the attachment content on some publicly-accessible URL, # this *non-portable* alternative allows sending attachments with templates: message.esp_extra = { - 'attachment': [{ - 'name': 'attachment1.txt', - # URL where Sendinblue can download the attachment content while sending: - 'url': 'https://raw.githubusercontent.com/anymail/django-anymail/main/AUTHORS.txt', - }] + "attachment": [ + { + "name": "attachment1.txt", + # URL where Sendinblue can download + # the attachment content while sending: + "url": "https://raw.githubusercontent.com/anymail" + "/django-anymail/main/AUTHORS.txt", + } + ] } message.send() - self.assertEqual(message.anymail_status.status, {'queued'}) # SendinBlue always queues - self.assertRegex(message.anymail_status.message_id, r'\<.+@.+\>') + # SendinBlue always queues: + self.assertEqual(message.anymail_status.status, {"queued"}) + self.assertRegex(message.anymail_status.message_id, r"\<.+@.+\>") @override_settings(ANYMAIL_SENDINBLUE_API_KEY="Hey, that's not an API key!") def test_invalid_api_key(self): diff --git a/tests/test_sendinblue_webhooks.py b/tests/test_sendinblue_webhooks.py index 02a0f89..6c64272 100644 --- a/tests/test_sendinblue_webhooks.py +++ b/tests/test_sendinblue_webhooks.py @@ -6,19 +6,23 @@ from django.test import tag from anymail.signals import AnymailTrackingEvent from anymail.webhooks.sendinblue import SendinBlueTrackingWebhookView + from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase -@tag('sendinblue') +@tag("sendinblue") class SendinBlueWebhookSecurityTestCase(WebhookBasicAuthTestCase): def call_webhook(self): - return self.client.post('/anymail/sendinblue/tracking/', - content_type='application/json', data=json.dumps({})) + return self.client.post( + "/anymail/sendinblue/tracking/", + content_type="application/json", + data=json.dumps({}), + ) # Actual tests are in WebhookBasicAuthTestCase -@tag('sendinblue') +@tag("sendinblue") class SendinBlueDeliveryTestCase(WebhookTestCase): # SendinBlue's webhook payload data is partially documented at # https://help.sendinblue.com/hc/en-us/articles/360007666479, @@ -29,16 +33,15 @@ class SendinBlueDeliveryTestCase(WebhookTestCase): raw_event = { "event": "request", "email": "recipient@example.com", - "id": 9999999, # this appears to be a SendinBlue account id (not an event id) + "id": 9999999, # this seems to be SendinBlue account id (not an event id) "message-id": "<201803062010.27287306012@smtp-relay.mailin.fr>", "subject": "Test subject", - - # From a message sent at 2018-03-06 11:10:23-08:00 (2018-03-06 19:10:23+00:00)... - "date": "2018-03-06 11:10:23", # uses time zone from SendinBlue account's preferences + # From a message sent at 2018-03-06 11:10:23-08:00 + # (2018-03-06 19:10:23+00:00)... + "date": "2018-03-06 11:10:23", # tz from SendinBlue account's preferences "ts": 1520331023, # 2018-03-06 10:10:23 -- what time zone is this? "ts_event": 1520331023, # unclear if this ever differs from "ts" - "ts_epoch": 1520363423000, # 2018-03-06 19:10:23.000+00:00 -- UTC (milliseconds) - + "ts_epoch": 1520363423000, # 2018-03-06 19:10:23.000+00:00 -- UTC (msec) "X-Mailin-custom": '{"meta": "data"}', # "tag" is JSON-serialized tags array if `tags` param set on send, # else single tag string if `X-Mailin-Tag` header set on send, @@ -50,18 +53,31 @@ class SendinBlueDeliveryTestCase(WebhookTestCase): "template_id": 12, "sending_ip": "333.33.33.33", } - response = self.client.post('/anymail/sendinblue/tracking/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/sendinblue/tracking/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendinBlueTrackingWebhookView, - event=ANY, esp_name='SendinBlue') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SendinBlueTrackingWebhookView, + event=ANY, + esp_name="SendinBlue", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "queued") self.assertEqual(event.esp_event, raw_event) - self.assertEqual(event.timestamp, datetime(2018, 3, 6, 19, 10, 23, microsecond=0, tzinfo=timezone.utc)) - self.assertEqual(event.message_id, "<201803062010.27287306012@smtp-relay.mailin.fr>") - self.assertIsNone(event.event_id) # SendinBlue does not provide a unique event id + self.assertEqual( + event.timestamp, + datetime(2018, 3, 6, 19, 10, 23, microsecond=0, tzinfo=timezone.utc), + ) + self.assertEqual( + event.message_id, "<201803062010.27287306012@smtp-relay.mailin.fr>" + ) + # SendinBlue does not provide a unique event id: + self.assertIsNone(event.event_id) self.assertEqual(event.recipient, "recipient@example.com") self.assertEqual(event.metadata, {"meta": "data"}) self.assertEqual(event.tags, ["tag1", "tag2"]) @@ -75,18 +91,28 @@ class SendinBlueDeliveryTestCase(WebhookTestCase): "ts_epoch": 1520363423000, "message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>", } - response = self.client.post('/anymail/sendinblue/tracking/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/sendinblue/tracking/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendinBlueTrackingWebhookView, - event=ANY, esp_name='SendinBlue') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SendinBlueTrackingWebhookView, + event=ANY, + esp_name="SendinBlue", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "delivered") self.assertEqual(event.esp_event, raw_event) - self.assertEqual(event.message_id, "<201803011158.9876543210@smtp-relay.mailin.fr>") + self.assertEqual( + event.message_id, "<201803011158.9876543210@smtp-relay.mailin.fr>" + ) self.assertEqual(event.recipient, "recipient@example.com") - self.assertEqual(event.metadata, {}) # empty dict when no X-Mailin-custom header given + # empty dict when no X-Mailin-custom header given: + self.assertEqual(event.metadata, {}) self.assertEqual(event.tags, []) # empty list when no tags given def test_hard_bounce(self): @@ -96,19 +122,30 @@ class SendinBlueDeliveryTestCase(WebhookTestCase): "ts_epoch": 1520363423000, "message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>", # the leading space in the reason is as received in actual testing: - "reason": " RecipientError: 550 5.5.0 Requested action not taken: mailbox unavailable.", + "reason": " RecipientError: 550 5.5.0" + " Requested action not taken: mailbox unavailable.", "tag": "header-tag", } - response = self.client.post('/anymail/sendinblue/tracking/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/sendinblue/tracking/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendinBlueTrackingWebhookView, - event=ANY, esp_name='SendinBlue') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SendinBlueTrackingWebhookView, + event=ANY, + esp_name="SendinBlue", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "bounced") self.assertEqual(event.reject_reason, "bounced") - self.assertEqual(event.mta_response, - " RecipientError: 550 5.5.0 Requested action not taken: mailbox unavailable.") + self.assertEqual( + event.mta_response, + " RecipientError: 550 5.5.0" + " Requested action not taken: mailbox unavailable.", + ) self.assertEqual(event.tags, ["header-tag"]) def test_soft_bounce_event(self): @@ -119,16 +156,27 @@ class SendinBlueDeliveryTestCase(WebhookTestCase): "message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>", "reason": "undefined Unable to find MX of domain no-mx.example.com", } - response = self.client.post('/anymail/sendinblue/tracking/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/sendinblue/tracking/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendinBlueTrackingWebhookView, - event=ANY, esp_name='SendinBlue') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SendinBlueTrackingWebhookView, + event=ANY, + esp_name="SendinBlue", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "bounced") self.assertEqual(event.reject_reason, "bounced") - self.assertIsNone(event.description) # no human-readable description consistently available - self.assertEqual(event.mta_response, "undefined Unable to find MX of domain no-mx.example.com") + # no human-readable description consistently available: + self.assertIsNone(event.description) + self.assertEqual( + event.mta_response, + "undefined Unable to find MX of domain no-mx.example.com", + ) def test_blocked(self): raw_event = { @@ -138,12 +186,19 @@ class SendinBlueDeliveryTestCase(WebhookTestCase): "message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>", "reason": "blocked : due to blacklist user", } - response = self.client.post('/anymail/sendinblue/tracking/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/sendinblue/tracking/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendinBlueTrackingWebhookView, - event=ANY, esp_name='SendinBlue') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SendinBlueTrackingWebhookView, + event=ANY, + esp_name="SendinBlue", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "rejected") self.assertEqual(event.reject_reason, "blocked") self.assertEqual(event.mta_response, "blocked : due to blacklist user") @@ -157,18 +212,26 @@ class SendinBlueDeliveryTestCase(WebhookTestCase): "ts_epoch": 1520363423000, "message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>", } - response = self.client.post('/anymail/sendinblue/tracking/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/sendinblue/tracking/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendinBlueTrackingWebhookView, - event=ANY, esp_name='SendinBlue') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SendinBlueTrackingWebhookView, + event=ANY, + esp_name="SendinBlue", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "complained") def test_invalid_email(self): - # "If a ISP again indicated us that the email is not valid or if we discovered that the email is not valid." - # (unclear whether this error originates with the receiving MTA or with SendinBlue pre-send) - # (haven't observed "invalid_email" event in actual testing; payload below is a guess) + # "If a ISP again indicated us that the email is not valid or if we discovered + # that the email is not valid." (unclear whether this error originates with the + # receiving MTA or with SendinBlue pre-send) (haven't observed "invalid_email" + # event in actual testing; payload below is a guess) raw_event = { "event": "invalid_email", "email": "recipient@example.com", @@ -176,73 +239,106 @@ class SendinBlueDeliveryTestCase(WebhookTestCase): "message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>", "reason": "(guessing invalid_email includes a reason)", } - response = self.client.post('/anymail/sendinblue/tracking/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/sendinblue/tracking/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendinBlueTrackingWebhookView, - event=ANY, esp_name='SendinBlue') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SendinBlueTrackingWebhookView, + event=ANY, + esp_name="SendinBlue", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "bounced") self.assertEqual(event.reject_reason, "invalid") - self.assertEqual(event.mta_response, "(guessing invalid_email includes a reason)") + self.assertEqual( + event.mta_response, "(guessing invalid_email includes a reason)" + ) def test_deferred_event(self): - # Note: the example below is an actual event capture (with 'example.com' substituted - # for the real receiving domain). It's pretty clearly a bounce, not a deferral. - # It looks like SendinBlue mis-categorizes this SMTP response code. + # Note: the example below is an actual event capture (with 'example.com' + # substituted for the real receiving domain). It's pretty clearly a bounce, not + # a deferral. It looks like SendinBlue mis-categorizes this SMTP response code. raw_event = { "event": "deferred", "email": "notauser@example.com", "ts_epoch": 1520363423000, "message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>", - "reason": "550 RecipientError: 550 5.1.1 : Recipient address rejected: " - "User unknown in virtual alias table", + "reason": "550 RecipientError: 550 5.1.1 : Recipient" + " address rejected: User unknown in virtual alias table", } - response = self.client.post('/anymail/sendinblue/tracking/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/sendinblue/tracking/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendinBlueTrackingWebhookView, - event=ANY, esp_name='SendinBlue') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SendinBlueTrackingWebhookView, + event=ANY, + esp_name="SendinBlue", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "deferred") - self.assertIsNone(event.description) # no human-readable description consistently available - self.assertEqual(event.mta_response, - "550 RecipientError: 550 5.1.1 : Recipient address rejected: " - "User unknown in virtual alias table") + # no human-readable description consistently available: + self.assertIsNone(event.description) + self.assertEqual( + event.mta_response, + "550 RecipientError: 550 5.1.1 :" + " Recipient address rejected: User unknown in virtual alias table", + ) def test_opened_event(self): - # SendinBlue delivers unique_opened *and* opened on the first open. - # To avoid double-counting, you should only enable one of the two events in SendinBlue. + # SendinBlue delivers unique_opened *and* opened on the first open. To avoid + # double-counting, you should only enable one of the two events in SendinBlue. raw_event = { "event": "opened", "email": "recipient@example.com", "ts_epoch": 1520363423000, "message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>", } - response = self.client.post('/anymail/sendinblue/tracking/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/sendinblue/tracking/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendinBlueTrackingWebhookView, - event=ANY, esp_name='SendinBlue') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SendinBlueTrackingWebhookView, + event=ANY, + esp_name="SendinBlue", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "opened") self.assertIsNone(event.user_agent) # SendinBlue doesn't report user agent def test_unique_opened_event(self): - # SendinBlue delivers unique_opened *and* opened on the first open. - # To avoid double-counting, you should only enable one of the two events in SendinBlue. + # SendinBlue delivers unique_opened *and* opened on the first open. To avoid + # double-counting, you should only enable one of the two events in SendinBlue. raw_event = { "event": "unique_opened", "email": "recipient@example.com", "ts_epoch": 1520363423000, "message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>", } - response = self.client.post('/anymail/sendinblue/tracking/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/sendinblue/tracking/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendinBlueTrackingWebhookView, - event=ANY, esp_name='SendinBlue') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SendinBlueTrackingWebhookView, + event=ANY, + esp_name="SendinBlue", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "opened") def test_clicked_event(self): @@ -253,29 +349,44 @@ class SendinBlueDeliveryTestCase(WebhookTestCase): "message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>", "link": "https://example.com/click/me", } - response = self.client.post('/anymail/sendinblue/tracking/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/sendinblue/tracking/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendinBlueTrackingWebhookView, - event=ANY, esp_name='SendinBlue') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SendinBlueTrackingWebhookView, + event=ANY, + esp_name="SendinBlue", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "clicked") self.assertEqual(event.click_url, "https://example.com/click/me") self.assertIsNone(event.user_agent) # SendinBlue doesn't report user agent def test_unsubscribe(self): # "When a person unsubscribes from the email received." - # (haven't observed "unsubscribe" event in actual testing; payload below is a guess) + # (haven't observed "unsubscribe" event in actual testing; + # payload below is a guess) raw_event = { "event": "unsubscribe", "email": "recipient@example.com", "ts_epoch": 1520363423000, "message-id": "<201803011158.9876543210@smtp-relay.mailin.fr>", } - response = self.client.post('/anymail/sendinblue/tracking/', - content_type='application/json', data=json.dumps(raw_event)) + response = self.client.post( + "/anymail/sendinblue/tracking/", + content_type="application/json", + data=json.dumps(raw_event), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SendinBlueTrackingWebhookView, - event=ANY, esp_name='SendinBlue') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SendinBlueTrackingWebhookView, + event=ANY, + esp_name="SendinBlue", + ) + event = kwargs["event"] self.assertEqual(event.event_type, "unsubscribed") diff --git a/tests/test_settings/urls.py b/tests/test_settings/urls.py index d6402f7..151ca87 100644 --- a/tests/test_settings/urls.py +++ b/tests/test_settings/urls.py @@ -1,5 +1,5 @@ from django.urls import include, re_path urlpatterns = [ - re_path(r'^anymail/', include('anymail.urls')), + re_path(r"^anymail/", include("anymail.urls")), ] diff --git a/tests/test_sparkpost_backend.py b/tests/test_sparkpost_backend.py index 4ccca55..25589b1 100644 --- a/tests/test_sparkpost_backend.py +++ b/tests/test_sparkpost_backend.py @@ -7,20 +7,34 @@ from email.mime.text import MIMEText from django.core import mail from django.test import override_settings, tag -from django.utils.timezone import get_fixed_timezone, override as override_current_timezone +from django.utils.timezone import ( + get_fixed_timezone, + override as override_current_timezone, +) from anymail.exceptions import ( - AnymailAPIError, AnymailConfigurationError, AnymailRecipientsRefused, - AnymailSerializationError, AnymailUnsupportedFeature) + AnymailAPIError, + AnymailConfigurationError, + AnymailRecipientsRefused, + AnymailSerializationError, + AnymailUnsupportedFeature, +) from anymail.message import attach_inline_image_file from .mock_requests_backend import RequestsBackendMockAPITestCase -from .utils import SAMPLE_IMAGE_FILENAME, decode_att, sample_image_content, sample_image_path +from .utils import ( + SAMPLE_IMAGE_FILENAME, + decode_att, + sample_image_content, + sample_image_path, +) -@tag('sparkpost') -@override_settings(EMAIL_BACKEND='anymail.backends.sparkpost.EmailBackend', - ANYMAIL={'SPARKPOST_API_KEY': 'test_api_key'}) +@tag("sparkpost") +@override_settings( + EMAIL_BACKEND="anymail.backends.sparkpost.EmailBackend", + ANYMAIL={"SPARKPOST_API_KEY": "test_api_key"}, +) class SparkPostBackendMockAPITestCase(RequestsBackendMockAPITestCase): """TestCase that uses SparkPostEmailBackend with a mocked transmissions.send API""" @@ -35,32 +49,40 @@ class SparkPostBackendMockAPITestCase(RequestsBackendMockAPITestCase): def setUp(self): super().setUp() # Simple message useful for many tests - self.message = mail.EmailMultiAlternatives('Subject', 'Text Body', - 'from@example.com', ['to@example.com']) + self.message = mail.EmailMultiAlternatives( + "Subject", "Text Body", "from@example.com", ["to@example.com"] + ) def set_mock_result(self, accepted=1, rejected=0, id="12345678901234567890"): """Set a mock response that reflects count of accepted/rejected recipients""" - raw = json.dumps({ - "results": { - "id": id, - "total_accepted_recipients": accepted, - "total_rejected_recipients": rejected, + raw = json.dumps( + { + "results": { + "id": id, + "total_accepted_recipients": accepted, + "total_rejected_recipients": rejected, + } } - }).encode("utf-8") + ).encode("utf-8") self.set_mock_response(raw=raw) return raw -@tag('sparkpost') +@tag("sparkpost") class SparkPostBackendStandardEmailTests(SparkPostBackendMockAPITestCase): """Test backend support for Django standard email features""" def test_send_mail(self): """Test basic API for simple send""" - mail.send_mail('Subject here', 'Here is the message.', - 'from@example.com', ['to@example.com'], fail_silently=False) + mail.send_mail( + "Subject here", + "Here is the message.", + "from@example.com", + ["to@example.com"], + fail_silently=False, + ) - self.assert_esp_called('/api/v1/transmissions/') + self.assert_esp_called("/api/v1/transmissions/") headers = self.get_api_call_headers() self.assertEqual("test_api_key", headers["Authorization"]) @@ -69,9 +91,10 @@ class SparkPostBackendStandardEmailTests(SparkPostBackendMockAPITestCase): self.assertEqual(data["content"]["subject"], "Subject here") self.assertEqual(data["content"]["text"], "Here is the message.") self.assertEqual(data["content"]["from"], "from@example.com") - self.assertEqual(data['recipients'], [{ - "address": {"email": "to@example.com", "header_to": "to@example.com"} - }]) + self.assertEqual( + data["recipients"], + [{"address": {"email": "to@example.com", "header_to": "to@example.com"}}], + ) def test_name_addr(self): """Make sure RFC2822 name-addr format (with display-name) is allowed @@ -80,73 +103,102 @@ class SparkPostBackendStandardEmailTests(SparkPostBackendMockAPITestCase): """ self.set_mock_result(accepted=6) msg = mail.EmailMessage( - 'Subject', 'Message', 'From Name ', - ['Recipient #1 ', 'to2@example.com'], - cc=['Carbon Copy ', 'cc2@example.com'], - bcc=['Blind Copy ', 'bcc2@example.com']) + "Subject", + "Message", + "From Name ", + ["Recipient #1 ", "to2@example.com"], + cc=["Carbon Copy ", "cc2@example.com"], + bcc=["Blind Copy ", "bcc2@example.com"], + ) msg.send() data = self.get_api_call_json() self.assertEqual(data["content"]["from"], "From Name ") - # This also checks recipient generation for cc and bcc. Because it's *not* - # a batch send, each recipient should see a To header reflecting all To addresses. - self.assertCountEqual(data["recipients"], [ - {"address": { - "email": "to1@example.com", - "header_to": "Recipient #1 , to2@example.com", - }}, - {"address": { - "email": "to2@example.com", - "header_to": "Recipient #1 , to2@example.com", - }}, - # cc and bcc must be explicitly specified as recipients - {"address": { - "email": "cc1@example.com", - "header_to": "Recipient #1 , to2@example.com", - }}, - {"address": { - "email": "cc2@example.com", - "header_to": "Recipient #1 , to2@example.com", - }}, - {"address": { - "email": "bcc1@example.com", - "header_to": "Recipient #1 , to2@example.com", - }}, - {"address": { - "email": "bcc2@example.com", - "header_to": "Recipient #1 , to2@example.com", - }}, - ]) + # This also checks recipient generation for cc and bcc. Because it's *not* a + # batch send, each recipient should see a To header reflecting all To addresses. + self.assertCountEqual( + data["recipients"], + [ + { + "address": { + "email": "to1@example.com", + "header_to": "Recipient #1 , to2@example.com", + } + }, + { + "address": { + "email": "to2@example.com", + "header_to": "Recipient #1 , to2@example.com", + } + }, + # cc and bcc must be explicitly specified as recipients + { + "address": { + "email": "cc1@example.com", + "header_to": "Recipient #1 , to2@example.com", + } + }, + { + "address": { + "email": "cc2@example.com", + "header_to": "Recipient #1 , to2@example.com", + } + }, + { + "address": { + "email": "bcc1@example.com", + "header_to": "Recipient #1 , to2@example.com", + } + }, + { + "address": { + "email": "bcc2@example.com", + "header_to": "Recipient #1 , to2@example.com", + } + }, + ], + ) # Make sure we added a formatted Cc header visible to recipients # (and not a Bcc header) - self.assertEqual(data["content"]["headers"], { - "Cc": "Carbon Copy , cc2@example.com" - }) + self.assertEqual( + data["content"]["headers"], + {"Cc": "Carbon Copy , cc2@example.com"}, + ) def test_custom_headers(self): self.set_mock_result(accepted=6) email = mail.EmailMessage( - 'Subject', 'Body goes here', 'from@example.com', ['to1@example.com'], - cc=['cc1@example.com'], - headers={'Reply-To': 'another@example.com', - 'X-MyHeader': 'my value', - 'Message-ID': 'mycustommsgid@example.com'}) + "Subject", + "Body goes here", + "from@example.com", + ["to1@example.com"], + cc=["cc1@example.com"], + headers={ + "Reply-To": "another@example.com", + "X-MyHeader": "my value", + "Message-ID": "mycustommsgid@example.com", + }, + ) email.send() data = self.get_api_call_json() - self.assertEqual(data["content"]["headers"], { - # Reply-To moved to separate param (below) - "X-MyHeader": "my value", - "Message-ID": "mycustommsgid@example.com", - "Cc": "cc1@example.com", # Cc header added - }) + self.assertEqual( + data["content"]["headers"], + { + # Reply-To moved to separate param (below) + "X-MyHeader": "my value", + "Message-ID": "mycustommsgid@example.com", + "Cc": "cc1@example.com", # Cc header added + }, + ) self.assertEqual(data["content"]["reply_to"], "another@example.com") def test_html_message(self): - text_content = 'This is an important message.' - html_content = '

This is an important message.

' - email = mail.EmailMultiAlternatives('Subject', text_content, - 'from@example.com', ['to@example.com']) + text_content = "This is an important message." + html_content = "

This is an important message.

" + email = mail.EmailMultiAlternatives( + "Subject", text_content, "from@example.com", ["to@example.com"] + ) email.attach_alternative(html_content, "text/html") email.send() @@ -157,8 +209,10 @@ class SparkPostBackendStandardEmailTests(SparkPostBackendMockAPITestCase): self.assertNotIn("attachments", data["content"]) def test_html_only_message(self): - html_content = '

This is an important message.

' - email = mail.EmailMessage('Subject', html_content, 'from@example.com', ['to@example.com']) + html_content = "

This is an important message.

" + email = mail.EmailMessage( + "Subject", html_content, "from@example.com", ["to@example.com"] + ) email.content_subtype = "html" # Main content is now text/html email.send() @@ -167,19 +221,28 @@ class SparkPostBackendStandardEmailTests(SparkPostBackendMockAPITestCase): self.assertEqual(data["content"]["html"], html_content) def test_reply_to(self): - email = mail.EmailMessage('Subject', 'Body goes here', 'from@example.com', ['to1@example.com'], - reply_to=['reply@example.com', 'Other '], - headers={'X-Other': 'Keep'}) + email = mail.EmailMessage( + "Subject", + "Body goes here", + "from@example.com", + ["to1@example.com"], + reply_to=["reply@example.com", "Other "], + headers={"X-Other": "Keep"}, + ) email.send() data = self.get_api_call_json() - self.assertEqual(data["content"]["reply_to"], - "reply@example.com, Other ") - self.assertEqual(data["content"]["headers"], {"X-Other": "Keep"}) # don't lose other headers + self.assertEqual( + data["content"]["reply_to"], "reply@example.com, Other " + ) + # don't lose other headers: + self.assertEqual(data["content"]["headers"], {"X-Other": "Keep"}) def test_attachments(self): text_content = "* Item one\n* Item two\n* Item three" - self.message.attach(filename="test.txt", content=text_content, mimetype="text/plain") + self.message.attach( + filename="test.txt", content=text_content, mimetype="text/plain" + ) # Should guess mimetype if not provided... png_content = b"PNG\xb4 pretend this is the contents of a png file" @@ -187,7 +250,7 @@ class SparkPostBackendStandardEmailTests(SparkPostBackendMockAPITestCase): # Should work with a MIMEBase object (also tests no filename)... pdf_content = b"PDF\xb4 pretend this is valid pdf params" - mimeattachment = MIMEBase('application', 'pdf') + mimeattachment = MIMEBase("application", "pdf") mimeattachment.set_payload(pdf_content) self.message.attach(mimeattachment) @@ -197,7 +260,9 @@ class SparkPostBackendStandardEmailTests(SparkPostBackendMockAPITestCase): self.assertEqual(len(attachments), 3) self.assertEqual(attachments[0]["type"], "text/plain") self.assertEqual(attachments[0]["name"], "test.txt") - self.assertEqual(decode_att(attachments[0]["data"]).decode("ascii"), text_content) + self.assertEqual( + decode_att(attachments[0]["data"]).decode("ascii"), text_content + ) self.assertEqual(attachments[1]["type"], "image/png") # inferred from filename self.assertEqual(attachments[1]["name"], "test.png") self.assertEqual(decode_att(attachments[1]["data"]), png_content) @@ -210,7 +275,9 @@ class SparkPostBackendStandardEmailTests(SparkPostBackendMockAPITestCase): def test_unicode_attachment_correctly_decoded(self): # Slight modification from the Django unicode docs: # http://django.readthedocs.org/en/latest/ref/unicode.html#email - self.message.attach("Une pièce jointe.html", '

\u2019

', mimetype='text/html') + self.message.attach( + "Une pièce jointe.html", "

\u2019

", mimetype="text/html" + ) self.message.send() data = self.get_api_call_json() attachments = data["content"]["attachments"] @@ -223,7 +290,9 @@ class SparkPostBackendStandardEmailTests(SparkPostBackendMockAPITestCase): data = self.get_api_call_json() attachment = data["content"]["attachments"][0] self.assertEqual(attachment["type"], 'text/plain; charset="iso8859-1"') - self.assertEqual(decode_att(attachment["data"]), "Une pièce jointe".encode("iso8859-1")) + self.assertEqual( + decode_att(attachment["data"]), "Une pièce jointe".encode("iso8859-1") + ) def test_embedded_images(self): image_filename = SAMPLE_IMAGE_FILENAME @@ -231,7 +300,9 @@ class SparkPostBackendStandardEmailTests(SparkPostBackendMockAPITestCase): image_data = sample_image_content(image_filename) cid = attach_inline_image_file(self.message, image_path) - html_content = '

This has an inline image.

' % cid + html_content = ( + '

This has an inline image.

' % cid + ) self.message.attach_alternative(html_content, "text/html") self.message.send() @@ -241,7 +312,9 @@ class SparkPostBackendStandardEmailTests(SparkPostBackendMockAPITestCase): self.assertEqual(len(data["content"]["inline_images"]), 1) self.assertEqual(data["content"]["inline_images"][0]["type"], "image/png") self.assertEqual(data["content"]["inline_images"][0]["name"], cid) - self.assertEqual(decode_att(data["content"]["inline_images"][0]["data"]), image_data) + self.assertEqual( + decode_att(data["content"]["inline_images"][0]["data"]), image_data + ) # Make sure neither the html nor the inline image is treated as an attachment: self.assertNotIn("attachments", data["content"]) @@ -250,9 +323,11 @@ class SparkPostBackendStandardEmailTests(SparkPostBackendMockAPITestCase): image_path = sample_image_path(image_filename) image_data = sample_image_content(image_filename) - self.message.attach_file(image_path) # option 1: attach as a file + # option 1: attach as a file + self.message.attach_file(image_path) - image = MIMEImage(image_data) # option 2: construct the MIMEImage and attach it directly + # option 2: construct the MIMEImage and attach it directly + image = MIMEImage(image_data) self.message.attach(image) self.message.send() @@ -305,18 +380,23 @@ class SparkPostBackendStandardEmailTests(SparkPostBackendMockAPITestCase): self.assertNotIn("reply_to", data["content"]) def test_empty_to(self): - # Test empty `to` -- but send requires at least one recipient somewhere (like cc) + # Test empty `to`--but send requires at least one recipient somewhere (like cc) self.message.to = [] self.message.cc = ["cc@example.com"] self.message.send() data = self.get_api_call_json() - self.assertEqual(data["recipients"], [{ - "address": { - "email": "cc@example.com", - # This results in a message with an empty To header, as desired: - "header_to": "", - }, - }]) + self.assertEqual( + data["recipients"], + [ + { + "address": { + "email": "cc@example.com", + # This results in a message with an empty To header, as desired: + "header_to": "", + }, + } + ], + ) def test_api_failure(self): self.set_mock_response(status_code=400) @@ -337,11 +417,13 @@ class SparkPostBackendStandardEmailTests(SparkPostBackendMockAPITestCase): }] }""" self.set_mock_response(status_code=400, raw=failure_response) - with self.assertRaisesMessage(AnymailAPIError, "Helpful explanation from your ESP"): + with self.assertRaisesMessage( + AnymailAPIError, "Helpful explanation from your ESP" + ): self.message.send() -@tag('sparkpost') +@tag("sparkpost") class SparkPostBackendAnymailFeatureTests(SparkPostBackendMockAPITestCase): """Test backend support for Anymail added features""" @@ -352,10 +434,10 @@ class SparkPostBackendAnymailFeatureTests(SparkPostBackendMockAPITestCase): self.assertEqual(data["return_path"], "bounce-handler@bounces.example.com") def test_metadata(self): - self.message.metadata = {'user_id': "12345", 'items': 'spark, post'} + self.message.metadata = {"user_id": "12345", "items": "spark, post"} self.message.send() data = self.get_api_call_json() - self.assertEqual(data["metadata"], {'user_id': "12345", 'items': 'spark, post'}) + self.assertEqual(data["metadata"], {"user_id": "12345", "items": "spark, post"}) def test_send_at(self): utc_plus_6 = get_fixed_timezone(6 * 60) @@ -407,7 +489,7 @@ class SparkPostBackendAnymailFeatureTests(SparkPostBackendMockAPITestCase): self.assertEqual(data["campaign_id"], "receipt") self.message.tags = ["receipt", "repeat-user"] - with self.assertRaisesMessage(AnymailUnsupportedFeature, 'multiple tags'): + with self.assertRaisesMessage(AnymailUnsupportedFeature, "multiple tags"): self.message.send() def test_tracking(self): @@ -428,7 +510,9 @@ class SparkPostBackendAnymailFeatureTests(SparkPostBackendMockAPITestCase): self.assertEqual(data["options"]["click_tracking"], True) def test_template_id(self): - message = mail.EmailMultiAlternatives(from_email='from@example.com', to=['to@example.com']) + message = mail.EmailMultiAlternatives( + from_email="from@example.com", to=["to@example.com"] + ) message.template_id = "welcome_template" message.send() data = self.get_api_call_json() @@ -440,51 +524,85 @@ class SparkPostBackendAnymailFeatureTests(SparkPostBackendMockAPITestCase): def test_merge_data(self): self.set_mock_result(accepted=4) # two 'to' plus one 'cc' for each 'to' - self.message.to = ['alice@example.com', 'Bob '] - self.message.cc = ['cc@example.com'] + self.message.to = ["alice@example.com", "Bob "] + self.message.cc = ["cc@example.com"] self.message.body = "Hi {{address.name}}. Welcome to {{group}} at {{site}}." self.message.merge_data = { - 'alice@example.com': {'name': "Alice", 'group': "Developers"}, - 'bob@example.com': {'name': "Bob"}, # and leave group undefined - 'nobody@example.com': {'name': "Not a recipient for this message"}, + "alice@example.com": {"name": "Alice", "group": "Developers"}, + "bob@example.com": {"name": "Bob"}, # and leave group undefined + "nobody@example.com": {"name": "Not a recipient for this message"}, } - self.message.merge_global_data = {'group': "Users", 'site': "ExampleCo"} + self.message.merge_global_data = {"group": "Users", "site": "ExampleCo"} self.message.send() data = self.get_api_call_json() - self.assertEqual({"group": "Users", "site": "ExampleCo"}, data["substitution_data"]) - self.assertEqual([{ - "address": {"email": "alice@example.com", "header_to": "alice@example.com"}, - "substitution_data": {"name": "Alice", "group": "Developers"}, - }, { - "address": {"email": "bob@example.com", "header_to": "Bob "}, - "substitution_data": {"name": "Bob"}, - }, { # duplicated for cc recipients... - "address": {"email": "cc@example.com", "header_to": "alice@example.com"}, - "substitution_data": {"name": "Alice", "group": "Developers"}, - }, { - "address": {"email": "cc@example.com", "header_to": "Bob "}, - "substitution_data": {"name": "Bob"}, - }], data["recipients"]) + self.assertEqual( + {"group": "Users", "site": "ExampleCo"}, data["substitution_data"] + ) + self.assertEqual( + [ + { + "address": { + "email": "alice@example.com", + "header_to": "alice@example.com", + }, + "substitution_data": {"name": "Alice", "group": "Developers"}, + }, + { + "address": { + "email": "bob@example.com", + "header_to": "Bob ", + }, + "substitution_data": {"name": "Bob"}, + }, + { # duplicated for cc recipients... + "address": { + "email": "cc@example.com", + "header_to": "alice@example.com", + }, + "substitution_data": {"name": "Alice", "group": "Developers"}, + }, + { + "address": { + "email": "cc@example.com", + "header_to": "Bob ", + }, + "substitution_data": {"name": "Bob"}, + }, + ], + data["recipients"], + ) def test_merge_metadata(self): self.set_mock_result(accepted=2) - self.message.to = ['alice@example.com', 'Bob '] + self.message.to = ["alice@example.com", "Bob "] self.message.merge_metadata = { - 'alice@example.com': {'order_id': 123}, - 'bob@example.com': {'order_id': 678, 'tier': 'premium'}, + "alice@example.com": {"order_id": 123}, + "bob@example.com": {"order_id": 678, "tier": "premium"}, } - self.message.metadata = {'notification_batch': 'zx912'} + self.message.metadata = {"notification_batch": "zx912"} self.message.send() data = self.get_api_call_json() - self.assertEqual([{ - "address": {"email": "alice@example.com", "header_to": "alice@example.com"}, - "metadata": {"order_id": 123}, - }, { - "address": {"email": "bob@example.com", "header_to": "Bob "}, - "metadata": {"order_id": 678, "tier": "premium"} - }], data["recipients"]) + self.assertEqual( + [ + { + "address": { + "email": "alice@example.com", + "header_to": "alice@example.com", + }, + "metadata": {"order_id": 123}, + }, + { + "address": { + "email": "bob@example.com", + "header_to": "Bob ", + }, + "metadata": {"order_id": 678, "tier": "premium"}, + }, + ], + data["recipients"], + ) self.assertEqual(data["metadata"], {"notification_batch": "zx912"}) def test_default_omits_options(self): @@ -498,7 +616,8 @@ class SparkPostBackendAnymailFeatureTests(SparkPostBackendMockAPITestCase): data = self.get_api_call_json() self.assertNotIn("campaign_id", data) self.assertNotIn("metadata", data) - self.assertNotIn("options", data) # covers start_time, click_tracking, open_tracking + # covers start_time, click_tracking, open_tracking: + self.assertNotIn("options", data) self.assertNotIn("substitution_data", data) self.assertNotIn("template_id", data["content"]) @@ -517,64 +636,108 @@ class SparkPostBackendAnymailFeatureTests(SparkPostBackendMockAPITestCase): self.message.send() data = self.get_api_call_json() self.assertEqual(data["description"], "The description") - self.assertEqual(data["options"], { - "transactional": True, - "click_tracking": True, # deep merge - }) - self.assertDictMatches({ - "use_draft_template": True, - "ab_test_id": "highlight_support_links", - "text": "Text Body", # deep merge - "subject": "Subject", # deep merge - }, data["content"]) + self.assertEqual( + data["options"], + { + "transactional": True, + "click_tracking": True, # deep merge + }, + ) + self.assertDictMatches( + { + "use_draft_template": True, + "ab_test_id": "highlight_support_links", + "text": "Text Body", # deep merge + "subject": "Subject", # deep merge + }, + data["content"], + ) def test_send_attaches_anymail_status(self): - """The anymail_status should be attached to the message when it is sent """ + """The anymail_status should be attached to the message when it is sent""" response_content = self.set_mock_result(accepted=1, rejected=0, id="9876543210") - msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', ['to1@example.com'],) + msg = mail.EmailMessage( + "Subject", + "Message", + "from@example.com", + ["to1@example.com"], + ) sent = msg.send() self.assertEqual(sent, 1) - self.assertEqual(msg.anymail_status.status, {'queued'}) - self.assertEqual(msg.anymail_status.message_id, '9876543210') - self.assertEqual(msg.anymail_status.recipients['to1@example.com'].status, 'queued') - self.assertEqual(msg.anymail_status.recipients['to1@example.com'].message_id, '9876543210') + self.assertEqual(msg.anymail_status.status, {"queued"}) + self.assertEqual(msg.anymail_status.message_id, "9876543210") + self.assertEqual( + msg.anymail_status.recipients["to1@example.com"].status, "queued" + ) + self.assertEqual( + msg.anymail_status.recipients["to1@example.com"].message_id, "9876543210" + ) self.assertEqual(msg.anymail_status.esp_response.content, response_content) - @override_settings(ANYMAIL_IGNORE_RECIPIENT_STATUS=True) # exception is tested later + @override_settings( + ANYMAIL_IGNORE_RECIPIENT_STATUS=True # exception is tested later + ) def test_send_all_rejected(self): """The anymail_status should be 'rejected' when all recipients rejected""" self.set_mock_result(accepted=0, rejected=2) - msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', - ['to1@example.com', 'to2@example.com'],) + msg = mail.EmailMessage( + "Subject", + "Message", + "from@example.com", + ["to1@example.com", "to2@example.com"], + ) msg.send() - self.assertEqual(msg.anymail_status.status, {'rejected'}) - self.assertEqual(msg.anymail_status.recipients['to1@example.com'].status, 'rejected') - self.assertEqual(msg.anymail_status.recipients['to2@example.com'].status, 'rejected') + self.assertEqual(msg.anymail_status.status, {"rejected"}) + self.assertEqual( + msg.anymail_status.recipients["to1@example.com"].status, "rejected" + ) + self.assertEqual( + msg.anymail_status.recipients["to2@example.com"].status, "rejected" + ) def test_send_some_rejected(self): - """The anymail_status should be 'unknown' when some recipients accepted and some rejected""" + """ + The anymail_status should be 'unknown' + when some recipients accepted and some rejected + """ self.set_mock_result(accepted=1, rejected=1) - msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', - ['to1@example.com', 'to2@example.com'],) + msg = mail.EmailMessage( + "Subject", + "Message", + "from@example.com", + ["to1@example.com", "to2@example.com"], + ) msg.send() - self.assertEqual(msg.anymail_status.status, {'unknown'}) - self.assertEqual(msg.anymail_status.recipients['to1@example.com'].status, 'unknown') - self.assertEqual(msg.anymail_status.recipients['to2@example.com'].status, 'unknown') + self.assertEqual(msg.anymail_status.status, {"unknown"}) + self.assertEqual( + msg.anymail_status.recipients["to1@example.com"].status, "unknown" + ) + self.assertEqual( + msg.anymail_status.recipients["to2@example.com"].status, "unknown" + ) def test_send_unexpected_count(self): """The anymail_status should be 'unknown' when the total result count - doesn't match the number of recipients""" + doesn't match the number of recipients""" self.set_mock_result(accepted=3, rejected=0) # but only 2 in the to-list - msg = mail.EmailMessage('Subject', 'Message', 'from@example.com', - ['to1@example.com', 'to2@example.com'],) + msg = mail.EmailMessage( + "Subject", + "Message", + "from@example.com", + ["to1@example.com", "to2@example.com"], + ) msg.send() - self.assertEqual(msg.anymail_status.status, {'unknown'}) - self.assertEqual(msg.anymail_status.recipients['to1@example.com'].status, 'unknown') - self.assertEqual(msg.anymail_status.recipients['to2@example.com'].status, 'unknown') + self.assertEqual(msg.anymail_status.status, {"unknown"}) + self.assertEqual( + msg.anymail_status.recipients["to1@example.com"].status, "unknown" + ) + self.assertEqual( + msg.anymail_status.recipients["to2@example.com"].status, "unknown" + ) # noinspection PyUnresolvedReferences def test_send_failed_anymail_status(self): - """ If the send fails, anymail_status should contain initial values""" + """If the send fails, anymail_status should contain initial values""" self.set_mock_response(status_code=400) sent = self.message.send(fail_silently=True) self.assertEqual(sent, 0) @@ -585,7 +748,10 @@ class SparkPostBackendAnymailFeatureTests(SparkPostBackendMockAPITestCase): # noinspection PyUnresolvedReferences def test_send_unparsable_response(self): - """If the send succeeds, but result is unexpected format, should raise an API exception""" + """ + If the send succeeds, but result is unexpected format, + should raise an API exception + """ response_content = b"""{"wrong": "format"}""" self.set_mock_response(raw=response_content) with self.assertRaises(AnymailAPIError): @@ -593,99 +759,142 @@ class SparkPostBackendAnymailFeatureTests(SparkPostBackendMockAPITestCase): self.assertIsNone(self.message.anymail_status.status) self.assertIsNone(self.message.anymail_status.message_id) self.assertEqual(self.message.anymail_status.recipients, {}) - self.assertEqual(self.message.anymail_status.esp_response.content, response_content) + self.assertEqual( + self.message.anymail_status.esp_response.content, response_content + ) def test_json_serialization_errors(self): """Try to provide more information about non-json-serializable data""" - self.message.tags = [Decimal('19.99')] # yeah, don't do this + self.message.tags = [Decimal("19.99")] # yeah, don't do this with self.assertRaises(AnymailSerializationError) as cm: self.message.send() print(self.get_api_call_json()) err = cm.exception self.assertIsInstance(err, TypeError) # compatibility with json.dumps - self.assertIn("Don't know how to send this data to SparkPost", str(err)) # our added context - self.assertRegex(str(err), r"Decimal.*is not JSON serializable") # original message + # our added context: + self.assertIn("Don't know how to send this data to SparkPost", str(err)) + # original message: + self.assertRegex(str(err), r"Decimal.*is not JSON serializable") -@tag('sparkpost') +@tag("sparkpost") class SparkPostBackendRecipientsRefusedTests(SparkPostBackendMockAPITestCase): - """Should raise AnymailRecipientsRefused when *all* recipients are rejected or invalid""" + """ + Should raise AnymailRecipientsRefused when *all* recipients are rejected or invalid + """ def test_recipients_refused(self): self.set_mock_result(accepted=0, rejected=2) - msg = mail.EmailMessage('Subject', 'Body', 'from@example.com', - ['invalid@localhost', 'reject@example.com']) + msg = mail.EmailMessage( + "Subject", + "Body", + "from@example.com", + ["invalid@localhost", "reject@example.com"], + ) with self.assertRaises(AnymailRecipientsRefused): msg.send() def test_fail_silently(self): self.set_mock_result(accepted=0, rejected=2) - sent = mail.send_mail('Subject', 'Body', 'from@example.com', - ['invalid@localhost', 'reject@example.com'], - fail_silently=True) + sent = mail.send_mail( + "Subject", + "Body", + "from@example.com", + ["invalid@localhost", "reject@example.com"], + fail_silently=True, + ) self.assertEqual(sent, 0) def test_mixed_response(self): """If *any* recipients are valid or queued, no exception is raised""" self.set_mock_result(accepted=2, rejected=2) - msg = mail.EmailMessage('Subject', 'Body', 'from@example.com', - ['invalid@localhost', 'valid@example.com', - 'reject@example.com', 'also.valid@example.com']) + msg = mail.EmailMessage( + "Subject", + "Body", + "from@example.com", + [ + "invalid@localhost", + "valid@example.com", + "reject@example.com", + "also.valid@example.com", + ], + ) sent = msg.send() - self.assertEqual(sent, 1) # one message sent, successfully, to 2 of 4 recipients + # one message sent, successfully, to 2 of 4 recipients: + self.assertEqual(sent, 1) status = msg.anymail_status # We don't know which recipients were rejected - self.assertEqual(status.recipients['invalid@localhost'].status, 'unknown') - self.assertEqual(status.recipients['valid@example.com'].status, 'unknown') - self.assertEqual(status.recipients['reject@example.com'].status, 'unknown') - self.assertEqual(status.recipients['also.valid@example.com'].status, 'unknown') + self.assertEqual(status.recipients["invalid@localhost"].status, "unknown") + self.assertEqual(status.recipients["valid@example.com"].status, "unknown") + self.assertEqual(status.recipients["reject@example.com"].status, "unknown") + self.assertEqual(status.recipients["also.valid@example.com"].status, "unknown") @override_settings(ANYMAIL_IGNORE_RECIPIENT_STATUS=True) def test_settings_override(self): """No exception with ignore setting""" self.set_mock_result(accepted=0, rejected=2) - sent = mail.send_mail('Subject', 'Body', 'from@example.com', - ['invalid@localhost', 'reject@example.com']) + sent = mail.send_mail( + "Subject", + "Body", + "from@example.com", + ["invalid@localhost", "reject@example.com"], + ) self.assertEqual(sent, 1) # refused message is included in sent count -@tag('sparkpost') +@tag("sparkpost") class SparkPostBackendConfigurationTests(SparkPostBackendMockAPITestCase): """Test various SparkPost client options""" - @override_settings(ANYMAIL={}) # clear SPARKPOST_API_KEY from SparkPostBackendMockAPITestCase + @override_settings( + # clear SPARKPOST_API_KEY from SparkPostBackendMockAPITestCase: + ANYMAIL={} + ) def test_missing_api_key(self): with self.assertRaises(AnymailConfigurationError) as cm: - mail.send_mail('Subject', 'Message', 'from@example.com', ['to@example.com']) + mail.send_mail("Subject", "Message", "from@example.com", ["to@example.com"]) errmsg = str(cm.exception) # Make sure the error mentions the different places to set the key - self.assertRegex(errmsg, r'\bSPARKPOST_API_KEY\b') - self.assertRegex(errmsg, r'\bANYMAIL_SPARKPOST_API_KEY\b') + self.assertRegex(errmsg, r"\bSPARKPOST_API_KEY\b") + self.assertRegex(errmsg, r"\bANYMAIL_SPARKPOST_API_KEY\b") - @override_settings(ANYMAIL={ - "SPARKPOST_API_URL": "https://api.eu.sparkpost.com/api/v1", - "SPARKPOST_API_KEY": "test_api_key", - }) + @override_settings( + ANYMAIL={ + "SPARKPOST_API_URL": "https://api.eu.sparkpost.com/api/v1", + "SPARKPOST_API_KEY": "test_api_key", + } + ) def test_sparkpost_api_url(self): - mail.send_mail('Subject', 'Message', 'from@example.com', ['to@example.com']) + mail.send_mail("Subject", "Message", "from@example.com", ["to@example.com"]) self.assert_esp_called("https://api.eu.sparkpost.com/api/v1/transmissions/") - # can also override on individual connection (and even use non-versioned labs endpoint) + # can also override on individual connection + # (and even use non-versioned labs endpoint) connection = mail.get_connection(api_url="https://api.sparkpost.com/api/labs") - mail.send_mail('Subject', 'Message', 'from@example.com', ['to@example.com'], - connection=connection) + mail.send_mail( + "Subject", + "Message", + "from@example.com", + ["to@example.com"], + connection=connection, + ) self.assert_esp_called("https://api.sparkpost.com/api/labs/transmissions/") def test_subaccount(self): # A likely use case is supplying subaccount for a particular message. # (For all messages, just set SPARKPOST_SUBACCOUNT in ANYMAIL settings.) connection = mail.get_connection(subaccount=123) - mail.send_mail('Subject', 'Message', 'from@example.com', ['to@example.com'], - connection=connection) + mail.send_mail( + "Subject", + "Message", + "from@example.com", + ["to@example.com"], + connection=connection, + ) headers = self.get_api_call_headers() self.assertEqual(headers["X-MSYS-SUBACCOUNT"], 123) # Make sure we're not setting the header on non-subaccount sends - mail.send_mail('Subject', 'Message', 'from@example.com', ['to@example.com']) + mail.send_mail("Subject", "Message", "from@example.com", ["to@example.com"]) headers = self.get_api_call_headers() self.assertNotIn("X-MSYS-SUBACCOUNT", headers) diff --git a/tests/test_sparkpost_inbound.py b/tests/test_sparkpost_inbound.py index 5ca17ac..1713d6f 100644 --- a/tests/test_sparkpost_inbound.py +++ b/tests/test_sparkpost_inbound.py @@ -9,21 +9,23 @@ from anymail.inbound import AnymailInboundMessage from anymail.signals import AnymailInboundEvent from anymail.webhooks.sparkpost import SparkPostInboundWebhookView -from .utils import sample_image_content, sample_email_content +from .utils import sample_email_content, sample_image_content from .webhook_cases import WebhookTestCase -@tag('sparkpost') +@tag("sparkpost") class SparkpostInboundTestCase(WebhookTestCase): def test_inbound_basics(self): event = { - 'protocol': "smtp", - 'rcpt_to': "test@inbound.example.com", - 'msg_from': "envelope-from@example.org", - 'content': { - # Anymail just parses the raw rfc822 email. SparkPost's other content fields are ignored. - 'email_rfc822_is_base64': False, - 'email_rfc822': dedent("""\ + "protocol": "smtp", + "rcpt_to": "test@inbound.example.com", + "msg_from": "envelope-from@example.org", + "content": { + # Anymail just parses the raw rfc822 email. + # SparkPost's other content fields are ignored. + "email_rfc822_is_base64": False, + "email_rfc822": dedent( + """\ Received: from mail.example.org by c.mta1vsmtp.cc.prd.sparkpost ... Received: by mail.example.org for ... DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.org; ... @@ -50,20 +52,28 @@ class SparkpostInboundTestCase(WebhookTestCase):
It's a body=E2=80=A6
--94eb2c05e174adb140055b6339c5-- - """), + """ # NOQA: E501 + ), }, } - raw_event = {'msys': {'relay_message': event}} + raw_event = {"msys": {"relay_message": event}} - response = self.client.post('/anymail/sparkpost/inbound/', - content_type='application/json', data=json.dumps([raw_event])) + response = self.client.post( + "/anymail/sparkpost/inbound/", + content_type="application/json", + data=json.dumps([raw_event]), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.inbound_handler, sender=SparkPostInboundWebhookView, - event=ANY, esp_name='SparkPost') + kwargs = self.assert_handler_called_once_with( + self.inbound_handler, + sender=SparkPostInboundWebhookView, + event=ANY, + esp_name="SparkPost", + ) # AnymailInboundEvent - event = kwargs['event'] + event = kwargs["event"] self.assertIsInstance(event, AnymailInboundEvent) - self.assertEqual(event.event_type, 'inbound') + self.assertEqual(event.event_type, "inbound") self.assertIsNone(event.timestamp) self.assertIsNone(event.event_id) self.assertIsInstance(event.message, AnymailInboundMessage) @@ -72,36 +82,44 @@ class SparkpostInboundTestCase(WebhookTestCase): # AnymailInboundMessage - convenience properties message = event.message - self.assertEqual(message.from_email.display_name, 'Displayed From') - self.assertEqual(message.from_email.addr_spec, 'from+test@example.org') - self.assertEqual([str(e) for e in message.to], - ['Test Inbound ', 'other@example.com']) - self.assertEqual([str(e) for e in message.cc], - ['cc@example.com']) - self.assertEqual(message.subject, 'Test subject') + self.assertEqual(message.from_email.display_name, "Displayed From") + self.assertEqual(message.from_email.addr_spec, "from+test@example.org") + self.assertEqual( + [str(e) for e in message.to], + ["Test Inbound ", "other@example.com"], + ) + self.assertEqual([str(e) for e in message.cc], ["cc@example.com"]) + self.assertEqual(message.subject, "Test subject") self.assertEqual(message.date.isoformat(" "), "2017-10-11 18:31:04-07:00") self.assertEqual(message.text, "It's a body\N{HORIZONTAL ELLIPSIS}\n") - self.assertEqual(message.html, """
It's a body\N{HORIZONTAL ELLIPSIS}
\n""") + self.assertEqual( + message.html, + """
It's a body\N{HORIZONTAL ELLIPSIS}
\n""", + ) - self.assertEqual(message.envelope_sender, 'envelope-from@example.org') - self.assertEqual(message.envelope_recipient, 'test@inbound.example.com') + self.assertEqual(message.envelope_sender, "envelope-from@example.org") + self.assertEqual(message.envelope_recipient, "test@inbound.example.com") self.assertIsNone(message.stripped_text) self.assertIsNone(message.stripped_html) self.assertIsNone(message.spam_detected) self.assertIsNone(message.spam_score) # AnymailInboundMessage - other headers - self.assertEqual(message['Message-ID'], "") - self.assertEqual(message.get_all('Received'), [ - "from mail.example.org by c.mta1vsmtp.cc.prd.sparkpost ...", - "by mail.example.org for ...", - "by 10.10.1.71 with HTTP; Wed, 11 Oct 2017 18:31:04 -0700 (PDT)", - ]) + self.assertEqual(message["Message-ID"], "") + self.assertEqual( + message.get_all("Received"), + [ + "from mail.example.org by c.mta1vsmtp.cc.prd.sparkpost ...", + "by mail.example.org for ...", + "by 10.10.1.71 with HTTP; Wed, 11 Oct 2017 18:31:04 -0700 (PDT)", + ], + ) def test_attachments(self): image_content = sample_image_content() email_content = sample_email_content() - raw_mime = dedent("""\ + raw_mime = dedent( + """\ MIME-Version: 1.0 From: from@example.org Subject: Attachments @@ -136,42 +154,60 @@ class SparkpostInboundTestCase(WebhookTestCase): {email_content} --boundary0-- - """).format(image_content_base64=b64encode(image_content).decode('ascii'), - email_content=email_content.decode('ascii')) + """ # NOQA: E501 + ).format( + image_content_base64=b64encode(image_content).decode("ascii"), + email_content=email_content.decode("ascii"), + ) - raw_event = {'msys': {'relay_message': { - 'protocol': "smtp", - 'content': { - 'email_rfc822_is_base64': True, - 'email_rfc822': b64encode(raw_mime.encode('utf-8')).decode('ascii'), - }, - }}} + raw_event = { + "msys": { + "relay_message": { + "protocol": "smtp", + "content": { + "email_rfc822_is_base64": True, + "email_rfc822": b64encode(raw_mime.encode("utf-8")).decode( + "ascii" + ), + }, + } + } + } - response = self.client.post('/anymail/sparkpost/inbound/', - content_type='application/json', data=json.dumps([raw_event])) + response = self.client.post( + "/anymail/sparkpost/inbound/", + content_type="application/json", + data=json.dumps([raw_event]), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.inbound_handler, sender=SparkPostInboundWebhookView, - event=ANY, esp_name='SparkPost') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.inbound_handler, + sender=SparkPostInboundWebhookView, + event=ANY, + esp_name="SparkPost", + ) + event = kwargs["event"] message = event.message attachments = message.attachments # AnymailInboundMessage convenience accessor self.assertEqual(len(attachments), 2) - self.assertEqual(attachments[0].get_filename(), 'test.txt') - self.assertEqual(attachments[0].get_content_type(), 'text/plain') - self.assertEqual(attachments[0].get_content_text(), 'test attachment') - self.assertEqual(attachments[1].get_content_type(), 'message/rfc822') - self.assertEqualIgnoringHeaderFolding(attachments[1].get_content_bytes(), email_content) + self.assertEqual(attachments[0].get_filename(), "test.txt") + self.assertEqual(attachments[0].get_content_type(), "text/plain") + self.assertEqual(attachments[0].get_content_text(), "test attachment") + self.assertEqual(attachments[1].get_content_type(), "message/rfc822") + self.assertEqualIgnoringHeaderFolding( + attachments[1].get_content_bytes(), email_content + ) # the message attachment (its payload) is fully parsed # (see the original in test_files/sample_email.txt) att_message = attachments[1].get_payload(0) self.assertEqual(att_message.get_content_type(), "multipart/alternative") - self.assertEqual(att_message['Subject'], "Test email") + self.assertEqual(att_message["Subject"], "Test email") self.assertEqual(att_message.text, "Hi Bob, This is a message. Thanks!\n") inlines = message.inline_attachments self.assertEqual(len(inlines), 1) - inline = inlines['abc123'] - self.assertEqual(inline.get_filename(), 'image.png') - self.assertEqual(inline.get_content_type(), 'image/png') + inline = inlines["abc123"] + self.assertEqual(inline.get_filename(), "image.png") + self.assertEqual(inline.get_content_type(), "image/png") self.assertEqual(inline.get_content_bytes(), image_content) diff --git a/tests/test_sparkpost_integration.py b/tests/test_sparkpost_integration.py index bdf8a8f..ba0edd9 100644 --- a/tests/test_sparkpost_integration.py +++ b/tests/test_sparkpost_integration.py @@ -10,16 +10,20 @@ from anymail.message import AnymailMessage from .utils import AnymailTestMixin, sample_image_path -ANYMAIL_TEST_SPARKPOST_API_KEY = os.getenv('ANYMAIL_TEST_SPARKPOST_API_KEY') -ANYMAIL_TEST_SPARKPOST_DOMAIN = os.getenv('ANYMAIL_TEST_SPARKPOST_DOMAIN') +ANYMAIL_TEST_SPARKPOST_API_KEY = os.getenv("ANYMAIL_TEST_SPARKPOST_API_KEY") +ANYMAIL_TEST_SPARKPOST_DOMAIN = os.getenv("ANYMAIL_TEST_SPARKPOST_DOMAIN") -@tag('sparkpost', 'live') -@unittest.skipUnless(ANYMAIL_TEST_SPARKPOST_API_KEY and ANYMAIL_TEST_SPARKPOST_DOMAIN, - "Set ANYMAIL_TEST_SPARKPOST_API_KEY and ANYMAIL_TEST_SPARKPOST_DOMAIN " - "environment variables to run SparkPost integration tests") -@override_settings(ANYMAIL_SPARKPOST_API_KEY=ANYMAIL_TEST_SPARKPOST_API_KEY, - EMAIL_BACKEND="anymail.backends.sparkpost.EmailBackend") +@tag("sparkpost", "live") +@unittest.skipUnless( + ANYMAIL_TEST_SPARKPOST_API_KEY and ANYMAIL_TEST_SPARKPOST_DOMAIN, + "Set ANYMAIL_TEST_SPARKPOST_API_KEY and ANYMAIL_TEST_SPARKPOST_DOMAIN " + "environment variables to run SparkPost integration tests", +) +@override_settings( + ANYMAIL_SPARKPOST_API_KEY=ANYMAIL_TEST_SPARKPOST_API_KEY, + EMAIL_BACKEND="anymail.backends.sparkpost.EmailBackend", +) class SparkPostBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): """SparkPost API integration tests @@ -35,23 +39,32 @@ class SparkPostBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): def setUp(self): super().setUp() - self.from_email = 'test@%s' % ANYMAIL_TEST_SPARKPOST_DOMAIN - self.message = AnymailMessage('Anymail SparkPost integration test', 'Text content', - self.from_email, ['to@test.sink.sparkpostmail.com']) - self.message.attach_alternative('

HTML content

', "text/html") + self.from_email = "test@%s" % ANYMAIL_TEST_SPARKPOST_DOMAIN + self.message = AnymailMessage( + "Anymail SparkPost integration test", + "Text content", + self.from_email, + ["to@test.sink.sparkpostmail.com"], + ) + self.message.attach_alternative("

HTML content

", "text/html") def test_simple_send(self): - # Example of getting the SparkPost send status and transmission id from the message + # Example of getting the SparkPost send status + # and transmission id from the message sent_count = self.message.send() self.assertEqual(sent_count, 1) anymail_status = self.message.anymail_status - sent_status = anymail_status.recipients['to@test.sink.sparkpostmail.com'].status - message_id = anymail_status.recipients['to@test.sink.sparkpostmail.com'].message_id + sent_status = anymail_status.recipients["to@test.sink.sparkpostmail.com"].status + message_id = anymail_status.recipients[ + "to@test.sink.sparkpostmail.com" + ].message_id - self.assertEqual(sent_status, 'queued') # SparkPost always queues - self.assertRegex(message_id, r'.+') # this is actually the transmission_id; should be non-blank - self.assertEqual(anymail_status.status, {sent_status}) # set of all recipient statuses + self.assertEqual(sent_status, "queued") # SparkPost always queues + # this is actually the transmission_id; should be non-blank: + self.assertRegex(message_id, r".+") + # set of all recipient statuses: + self.assertEqual(anymail_status.status, {sent_status}) self.assertEqual(anymail_status.message_id, message_id) def test_all_options(self): @@ -60,14 +73,15 @@ class SparkPostBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): subject="Anymail all-options integration test", body="This is the text body", from_email=formataddr(("Test From, with comma", self.from_email)), - to=["to1@test.sink.sparkpostmail.com", "Recipient 2 "], - # Limit the live b/cc's to avoid running through our small monthly allowance: - # cc=["cc1@test.sink.sparkpostmail.com", "Copy 2 "], - # bcc=["bcc1@test.sink.sparkpostmail.com", "Blind Copy 2 "], + to=[ + "to1@test.sink.sparkpostmail.com", + "Recipient 2 ", + ], + # Limit the live b/cc's to avoid running through our small monthly + # allowance: cc=["Copy To "], reply_to=["reply1@example.com", "Reply 2 "], headers={"X-Anymail-Test": "value"}, - metadata={"meta1": "simple string", "meta2": 2}, send_at=send_at, tags=["tag 1"], # SparkPost only supports single tags @@ -80,47 +94,57 @@ class SparkPostBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): message.attach_alternative( "

HTML: with link" "and image: " % cid, - "text/html") + "text/html", + ) message.send() - self.assertEqual(message.anymail_status.status, {'queued'}) # SparkPost always queues + # SparkPost always queues: + self.assertEqual(message.anymail_status.status, {"queued"}) def test_merge_data(self): message = AnymailMessage( subject="Anymail merge_data test: {{ value }}", body="This body includes merge data: {{ value }}\n" - "And global merge data: {{ global }}", + "And global merge data: {{ global }}", from_email=formataddr(("Test From", self.from_email)), - to=["to1@test.sink.sparkpostmail.com", "Recipient 2 "], + to=[ + "to1@test.sink.sparkpostmail.com", + "Recipient 2 ", + ], merge_data={ - 'to1@test.sink.sparkpostmail.com': {'value': 'one'}, - 'to2@test.sink.sparkpostmail.com': {'value': 'two'}, - }, - merge_global_data={ - 'global': 'global_value' + "to1@test.sink.sparkpostmail.com": {"value": "one"}, + "to2@test.sink.sparkpostmail.com": {"value": "two"}, }, + merge_global_data={"global": "global_value"}, ) message.send() recipient_status = message.anymail_status.recipients - self.assertEqual(recipient_status['to1@test.sink.sparkpostmail.com'].status, 'queued') - self.assertEqual(recipient_status['to2@test.sink.sparkpostmail.com'].status, 'queued') + self.assertEqual( + recipient_status["to1@test.sink.sparkpostmail.com"].status, "queued" + ) + self.assertEqual( + recipient_status["to2@test.sink.sparkpostmail.com"].status, "queued" + ) def test_stored_template(self): message = AnymailMessage( - template_id='test-template', # a real template in our SparkPost test account + # a real template in our SparkPost test account: + template_id="test-template", to=["to1@test.sink.sparkpostmail.com"], merge_data={ - 'to1@test.sink.sparkpostmail.com': { - 'name': "Test Recipient", + "to1@test.sink.sparkpostmail.com": { + "name": "Test Recipient", } }, merge_global_data={ - 'order': '12345', + "order": "12345", }, ) message.send() recipient_status = message.anymail_status.recipients - self.assertEqual(recipient_status['to1@test.sink.sparkpostmail.com'].status, 'queued') + self.assertEqual( + recipient_status["to1@test.sink.sparkpostmail.com"].status, "queued" + ) @override_settings(ANYMAIL_SPARKPOST_API_KEY="Hey, that's not an API key!") def test_invalid_api_key(self): diff --git a/tests/test_sparkpost_webhooks.py b/tests/test_sparkpost_webhooks.py index 454990a..077eeae 100644 --- a/tests/test_sparkpost_webhooks.py +++ b/tests/test_sparkpost_webhooks.py @@ -10,92 +10,129 @@ from anymail.webhooks.sparkpost import SparkPostTrackingWebhookView from .webhook_cases import WebhookBasicAuthTestCase, WebhookTestCase -@tag('sparkpost') +@tag("sparkpost") class SparkPostWebhookSecurityTestCase(WebhookBasicAuthTestCase): def call_webhook(self): - return self.client.post('/anymail/sparkpost/tracking/', - content_type='application/json', data=json.dumps([])) + return self.client.post( + "/anymail/sparkpost/tracking/", + content_type="application/json", + data=json.dumps([]), + ) # Actual tests are in WebhookBasicAuthTestCase -@tag('sparkpost') +@tag("sparkpost") class SparkPostDeliveryTestCase(WebhookTestCase): - def test_ping_event(self): - raw_events = [{'msys': {}}] - response = self.client.post('/anymail/sparkpost/tracking/', - content_type='application/json', data=json.dumps(raw_events)) + raw_events = [{"msys": {}}] + response = self.client.post( + "/anymail/sparkpost/tracking/", + content_type="application/json", + data=json.dumps(raw_events), + ) self.assertEqual(response.status_code, 200) self.assertFalse(self.tracking_handler.called) # no real events def test_injection_event(self): - # Full event from SparkPost sample events API. (Later tests omit unused event fields.) - raw_events = [{"msys": {"message_event": { - "type": "injection", - "campaign_id": "Example Campaign Name", - "customer_id": "1", - "event_id": "92356927693813856", - "friendly_from": "sender@example.com", - "ip_pool": "Example-Ip-Pool", - "message_id": "000443ee14578172be22", - "msg_from": "sender@example.com", - "msg_size": "1337", - "rcpt_meta": {"customKey": "customValue"}, - "rcpt_tags": ["male", "US"], - "rcpt_to": "recipient@example.com", - "raw_rcpt_to": "recipient@example.com", - "rcpt_type": "cc", - "routing_domain": "example.com", - "sending_ip": "127.0.0.1", - "sms_coding": "ASCII", - "sms_dst": "7876712656", - "sms_dst_npi": "E164", - "sms_dst_ton": "International", - "sms_segments": 5, - "sms_src": "1234", - "sms_src_npi": "E164", - "sms_src_ton": "Unknown", - "sms_text": "lol", - "subaccount_id": "101", - "subject": "Summer deals are here!", - "template_id": "templ-1234", - "template_version": "1", - "timestamp": "1454442600", - "transmission_id": "65832150921904138" - }}}] - response = self.client.post('/anymail/sparkpost/tracking/', - content_type='application/json', data=json.dumps(raw_events)) + # Full event from SparkPost sample events API. + # (Later tests omit unused event fields.) + raw_events = [ + { + "msys": { + "message_event": { + "type": "injection", + "campaign_id": "Example Campaign Name", + "customer_id": "1", + "event_id": "92356927693813856", + "friendly_from": "sender@example.com", + "ip_pool": "Example-Ip-Pool", + "message_id": "000443ee14578172be22", + "msg_from": "sender@example.com", + "msg_size": "1337", + "rcpt_meta": {"customKey": "customValue"}, + "rcpt_tags": ["male", "US"], + "rcpt_to": "recipient@example.com", + "raw_rcpt_to": "recipient@example.com", + "rcpt_type": "cc", + "routing_domain": "example.com", + "sending_ip": "127.0.0.1", + "sms_coding": "ASCII", + "sms_dst": "7876712656", + "sms_dst_npi": "E164", + "sms_dst_ton": "International", + "sms_segments": 5, + "sms_src": "1234", + "sms_src_npi": "E164", + "sms_src_ton": "Unknown", + "sms_text": "lol", + "subaccount_id": "101", + "subject": "Summer deals are here!", + "template_id": "templ-1234", + "template_version": "1", + "timestamp": "1454442600", + "transmission_id": "65832150921904138", + } + } + } + ] + response = self.client.post( + "/anymail/sparkpost/tracking/", + content_type="application/json", + data=json.dumps(raw_events), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SparkPostTrackingWebhookView, - event=ANY, esp_name='SparkPost') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SparkPostTrackingWebhookView, + event=ANY, + esp_name="SparkPost", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "queued") - self.assertEqual(event.timestamp, datetime(2016, 2, 2, 19, 50, 00, tzinfo=timezone.utc)) + self.assertEqual( + event.timestamp, datetime(2016, 2, 2, 19, 50, 00, tzinfo=timezone.utc) + ) self.assertEqual(event.esp_event, raw_events[0]) - self.assertEqual(event.message_id, "65832150921904138") # actually transmission_id + # normalized "message_id" is actually transmission_id: + self.assertEqual(event.message_id, "65832150921904138") self.assertEqual(event.event_id, "92356927693813856") self.assertEqual(event.recipient, "recipient@example.com") - self.assertEqual(event.tags, ["Example Campaign Name"]) # campaign_id (rcpt_tags not available at send) - self.assertEqual(event.metadata, {"customKey": "customValue"}) # includes transmissions.send metadata + # campaign_id (rcpt_tags not available at send): + self.assertEqual(event.tags, ["Example Campaign Name"]) + # includes transmissions.send metadata: + self.assertEqual(event.metadata, {"customKey": "customValue"}) def test_delivery_event(self): - raw_events = [{"msys": {"message_event": { - "type": "delivery", - "event_id": "92356927693813856", - "rcpt_to": "recipient@example.com", - "raw_rcpt_to": "Recipient@example.com", - "rcpt_meta": {}, - "timestamp": "1454442600", - "transmission_id": "65832150921904138" - }}}] - response = self.client.post('/anymail/sparkpost/tracking/', - content_type='application/json', data=json.dumps(raw_events)) + raw_events = [ + { + "msys": { + "message_event": { + "type": "delivery", + "event_id": "92356927693813856", + "rcpt_to": "recipient@example.com", + "raw_rcpt_to": "Recipient@example.com", + "rcpt_meta": {}, + "timestamp": "1454442600", + "transmission_id": "65832150921904138", + } + } + } + ] + response = self.client.post( + "/anymail/sparkpost/tracking/", + content_type="application/json", + data=json.dumps(raw_events), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SparkPostTrackingWebhookView, - event=ANY, esp_name='SparkPost') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SparkPostTrackingWebhookView, + event=ANY, + esp_name="SparkPost", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "delivered") self.assertEqual(event.recipient, "Recipient@example.com") @@ -103,29 +140,41 @@ class SparkPostDeliveryTestCase(WebhookTestCase): self.assertEqual(event.metadata, {}) def test_bounce_event(self): - raw_events = [{ - "msys": {"message_event": { - "type": "bounce", - "bounce_class": "10", - "customer_id": "00000", - "error_code": "550", - "event_id": "84345317653491230", - "message_id": "0004e3724f57753a3561", - "raw_rcpt_to": "bounce@example.com", - "raw_reason": "550 5.1.1 : Recipient address rejected: User unknown", - "rcpt_to": "bounce@example.com", - "reason": "550 5.1.1 ...@... Recipient address rejected: ...", - "timestamp": "1464824548", - "transmission_id": "84345317650824116", - }}, - "cust": {"id": "00000"} # Included in real (non-example) event data - }] - response = self.client.post('/anymail/sparkpost/tracking/', - content_type='application/json', data=json.dumps(raw_events)) + raw_events = [ + { + "msys": { + "message_event": { + "type": "bounce", + "bounce_class": "10", + "customer_id": "00000", + "error_code": "550", + "event_id": "84345317653491230", + "message_id": "0004e3724f57753a3561", + "raw_rcpt_to": "bounce@example.com", + "raw_reason": "550 5.1.1 :" + " Recipient address rejected: User unknown", + "rcpt_to": "bounce@example.com", + "reason": "550 5.1.1 ...@... Recipient address rejected: ...", + "timestamp": "1464824548", + "transmission_id": "84345317650824116", + } + }, + "cust": {"id": "00000"}, # Included in real (non-example) event data + } + ] + response = self.client.post( + "/anymail/sparkpost/tracking/", + content_type="application/json", + data=json.dumps(raw_events), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SparkPostTrackingWebhookView, - event=ANY, esp_name='SparkPost') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SparkPostTrackingWebhookView, + event=ANY, + esp_name="SparkPost", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "bounced") self.assertEqual(event.esp_event, raw_events[0]) @@ -133,218 +182,389 @@ class SparkPostDeliveryTestCase(WebhookTestCase): self.assertEqual(event.event_id, "84345317653491230") self.assertEqual(event.recipient, "bounce@example.com") self.assertEqual(event.reject_reason, "invalid") - self.assertEqual(event.mta_response, - "550 5.1.1 : Recipient address rejected: User unknown") + self.assertEqual( + event.mta_response, + "550 5.1.1 : Recipient address rejected: User unknown", + ) def test_delay_event(self): - raw_events = [{"msys": {"message_event": { - "type": "delay", - "bounce_class": "21", - "error_code": "454", - "event_id": "84345317653675522", - "message_id": "0004e3724f57753a3861", - "num_retries": "1", - "queue_time": "1200161", - "raw_rcpt_to": "recipient@nomx.example.com", - "raw_reason": "454 4.4.4 [internal] no MX or A for domain", - "rcpt_to": "recipient@nomx.example.com", - "reason": "454 4.4.4 [internal] no MX or A for domain", - "timestamp": "1464825748", - "transmission_id": "84345317650824116", - }}}] - response = self.client.post('/anymail/sparkpost/tracking/', - content_type='application/json', data=json.dumps(raw_events)) + raw_events = [ + { + "msys": { + "message_event": { + "type": "delay", + "bounce_class": "21", + "error_code": "454", + "event_id": "84345317653675522", + "message_id": "0004e3724f57753a3861", + "num_retries": "1", + "queue_time": "1200161", + "raw_rcpt_to": "recipient@nomx.example.com", + "raw_reason": "454 4.4.4 [internal] no MX or A for domain", + "rcpt_to": "recipient@nomx.example.com", + "reason": "454 4.4.4 [internal] no MX or A for domain", + "timestamp": "1464825748", + "transmission_id": "84345317650824116", + } + } + } + ] + response = self.client.post( + "/anymail/sparkpost/tracking/", + content_type="application/json", + data=json.dumps(raw_events), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SparkPostTrackingWebhookView, - event=ANY, esp_name='SparkPost') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SparkPostTrackingWebhookView, + event=ANY, + esp_name="SparkPost", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "deferred") self.assertEqual(event.esp_event, raw_events[0]) self.assertEqual(event.recipient, "recipient@nomx.example.com") - self.assertEqual(event.mta_response, "454 4.4.4 [internal] no MX or A for domain") + self.assertEqual( + event.mta_response, "454 4.4.4 [internal] no MX or A for domain" + ) def test_unsubscribe_event(self): - raw_events = [{"msys": {"unsubscribe_event": { - "type": "list_unsubscribe", - "event_id": "66331590532986193", - "message_id": "0004278150574660124d", - "raw_rcpt_to": "recipient@example.com", - "rcpt_to": "recipient@example.com", - "timestamp": "1464894280", - "transmission_id": "84345993965073285", - }}}] - response = self.client.post('/anymail/sparkpost/tracking/', - content_type='application/json', data=json.dumps(raw_events)) + raw_events = [ + { + "msys": { + "unsubscribe_event": { + "type": "list_unsubscribe", + "event_id": "66331590532986193", + "message_id": "0004278150574660124d", + "raw_rcpt_to": "recipient@example.com", + "rcpt_to": "recipient@example.com", + "timestamp": "1464894280", + "transmission_id": "84345993965073285", + } + } + } + ] + response = self.client.post( + "/anymail/sparkpost/tracking/", + content_type="application/json", + data=json.dumps(raw_events), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SparkPostTrackingWebhookView, - event=ANY, esp_name='SparkPost') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SparkPostTrackingWebhookView, + event=ANY, + esp_name="SparkPost", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "unsubscribed") self.assertEqual(event.recipient, "recipient@example.com") def test_generation_rejection_event(self): # This is what you get if you try to send to a suppressed address - raw_events = [{"msys": {"gen_event": { - "type": "generation_rejection", - "error_code": "554", - "event_id": "102360394390563734", - "message_id": "0005c29950577c61695d", - "raw_rcpt_to": "suppressed@example.com", - "raw_reason": "554 5.7.1 recipient address suppressed due to customer policy", - "rcpt_to": "suppressed@example.com", - "reason": "554 5.7.1 recipient address suppressed due to customer policy", - "timestamp": "1464900034", - "transmission_id": "102360394387646691", - }}}] - response = self.client.post('/anymail/sparkpost/tracking/', - content_type='application/json', data=json.dumps(raw_events)) + raw_events = [ + { + "msys": { + "gen_event": { + "type": "generation_rejection", + "error_code": "554", + "event_id": "102360394390563734", + "message_id": "0005c29950577c61695d", + "raw_rcpt_to": "suppressed@example.com", + "raw_reason": "554 5.7.1 recipient address suppressed" + " due to customer policy", + "rcpt_to": "suppressed@example.com", + "reason": "554 5.7.1 recipient address suppressed" + " due to customer policy", + "timestamp": "1464900034", + "transmission_id": "102360394387646691", + } + } + } + ] + response = self.client.post( + "/anymail/sparkpost/tracking/", + content_type="application/json", + data=json.dumps(raw_events), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SparkPostTrackingWebhookView, - event=ANY, esp_name='SparkPost') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SparkPostTrackingWebhookView, + event=ANY, + esp_name="SparkPost", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "rejected") self.assertEqual(event.recipient, "suppressed@example.com") - self.assertEqual(event.mta_response, "554 5.7.1 recipient address suppressed due to customer policy") + self.assertEqual( + event.mta_response, + "554 5.7.1 recipient address suppressed due to customer policy", + ) def test_generation_failure_event(self): # This is what you get from a template rendering failure - raw_events = [{"msys": {"message_event": { - "type": "generation_failure", - "error_code": "554", - "event_id": "139013368081587254", - "raw_rcpt_to": "recipient@example.com", - "raw_reason": "554 5.3.3 [internal] Error while rendering part html: ...", - "rcpt_subs": {"name": "Alice", "order_no": "12345"}, - "rcpt_to": "recipient@example.com", - "reason": "554 5.3.3 [internal] Error while rendering part html: ...", - "tdate": "2018-10-11T23:24:45.000Z", - "template_id": "test-template", - "template_version": "3", - "transmission_id": "139013368081177607", - "timestamp": "2018-10-11T23:24:45.000+00:00" - }}}] - response = self.client.post('/anymail/sparkpost/tracking/', - content_type='application/json', data=json.dumps(raw_events)) + raw_events = [ + { + "msys": { + "message_event": { + "type": "generation_failure", + "error_code": "554", + "event_id": "139013368081587254", + "raw_rcpt_to": "recipient@example.com", + "raw_reason": "554 5.3.3 [internal]" + " Error while rendering part html: ...", + "rcpt_subs": {"name": "Alice", "order_no": "12345"}, + "rcpt_to": "recipient@example.com", + "reason": "554 5.3.3 [internal]" + " Error while rendering part html: ...", + "tdate": "2018-10-11T23:24:45.000Z", + "template_id": "test-template", + "template_version": "3", + "transmission_id": "139013368081177607", + "timestamp": "2018-10-11T23:24:45.000+00:00", + } + } + } + ] + response = self.client.post( + "/anymail/sparkpost/tracking/", + content_type="application/json", + data=json.dumps(raw_events), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SparkPostTrackingWebhookView, - event=ANY, esp_name='SparkPost') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SparkPostTrackingWebhookView, + event=ANY, + esp_name="SparkPost", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "failed") self.assertEqual(event.recipient, "recipient@example.com") - self.assertEqual(event.mta_response, "554 5.3.3 [internal] Error while rendering part html: ...") + self.assertEqual( + event.mta_response, + "554 5.3.3 [internal] Error while rendering part html: ...", + ) def test_bounce_challenge_response(self): # Test for changing initial event_type based on bounce_class - raw_events = [{"msys": {"message_event": { - "type": "bounce", - "bounce_class": "60", - "raw_rcpt_to": "vacationing@example.com", - "rcpt_to": "vacationing@example.com", - }}}] - response = self.client.post('/anymail/sparkpost/tracking/', - content_type='application/json', data=json.dumps(raw_events)) + raw_events = [ + { + "msys": { + "message_event": { + "type": "bounce", + "bounce_class": "60", + "raw_rcpt_to": "vacationing@example.com", + "rcpt_to": "vacationing@example.com", + } + } + } + ] + response = self.client.post( + "/anymail/sparkpost/tracking/", + content_type="application/json", + data=json.dumps(raw_events), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SparkPostTrackingWebhookView, - event=ANY, esp_name='SparkPost') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SparkPostTrackingWebhookView, + event=ANY, + esp_name="SparkPost", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "autoresponded") self.assertEqual(event.reject_reason, "other") self.assertEqual(event.recipient, "vacationing@example.com") def test_open_event(self): - raw_events = [{"msys": {"track_event": { - "type": "open", - "raw_rcpt_to": "recipient@example.com", - "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36", - }}}] - response = self.client.post('/anymail/sparkpost/tracking/', - content_type='application/json', data=json.dumps(raw_events)) + raw_events = [ + { + "msys": { + "track_event": { + "type": "open", + "raw_rcpt_to": "recipient@example.com", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3)" + " AppleWebKit/537.36", + } + } + } + ] + response = self.client.post( + "/anymail/sparkpost/tracking/", + content_type="application/json", + data=json.dumps(raw_events), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SparkPostTrackingWebhookView, - event=ANY, esp_name='SparkPost') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SparkPostTrackingWebhookView, + event=ANY, + esp_name="SparkPost", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "opened") - self.assertEqual(event.user_agent, "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36") + self.assertEqual( + event.user_agent, + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36", + ) @override_settings(ANYMAIL_SPARKPOST_TRACK_INITIAL_OPEN_AS_OPENED=True) def test_initial_open_event_as_opened(self): - # Mapping SparkPost "initial_open" to Anymail normalized "opened" is opt-in via a setting, - # for backwards compatibility and to avoid reporting duplicate "opened" events when all - # SparkPost event types are enabled. - raw_events = [{"msys": {"track_event": { - "type": "initial_open", - "raw_rcpt_to": "recipient@example.com", - "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36", - }}}] - response = self.client.post('/anymail/sparkpost/tracking/', - content_type='application/json', data=json.dumps(raw_events)) + # Mapping SparkPost "initial_open" to Anymail normalized "opened" is opt-in + # via a setting, for backwards compatibility and to avoid reporting duplicate + # "opened" events when all SparkPost event types are enabled. + raw_events = [ + { + "msys": { + "track_event": { + "type": "initial_open", + "raw_rcpt_to": "recipient@example.com", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3)" + " AppleWebKit/537.36", + } + } + } + ] + response = self.client.post( + "/anymail/sparkpost/tracking/", + content_type="application/json", + data=json.dumps(raw_events), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SparkPostTrackingWebhookView, - event=ANY, esp_name='SparkPost') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SparkPostTrackingWebhookView, + event=ANY, + esp_name="SparkPost", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "opened") - self.assertEqual(event.user_agent, "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36") + self.assertEqual( + event.user_agent, + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36", + ) def test_initial_open_event_as_unknown(self): # By default, SparkPost "initial_open" is *not* mapped to Anymail "opened". - raw_events = [{"msys": {"track_event": { - "type": "initial_open", - "raw_rcpt_to": "recipient@example.com", - "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36", - }}}] - response = self.client.post('/anymail/sparkpost/tracking/', - content_type='application/json', data=json.dumps(raw_events)) + raw_events = [ + { + "msys": { + "track_event": { + "type": "initial_open", + "raw_rcpt_to": "recipient@example.com", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3)" + " AppleWebKit/537.36", + } + } + } + ] + response = self.client.post( + "/anymail/sparkpost/tracking/", + content_type="application/json", + data=json.dumps(raw_events), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SparkPostTrackingWebhookView, - event=ANY, esp_name='SparkPost') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SparkPostTrackingWebhookView, + event=ANY, + esp_name="SparkPost", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "unknown") # Here's how to get the raw SparkPost event type: - self.assertEqual(event.esp_event["msys"].get("track_event", {}).get("type"), "initial_open") + self.assertEqual( + event.esp_event["msys"].get("track_event", {}).get("type"), "initial_open" + ) # Note that other Anymail normalized event properties are still available: - self.assertEqual(event.user_agent, "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36") + self.assertEqual( + event.user_agent, + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36", + ) def test_click_event(self): - raw_events = [{"msys": {"track_event": { - "type": "amp_click", - "raw_rcpt_to": "recipient@example.com", - "target_link_name": "Example Link Name", - "target_link_url": "http://example.com", - "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36", - }}}] - response = self.client.post('/anymail/sparkpost/tracking/', - content_type='application/json', data=json.dumps(raw_events)) + raw_events = [ + { + "msys": { + "track_event": { + "type": "amp_click", + "raw_rcpt_to": "recipient@example.com", + "target_link_name": "Example Link Name", + "target_link_url": "http://example.com", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3)" + " AppleWebKit/537.36", + } + } + } + ] + response = self.client.post( + "/anymail/sparkpost/tracking/", + content_type="application/json", + data=json.dumps(raw_events), + ) self.assertEqual(response.status_code, 200) - kwargs = self.assert_handler_called_once_with(self.tracking_handler, sender=SparkPostTrackingWebhookView, - event=ANY, esp_name='SparkPost') - event = kwargs['event'] + kwargs = self.assert_handler_called_once_with( + self.tracking_handler, + sender=SparkPostTrackingWebhookView, + event=ANY, + esp_name="SparkPost", + ) + event = kwargs["event"] self.assertIsInstance(event, AnymailTrackingEvent) self.assertEqual(event.event_type, "clicked") self.assertEqual(event.recipient, "recipient@example.com") - self.assertEqual(event.user_agent, "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36") + self.assertEqual( + event.user_agent, + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36", + ) self.assertEqual(event.click_url, "http://example.com") def test_amp_events(self): - raw_events = [{"msys": {"track_event": { - "type": "amp_open", - }}}, {"msys": {"track_event": { - "type": "amp_initial_open", - }}}, {"msys": {"track_event": { - "type": "amp_click", - }}}] - response = self.client.post('/anymail/sparkpost/tracking/', - content_type='application/json', data=json.dumps(raw_events)) + raw_events = [ + { + "msys": { + "track_event": { + "type": "amp_open", + } + } + }, + { + "msys": { + "track_event": { + "type": "amp_initial_open", + } + } + }, + { + "msys": { + "track_event": { + "type": "amp_click", + } + } + }, + ] + response = self.client.post( + "/anymail/sparkpost/tracking/", + content_type="application/json", + data=json.dumps(raw_events), + ) self.assertEqual(response.status_code, 200) self.assertEqual(self.tracking_handler.call_count, 3) - events = [kwargs["event"] for (args, kwargs) in self.tracking_handler.call_args_list] + events = [ + kwargs["event"] for (args, kwargs) in self.tracking_handler.call_args_list + ] self.assertEqual(events[0].event_type, "opened") - self.assertEqual(events[1].event_type, "unknown") # amp_initial_open is mapped to "unknown" by default + # amp_initial_open is mapped to "unknown" by default: + self.assertEqual(events[1].event_type, "unknown") self.assertEqual(events[2].event_type, "clicked") diff --git a/tests/test_utils.py b/tests/test_utils.py index aabf9b4..a3e8a6f 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -7,18 +7,28 @@ from email.mime.image import MIMEImage from email.mime.text import MIMEText from django.http import QueryDict -from django.test import SimpleTestCase, RequestFactory, override_settings +from django.test import RequestFactory, SimpleTestCase, override_settings from django.utils.text import format_lazy from django.utils.translation import gettext_lazy from anymail.exceptions import AnymailInvalidAddress, _LazyError from anymail.utils import ( - parse_address_list, parse_single_address, EmailAddress, + UNSET, Attachment, - is_lazy, force_non_lazy, force_non_lazy_dict, force_non_lazy_list, - update_deep, UNSET, - get_request_uri, get_request_basic_auth, parse_rfc2822date, querydict_getfirst, - CaseInsensitiveCasePreservingDict) + CaseInsensitiveCasePreservingDict, + EmailAddress, + force_non_lazy, + force_non_lazy_dict, + force_non_lazy_list, + get_request_basic_auth, + get_request_uri, + is_lazy, + parse_address_list, + parse_rfc2822date, + parse_single_address, + querydict_getfirst, + update_deep, +) class ParseAddressListTests(SimpleTestCase): @@ -48,36 +58,46 @@ class ParseAddressListTests(SimpleTestCase): def test_obsolete_display_name(self): # you can get away without the quotes if there are no commas or parens # (but it's not recommended) - parsed_list = parse_address_list(['Display Name ']) + parsed_list = parse_address_list(["Display Name "]) self.assertEqual(len(parsed_list), 1) parsed = parsed_list[0] self.assertEqual(parsed.addr_spec, "test@example.com") self.assertEqual(parsed.display_name, "Display Name") - self.assertEqual(parsed.address, 'Display Name ') + self.assertEqual(parsed.address, "Display Name ") def test_unicode_display_name(self): - parsed_list = parse_address_list(['"Unicode \N{HEAVY BLACK HEART}" ']) + parsed_list = parse_address_list( + ['"Unicode \N{HEAVY BLACK HEART}" '] + ) self.assertEqual(len(parsed_list), 1) parsed = parsed_list[0] self.assertEqual(parsed.addr_spec, "test@example.com") self.assertEqual(parsed.display_name, "Unicode \N{HEAVY BLACK HEART}") - # formatted display-name automatically shifts to quoted-printable/base64 for non-ascii chars: - self.assertEqual(parsed.address, '=?utf-8?b?VW5pY29kZSDinaQ=?= ') + # formatted display-name automatically shifts + # to quoted-printable/base64 for non-ascii chars: + self.assertEqual( + parsed.address, "=?utf-8?b?VW5pY29kZSDinaQ=?= " + ) def test_invalid_display_name(self): - with self.assertRaisesMessage(AnymailInvalidAddress, "Invalid email address 'webmaster'"): - parse_address_list(['webmaster']) + with self.assertRaisesMessage( + AnymailInvalidAddress, "Invalid email address 'webmaster'" + ): + parse_address_list(["webmaster"]) - with self.assertRaisesMessage(AnymailInvalidAddress, "Maybe missing quotes around a display-name?"): + with self.assertRaisesMessage( + AnymailInvalidAddress, "Maybe missing quotes around a display-name?" + ): # this parses as multiple email addresses, because of the comma: - parse_address_list(['Display Name, Inc. ']) + parse_address_list(["Display Name, Inc. "]) def test_idn(self): parsed_list = parse_address_list(["idn@\N{ENVELOPE}.example.com"]) self.assertEqual(len(parsed_list), 1) parsed = parsed_list[0] self.assertEqual(parsed.addr_spec, "idn@\N{ENVELOPE}.example.com") - self.assertEqual(parsed.address, "idn@xn--4bi.example.com") # punycode-encoded domain + # punycode-encoded domain: + self.assertEqual(parsed.address, "idn@xn--4bi.example.com") self.assertEqual(parsed.username, "idn") self.assertEqual(parsed.domain, "\N{ENVELOPE}.example.com") @@ -88,23 +108,23 @@ class ParseAddressListTests(SimpleTestCase): def test_empty_address(self): with self.assertRaises(AnymailInvalidAddress): - parse_address_list(['']) + parse_address_list([""]) def test_whitespace_only_address(self): with self.assertRaises(AnymailInvalidAddress): - parse_address_list([' ']) + parse_address_list([" "]) def test_invalid_address(self): with self.assertRaises(AnymailInvalidAddress): - parse_address_list(['localonly']) + parse_address_list(["localonly"]) with self.assertRaises(AnymailInvalidAddress): - parse_address_list(['localonly@']) + parse_address_list(["localonly@"]) with self.assertRaises(AnymailInvalidAddress): - parse_address_list(['@domainonly']) + parse_address_list(["@domainonly"]) with self.assertRaises(AnymailInvalidAddress): - parse_address_list(['']) + parse_address_list([""]) with self.assertRaises(AnymailInvalidAddress): - parse_address_list(['<@domainonly>']) + parse_address_list(["<@domainonly>"]) def test_email_list(self): parsed_list = parse_address_list(["first@example.com", "second@example.com"]) @@ -126,11 +146,12 @@ class ParseAddressListTests(SimpleTestCase): # the bare "Display Name" below should *not* get merged with # the email in the second item with self.assertRaisesMessage(AnymailInvalidAddress, "Display Name"): - parse_address_list(['"Display Name"', '']) + parse_address_list(['"Display Name"', ""]) def test_invalid_with_unicode(self): - with self.assertRaisesMessage(AnymailInvalidAddress, - "Invalid email address '\N{ENVELOPE}'"): + with self.assertRaisesMessage( + AnymailInvalidAddress, "Invalid email address '\N{ENVELOPE}'" + ): parse_address_list(["\N{ENVELOPE}"]) def test_single_string(self): @@ -140,7 +161,9 @@ class ParseAddressListTests(SimpleTestCase): self.assertEqual(parsed_list[0].addr_spec, "one@example.com") def test_lazy_strings(self): - parsed_list = parse_address_list([gettext_lazy('"Example, Inc." ')]) + parsed_list = parse_address_list( + [gettext_lazy('"Example, Inc." ')] + ) self.assertEqual(len(parsed_list), 1) self.assertEqual(parsed_list[0].display_name, "Example, Inc.") self.assertEqual(parsed_list[0].addr_spec, "one@example.com") @@ -154,7 +177,9 @@ class ParseAddressListTests(SimpleTestCase): parsed = parse_single_address("one@example.com") self.assertEqual(parsed.address, "one@example.com") - with self.assertRaisesMessage(AnymailInvalidAddress, "Only one email address is allowed; found 2"): + with self.assertRaisesMessage( + AnymailInvalidAddress, "Only one email address is allowed; found 2" + ): parse_single_address("one@example.com, two@example.com") with self.assertRaisesMessage(AnymailInvalidAddress, "Invalid email address"): @@ -174,8 +199,10 @@ class ParseAddressListTests(SimpleTestCase): _ = EmailAddress(name, addr) def test_email_address_repr(self): - self.assertEqual("EmailAddress('Name', 'addr@example.com')", - repr(EmailAddress('Name', 'addr@example.com'))) + self.assertEqual( + "EmailAddress('Name', 'addr@example.com')", + repr(EmailAddress("Name", "addr@example.com")), + ) class NormalizedAttachmentTests(SimpleTestCase): @@ -192,25 +219,32 @@ class NormalizedAttachmentTests(SimpleTestCase): self.assertFalse(att.inline) self.assertIsNone(att.content_id) self.assertEqual(att.cid, "") - self.assertEqual(repr(att), "Attachment") + self.assertEqual( + repr(att), "Attachment" + ) def test_content_disposition_inline(self): image = MIMEImage(b";-)", "x-emoticon") - image["Content-Disposition"] = 'inline' + image["Content-Disposition"] = "inline" att = Attachment(image, "ascii") self.assertIsNone(att.name) self.assertEqual(att.content, b";-)") self.assertTrue(att.inline) # even without the Content-ID self.assertIsNone(att.content_id) self.assertEqual(att.cid, "") - self.assertEqual(repr(att), "Attachment") + self.assertEqual( + repr(att), "Attachment" + ) image["Content-ID"] = "" att = Attachment(image, "ascii") self.assertEqual(att.content_id, "") self.assertEqual(att.cid, "abc123@example.net") - self.assertEqual(repr(att), - "Attachment") + self.assertEqual( + repr(att), + "Attachment", + ) def test_content_id_implies_inline(self): """A MIME object with a Content-ID should be assumed to be inline""" @@ -219,8 +253,11 @@ class NormalizedAttachmentTests(SimpleTestCase): att = Attachment(image, "ascii") self.assertTrue(att.inline) self.assertEqual(att.content_id, "") - self.assertEqual(repr(att), - "Attachment") + self.assertEqual( + repr(att), + "Attachment", + ) # ... but not if explicit Content-Disposition says otherwise image["Content-Disposition"] = "attachment" @@ -246,7 +283,7 @@ class LazyCoercionTests(SimpleTestCase): self.assertFalse(is_lazy("text not lazy")) self.assertFalse(is_lazy(b"bytes not lazy")) self.assertFalse(is_lazy(None)) - self.assertFalse(is_lazy({'dict': "not lazy"})) + self.assertFalse(is_lazy({"dict": "not lazy"})) self.assertFalse(is_lazy(["list", "not lazy"])) self.assertFalse(is_lazy(object())) self.assertFalse(is_lazy([gettext_lazy("doesn't recurse")])) @@ -257,10 +294,20 @@ class LazyCoercionTests(SimpleTestCase): self.assertEqual(result, "text") def test_format_lazy(self): - self.assertTrue(is_lazy(format_lazy("{0}{1}", - gettext_lazy("concatenation"), gettext_lazy("is lazy")))) - result = force_non_lazy(format_lazy("{first}/{second}", - first=gettext_lazy("text"), second=gettext_lazy("format"))) + self.assertTrue( + is_lazy( + format_lazy( + "{0}{1}", gettext_lazy("concatenation"), gettext_lazy("is lazy") + ) + ) + ) + result = force_non_lazy( + format_lazy( + "{first}/{second}", + first=gettext_lazy("text"), + second=gettext_lazy("format"), + ) + ) self.assertIsInstance(result, str) self.assertEqual(result, "text/format") @@ -279,11 +326,12 @@ class LazyCoercionTests(SimpleTestCase): self.assertIsNone(result) def test_force_dict(self): - result = force_non_lazy_dict({'a': 1, 'b': gettext_lazy("b"), - 'c': {'c1': gettext_lazy("c1")}}) - self.assertEqual(result, {'a': 1, 'b': "b", 'c': {'c1': "c1"}}) - self.assertIsInstance(result['b'], str) - self.assertIsInstance(result['c']['c1'], str) + result = force_non_lazy_dict( + {"a": 1, "b": gettext_lazy("b"), "c": {"c1": gettext_lazy("c1")}} + ) + self.assertEqual(result, {"a": 1, "b": "b", "c": {"c1": "c1"}}) + self.assertIsInstance(result["b"], str) + self.assertIsInstance(result["c"]["c1"], str) def test_force_list(self): result = force_non_lazy_list([0, gettext_lazy("b"), "c"]) @@ -295,26 +343,28 @@ class UpdateDeepTests(SimpleTestCase): """Test utils.update_deep""" def test_updates_recursively(self): - first = {'a': {'a1': 1, 'aa': {}}, 'b': "B"} - second = {'a': {'a2': 2, 'aa': {'aa1': 11}}} + first = {"a": {"a1": 1, "aa": {}}, "b": "B"} + second = {"a": {"a2": 2, "aa": {"aa1": 11}}} result = update_deep(first, second) - self.assertEqual(first, {'a': {'a1': 1, 'a2': 2, 'aa': {'aa1': 11}}, 'b': "B"}) - self.assertIsNone(result) # modifies first in place; doesn't return it (same as dict.update()) + self.assertEqual(first, {"a": {"a1": 1, "a2": 2, "aa": {"aa1": 11}}, "b": "B"}) + # modifies first in place; doesn't return it (same as dict.update()): + self.assertIsNone(result) def test_overwrites_sequences(self): """Only mappings are handled recursively; sequences are considered atomic""" - first = {'a': [1, 2]} - second = {'a': [3]} + first = {"a": [1, 2]} + second = {"a": [3]} update_deep(first, second) - self.assertEqual(first, {'a': [3]}) + self.assertEqual(first, {"a": [3]}) def test_handles_non_dict_mappings(self): """Mapping types in general are supported""" from collections import OrderedDict, defaultdict - first = OrderedDict(a=OrderedDict(a1=1), c={'c1': 1}) + + first = OrderedDict(a=OrderedDict(a1=1), c={"c1": 1}) second = defaultdict(None, a=dict(a2=2)) update_deep(first, second) - self.assertEqual(first, {'a': {'a1': 1, 'a2': 2}, 'c': {'c1': 1}}) + self.assertEqual(first, {"a": {"a1": 1, "a2": 2}, "c": {"c1": 1}}) @override_settings(ALLOWED_HOSTS=[".example.com"]) @@ -327,68 +377,89 @@ class RequestUtilsTests(SimpleTestCase): @staticmethod def basic_auth(username, password): - """Return HTTP_AUTHORIZATION header value for basic auth with username, password""" - credentials = base64.b64encode("{}:{}".format(username, password).encode('utf-8')).decode('utf-8') + """ + Return HTTP_AUTHORIZATION header value for basic auth with username, password + """ + credentials = base64.b64encode( + "{}:{}".format(username, password).encode("utf-8") + ).decode("utf-8") return "Basic {}".format(credentials) def test_get_request_basic_auth(self): # without auth: - request = self.request_factory.post('/path/to/?query', - HTTP_HOST='www.example.com', - HTTP_SCHEME='https') + request = self.request_factory.post( + "/path/to/?query", HTTP_HOST="www.example.com", HTTP_SCHEME="https" + ) self.assertIsNone(get_request_basic_auth(request)) # with basic auth: - request = self.request_factory.post('/path/to/?query', - HTTP_HOST='www.example.com', - HTTP_AUTHORIZATION=self.basic_auth('user', 'pass')) + request = self.request_factory.post( + "/path/to/?query", + HTTP_HOST="www.example.com", + HTTP_AUTHORIZATION=self.basic_auth("user", "pass"), + ) self.assertEqual(get_request_basic_auth(request), "user:pass") # with some other auth - request = self.request_factory.post('/path/to/?query', - HTTP_HOST='www.example.com', - HTTP_AUTHORIZATION="Bearer abcde12345") + request = self.request_factory.post( + "/path/to/?query", + HTTP_HOST="www.example.com", + HTTP_AUTHORIZATION="Bearer abcde12345", + ) self.assertIsNone(get_request_basic_auth(request)) def test_get_request_uri(self): # without auth: - request = self.request_factory.post('/path/to/?query', secure=True, - HTTP_HOST='www.example.com') - self.assertEqual(get_request_uri(request), - "https://www.example.com/path/to/?query") + request = self.request_factory.post( + "/path/to/?query", secure=True, HTTP_HOST="www.example.com" + ) + self.assertEqual( + get_request_uri(request), "https://www.example.com/path/to/?query" + ) # with basic auth: - request = self.request_factory.post('/path/to/?query', secure=True, - HTTP_HOST='www.example.com', - HTTP_AUTHORIZATION=self.basic_auth('user', 'pass')) - self.assertEqual(get_request_uri(request), - "https://user:pass@www.example.com/path/to/?query") + request = self.request_factory.post( + "/path/to/?query", + secure=True, + HTTP_HOST="www.example.com", + HTTP_AUTHORIZATION=self.basic_auth("user", "pass"), + ) + self.assertEqual( + get_request_uri(request), "https://user:pass@www.example.com/path/to/?query" + ) - @override_settings(SECURE_PROXY_SSL_HEADER=('HTTP_X_FORWARDED_PROTO', 'https'), - USE_X_FORWARDED_HOST=True) + @override_settings( + SECURE_PROXY_SSL_HEADER=("HTTP_X_FORWARDED_PROTO", "https"), + USE_X_FORWARDED_HOST=True, + ) def test_get_request_uri_with_proxy(self): - request = self.request_factory.post('/path/to/?query', secure=False, - HTTP_HOST='web1.internal', - HTTP_X_FORWARDED_PROTO='https', - HTTP_X_FORWARDED_HOST='secret.example.com:8989', - HTTP_AUTHORIZATION=self.basic_auth('user', 'pass')) - self.assertEqual(get_request_uri(request), - "https://user:pass@secret.example.com:8989/path/to/?query") + request = self.request_factory.post( + "/path/to/?query", + secure=False, + HTTP_HOST="web1.internal", + HTTP_X_FORWARDED_PROTO="https", + HTTP_X_FORWARDED_HOST="secret.example.com:8989", + HTTP_AUTHORIZATION=self.basic_auth("user", "pass"), + ) + self.assertEqual( + get_request_uri(request), + "https://user:pass@secret.example.com:8989/path/to/?query", + ) class QueryDictUtilsTests(SimpleTestCase): def test_querydict_getfirst(self): q = QueryDict("a=one&a=two&a=three") q.getfirst = querydict_getfirst.__get__(q) - self.assertEqual(q.getfirst('a'), "one") + self.assertEqual(q.getfirst("a"), "one") # missing key exception: with self.assertRaisesMessage(KeyError, "not a key"): q.getfirst("not a key") # defaults: - self.assertEqual(q.getfirst('not a key', "beta"), "beta") - self.assertIsNone(q.getfirst('not a key', None)) + self.assertEqual(q.getfirst("not a key", "beta"), "beta") + self.assertIsNone(q.getfirst("not a key", None)) class ParseRFC2822DateTests(SimpleTestCase): @@ -406,9 +477,11 @@ class ParseRFC2822DateTests(SimpleTestCase): self.assertIsNotNone(dt.tzinfo) # aware def test_without_timezones(self): - dt = parse_rfc2822date("Tue, 24 Oct 2017 10:11:35 -0000") # "no timezone information" + # "no timezone information": + dt = parse_rfc2822date("Tue, 24 Oct 2017 10:11:35 -0000") self.assertEqual(dt.isoformat(), "2017-10-24T10:11:35") - self.assertIsNone(dt.tzinfo) # naive (compare with +0000 version in previous test) + # naive (compare with +0000 version in previous test): + self.assertIsNone(dt.tzinfo) dt = parse_rfc2822date("Tue, 24 Oct 2017 10:11:35") self.assertEqual(dt.isoformat(), "2017-10-24T10:11:35") diff --git a/tests/utils.py b/tests/utils.py index da9d1d2..5de0ec7 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -15,13 +15,14 @@ import django.test.client def decode_att(att): """Returns the original data from base64-encoded attachment content""" - return b64decode(att.encode('ascii')) + return b64decode(att.encode("ascii")) def rfc822_unfold(text): - # "Unfolding is accomplished by simply removing any CRLF that is immediately followed by WSP" - # (WSP is space or tab, and per email.parser semantics, we allow CRLF, CR, or LF endings) - return re.sub(r'(\r\n|\r|\n)(?=[ \t])', "", text) + # "Unfolding is accomplished by simply removing any CRLF that is immediately + # followed by WSP" (WSP is space or tab, and per email.parser semantics, we allow + # CRLF, CR, or LF endings) + return re.sub(r"(\r\n|\r|\n)(?=[ \t])", "", text) # @@ -60,7 +61,9 @@ def sample_email_path(filename=SAMPLE_EMAIL_FILENAME): def sample_email_content(filename=SAMPLE_EMAIL_FILENAME): - """Returns bytes contents of an email file (e.g., for forwarding as an attachment)""" + """ + Returns bytes contents of an email file (e.g., for forwarding as an attachment) + """ return test_file_content(filename) @@ -68,6 +71,7 @@ def sample_email_content(filename=SAMPLE_EMAIL_FILENAME): # TestCase helpers # + class AnymailTestMixin(TestCase): """Helpful additional methods for Anymail tests""" @@ -86,20 +90,21 @@ class AnymailTestMixin(TestCase): if key not in actual: missing.append(key) elif value != actual[key]: - mismatched.append('%s, expected: %s, actual: %s' % - (safe_repr(key), safe_repr(value), - safe_repr(actual[key]))) + mismatched.append( + "%s, expected: %s, actual: %s" + % (safe_repr(key), safe_repr(value), safe_repr(actual[key])) + ) if not (missing or mismatched): return - standardMsg = '' + standardMsg = "" if missing: - standardMsg = 'Missing: %s' % ','.join(safe_repr(m) for m in missing) + standardMsg = "Missing: %s" % ",".join(safe_repr(m) for m in missing) if mismatched: if standardMsg: - standardMsg += '; ' - standardMsg += 'Mismatched values: %s' % ','.join(mismatched) + standardMsg += "; " + standardMsg += "Mismatched values: %s" % ",".join(mismatched) self.fail(self._formatMessage(msg, standardMsg)) @@ -124,8 +129,8 @@ class AnymailTestMixin(TestCase): # (Technically, this is unfolding both headers and (incorrectly) bodies, # but that doesn't really affect the tests.) if isinstance(first, bytes) and isinstance(second, bytes): - first = first.decode('utf-8') - second = second.decode('utf-8') + first = first.decode("utf-8") + second = second.decode("utf-8") first = rfc822_unfold(first) second = rfc822_unfold(second) self.assertEqual(first, second, msg) @@ -135,8 +140,7 @@ class AnymailTestMixin(TestCase): try: uuid.UUID(uuid_str, version=version) except (ValueError, AttributeError, TypeError): - raise self.failureException( - msg or "%r is not a valid UUID" % uuid_str) + raise self.failureException(msg or "%r is not a valid UUID" % uuid_str) @contextmanager def assertPrints(self, expected, match="contain", msg=None): @@ -168,8 +172,11 @@ class AnymailTestMixin(TestCase): bound_matchfn = getattr(actual, matchfn) if not bound_matchfn(expected): raise self.failureException( - msg or "Stdout {actual!r} does not {match} {expected!r}".format( - actual=actual, match=match, expected=expected)) + msg + or "Stdout {actual!r} does not {match} {expected!r}".format( + actual=actual, match=match, expected=expected + ) + ) finally: sys.stdout = old_stdout @@ -186,8 +193,8 @@ class ClientWithCsrfChecks(django.test.Client): # dedent for bytestrs # https://stackoverflow.com/a/39841195/647002 -_whitespace_only_re = re.compile(b'^[ \t]+$', re.MULTILINE) -_leading_whitespace_re = re.compile(b'(^[ \t]*)(?:[^ \t\n])', re.MULTILINE) +_whitespace_only_re = re.compile(b"^[ \t]+$", re.MULTILINE) +_leading_whitespace_re = re.compile(b"(^[ \t]*)(?:[^ \t\n])", re.MULTILINE) def dedent_bytes(text): @@ -195,7 +202,7 @@ def dedent_bytes(text): # Look for the longest leading string of spaces and tabs common to # all lines. margin = None - text = _whitespace_only_re.sub(b'', text) + text = _whitespace_only_re.sub(b"", text) indents = _leading_whitespace_re.findall(text) for indent in indents: if margin is None: @@ -219,10 +226,10 @@ def dedent_bytes(text): margin = margin[:i] break else: - margin = margin[:len(indent)] + margin = margin[: len(indent)] if margin: - text = re.sub(b'(?m)^' + margin, b'', text) + text = re.sub(b"(?m)^" + margin, b"", text) return text @@ -233,7 +240,7 @@ def make_fileobj(content, filename=None, content_type=None, encoding=None): """ # The logic that unpacks this is in django.test.client.encode_file. if isinstance(content, str): - content = content.encode(encoding or 'utf-8') + content = content.encode(encoding or "utf-8") fileobj = BytesIO(content) if filename is not None: fileobj.name = filename @@ -254,5 +261,5 @@ def encode_multipart(boundary, data): encoded = django.test.client.encode_multipart(boundary, data) re_keys = r"|".join(re.escape(key) for key in data.keys()) return re.sub( - rb'filename="(%s)"' % re_keys.encode("ascii"), - b'filename=""', encoded) + rb'filename="(%s)"' % re_keys.encode("ascii"), b'filename=""', encoded + ) diff --git a/tests/utils_postal.py b/tests/utils_postal.py index 5781258..4978d7f 100644 --- a/tests/utils_postal.py +++ b/tests/utils_postal.py @@ -6,10 +6,8 @@ from tests.utils import ClientWithCsrfChecks HAS_CRYPTOGRAPHY = True try: - from cryptography.hazmat.primitives.asymmetric import rsa - from cryptography.hazmat.primitives import serialization - from cryptography.hazmat.primitives import hashes - from cryptography.hazmat.primitives.asymmetric import padding + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import padding, rsa except ImportError: HAS_CRYPTOGRAPHY = False @@ -24,14 +22,14 @@ def make_key(): def derive_public_webhook_key(private_key): - """Derive public """ + """Derive public""" public_key = private_key.public_key() public_bytes = public_key.public_bytes( encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.SubjectPublicKeyInfo + format=serialization.PublicFormat.SubjectPublicKeyInfo, ) - public_bytes = b'\n'.join(public_bytes.splitlines()[1:-1]) - return public_bytes.decode('utf-8') + public_bytes = b"\n".join(public_bytes.splitlines()[1:-1]) + return public_bytes.decode("utf-8") def sign(private_key, message): @@ -47,11 +45,11 @@ class _ClientWithPostalSignature(ClientWithCsrfChecks): self.private_key = private_key def post(self, *args, **kwargs): - signature = b64encode(sign(self.private_key, kwargs['data'].encode('utf-8'))) - kwargs.setdefault('HTTP_X_POSTAL_SIGNATURE', signature) + signature = b64encode(sign(self.private_key, kwargs["data"].encode("utf-8"))) + kwargs.setdefault("HTTP_X_POSTAL_SIGNATURE", signature) webhook_key = derive_public_webhook_key(self.private_key) - with override_settings(ANYMAIL={'POSTAL_WEBHOOK_KEY': webhook_key}): + with override_settings(ANYMAIL={"POSTAL_WEBHOOK_KEY": webhook_key}): return super().post(*args, **kwargs) diff --git a/tests/webhook_cases.py b/tests/webhook_cases.py index ac7a2a4..81f9c79 100644 --- a/tests/webhook_cases.py +++ b/tests/webhook_cases.py @@ -1,10 +1,10 @@ import base64 -from unittest.mock import create_autospec, ANY +from unittest.mock import ANY, create_autospec -from django.test import override_settings, SimpleTestCase +from django.test import SimpleTestCase, override_settings from anymail.exceptions import AnymailInsecureWebhookWarning -from anymail.signals import tracking, inbound +from anymail.signals import inbound, tracking from .utils import AnymailTestMixin, ClientWithCsrfChecks @@ -14,7 +14,7 @@ def event_handler(sender, event, esp_name, **kwargs): pass -@override_settings(ANYMAIL={'WEBHOOK_SECRET': 'username:password'}) +@override_settings(ANYMAIL={"WEBHOOK_SECRET": "username:password"}) class WebhookTestCase(AnymailTestMixin, SimpleTestCase): """Base for testing webhooks @@ -38,18 +38,23 @@ class WebhookTestCase(AnymailTestMixin, SimpleTestCase): inbound.connect(self.inbound_handler) self.addCleanup(inbound.disconnect, self.inbound_handler) - def set_basic_auth(self, username='username', password='password'): + def set_basic_auth(self, username="username", password="password"): """Set basic auth for all subsequent test client requests""" - credentials = base64.b64encode("{}:{}".format(username, password).encode('utf-8')).decode('utf-8') - self.client.defaults['HTTP_AUTHORIZATION'] = "Basic {}".format(credentials) + credentials = base64.b64encode( + "{}:{}".format(username, password).encode("utf-8") + ).decode("utf-8") + self.client.defaults["HTTP_AUTHORIZATION"] = "Basic {}".format(credentials) def clear_basic_auth(self): - self.client.defaults.pop('HTTP_AUTHORIZATION', None) + self.client.defaults.pop("HTTP_AUTHORIZATION", None) - def assert_handler_called_once_with(self, mockfn, *expected_args, **expected_kwargs): + def assert_handler_called_once_with( + self, mockfn, *expected_args, **expected_kwargs + ): """Verifies mockfn was called with expected_args and at least expected_kwargs. - Ignores *additional* actual kwargs (which might be added by Django signal dispatch). + Ignores *additional* actual kwargs + (which might be added by Django signal dispatch). (This differs from mock.assert_called_once_with.) Returns the actual kwargs. @@ -80,16 +85,17 @@ class WebhookBasicAuthTestCase(WebhookTestCase): - adding or overriding any tests as appropriate """ - def __init__(self, methodName='runTest'): + def __init__(self, methodName="runTest"): if self.__class__ is WebhookBasicAuthTestCase: # don't run these tests on the abstract base implementation - methodName = 'runNoTestsInBaseClass' + methodName = "runNoTestsInBaseClass" super().__init__(methodName) def runNoTestsInBaseClass(self): pass - should_warn_if_no_auth = True # subclass set False if other webhook verification used + #: subclass set False if other webhook verification used + should_warn_if_no_auth = True def call_webhook(self): # Concrete test cases should call a webhook via self.client.post, @@ -111,7 +117,7 @@ class WebhookBasicAuthTestCase(WebhookTestCase): self.assertEqual(response.status_code, 200) def test_verifies_bad_auth(self): - self.set_basic_auth('baduser', 'wrongpassword') + self.set_basic_auth("baduser", "wrongpassword") response = self.call_webhook() self.assertEqual(response.status_code, 400) @@ -120,17 +126,17 @@ class WebhookBasicAuthTestCase(WebhookTestCase): response = self.call_webhook() self.assertEqual(response.status_code, 400) - @override_settings(ANYMAIL={'WEBHOOK_SECRET': ['cred1:pass1', 'cred2:pass2']}) + @override_settings(ANYMAIL={"WEBHOOK_SECRET": ["cred1:pass1", "cred2:pass2"]}) def test_supports_credential_rotation(self): """You can supply a list of basic auth credentials, and any is allowed""" - self.set_basic_auth('cred1', 'pass1') + self.set_basic_auth("cred1", "pass1") response = self.call_webhook() self.assertEqual(response.status_code, 200) - self.set_basic_auth('cred2', 'pass2') + self.set_basic_auth("cred2", "pass2") response = self.call_webhook() self.assertEqual(response.status_code, 200) - self.set_basic_auth('baduser', 'wrongpassword') + self.set_basic_auth("baduser", "wrongpassword") response = self.call_webhook() self.assertEqual(response.status_code, 400)