pax_global_header00006660000000000000000000000064147735732330014530gustar00rootroot0000000000000052 comment=7e0aa994edfdcf99923596a4a162349c7bfe9f52 django-anymail-13.0/000077500000000000000000000000001477357323300143455ustar00rootroot00000000000000django-anymail-13.0/.editorconfig000066400000000000000000000016561477357323300170320ustar00rootroot00000000000000# https://editorconfig.org/ # This is adapted from Django's .editorconfig: # https://github.com/django/django/blob/main/.editorconfig root = true [*] indent_style = space indent_size = 4 insert_final_newline = true max_line_length = 88 trim_trailing_whitespace = true end_of_line = lf charset = utf-8 # Match pyproject.toml [tool.black] config: [*.py] max_line_length = 88 # Match pyproject.toml [tool.doc8] config: [*.rst] max_line_length = 120 [*.md] indent_size = 2 [*.html] indent_size = 2 # Anymail uses smaller indents than Django in css and js sources [*.css] indent_size = 2 [*.js] indent_size = 2 [*.json] indent_size = 2 # Minified files shouldn't be changed [**.min.{css,js}] indent_style = ignore insert_final_newline = ignore # Makefiles always use tabs for indentation [Makefile] indent_style = tab # Batch files use tabs for indentation [*.bat] end_of_line = crlf indent_style = tab [*.{yml,yaml}] indent_size = 2 django-anymail-13.0/.flake8000066400000000000000000000005321477357323300155200ustar00rootroot00000000000000[flake8] extend-exclude = build, tests/test_settings/settings_*.py # Black compatibility: # - E203 (spaces around slice operators) is not PEP-8 compliant (and Black _is_) # - Black sometimes deliberately overruns max-line-length by a small amount # (97 is Black's max-line-length of 88 + 10%) extend-ignore = E203 max-line-length = 97 django-anymail-13.0/.git-blame-ignore-revs000066400000000000000000000002221477357323300204410ustar00rootroot00000000000000# Applied black, doc8, isort, prettier b4e22c63b38452386746fed19d5defe0797d76a0 # Upgraded black to 24.8 66c677e4ab2633b1f52198597251c47739ba7b93 django-anymail-13.0/.github/000077500000000000000000000000001477357323300157055ustar00rootroot00000000000000django-anymail-13.0/.github/CONTRIBUTING.md000066400000000000000000000024171477357323300201420ustar00rootroot00000000000000Anymail is supported and maintained by the people who use it—like you! We welcome all contributions: issue reports, bug fixes, documentation improvements, 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] 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 For more info on **pull requests** and the **development** environment, 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.dev/en/stable/contributing/ [discussions]: https://github.com/anymail/django-anymail/discussions [support]: https://anymail.dev/en/stable/help/#support [troubleshooting]: https://anymail.dev/en/stable/help/#troubleshooting django-anymail-13.0/.github/ISSUE_TEMPLATE.md000066400000000000000000000005141477357323300204120ustar00rootroot00000000000000Reporting 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 sending, your code that sends the message) django-anymail-13.0/.github/workflows/000077500000000000000000000000001477357323300177425ustar00rootroot00000000000000django-anymail-13.0/.github/workflows/integration-test.yml000066400000000000000000000125011477357323300237640ustar00rootroot00000000000000name: integration-test on: pull_request: push: 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 # (to conserve limited sending quotas in the live ESP test accounts) runs-on: ubuntu-22.04 continue-on-error: true outputs: should_skip: ${{ steps.skip_check.outputs.should_skip }} steps: - id: skip_check # uses: fkirc/skip-duplicate-actions@v5.3.1 uses: fkirc/skip-duplicate-actions@f75f66ce1886f00957d99748a42c724f4330bdcf with: concurrent_skipping: "same_content_newer" cancel_others: "true" test: name: ${{ matrix.config.tox }} ${{ matrix.config.options }} runs-on: ubuntu-22.04 needs: skip_duplicate_runs if: needs.skip_duplicate_runs.outputs.should_skip != 'true' timeout-minutes: 15 strategy: fail-fast: false matrix: # Live API integration tests are run on only one representative Python/Django version # combination, to avoid rapidly consuming the testing accounts' entire send allotments. config: - { tox: django52-py313-amazon_ses, python: "3.13" } - { tox: django52-py313-brevo, python: "3.13" } - { tox: django52-py313-mailersend, python: "3.13" } - { tox: django52-py313-mailgun, python: "3.13" } - { tox: django52-py313-mailjet, python: "3.13" } - { tox: django52-py313-mandrill, python: "3.13" } - { tox: django52-py313-postal, python: "3.13" } - { tox: django52-py313-postmark, python: "3.13" } - { tox: django52-py313-resend, python: "3.13" } - { tox: django52-py313-sendgrid, python: "3.13" } - { tox: django52-py313-sparkpost, python: "3.13" } - { tox: django52-py313-unisender_go, python: "3.13" } steps: - name: Get code uses: actions/checkout@v4 - name: Setup Python ${{ matrix.config.python }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.config.python }} cache: "pip" - name: Install tox run: | set -x python --version pip install 'tox<4' tox --version - name: Test ${{ matrix.config.tox }} run: | tox -e ${{ matrix.config.tox }} continue-on-error: ${{ contains( matrix.config.options, 'allow-failures' ) }} env: CONTINUOUS_INTEGRATION: true TOX_FORCE_IGNORE_OUTCOME: false ANYMAIL_RUN_LIVE_TESTS: true ANYMAIL_TEST_AMAZON_SES_ACCESS_KEY_ID: ${{ secrets.ANYMAIL_TEST_AMAZON_SES_ACCESS_KEY_ID }} ANYMAIL_TEST_AMAZON_SES_DOMAIN: ${{ secrets.ANYMAIL_TEST_AMAZON_SES_DOMAIN }} ANYMAIL_TEST_AMAZON_SES_REGION_NAME: ${{ secrets.ANYMAIL_TEST_AMAZON_SES_REGION_NAME }} ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY: ${{ secrets.ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY }} ANYMAIL_TEST_BREVO_API_KEY: ${{ secrets.ANYMAIL_TEST_BREVO_API_KEY }} ANYMAIL_TEST_BREVO_DOMAIN: ${{ vars.ANYMAIL_TEST_BREVO_DOMAIN }} ANYMAIL_TEST_MAILERSEND_API_TOKEN: ${{ secrets.ANYMAIL_TEST_MAILERSEND_API_TOKEN }} ANYMAIL_TEST_MAILERSEND_DOMAIN: ${{ secrets.ANYMAIL_TEST_MAILERSEND_DOMAIN }} ANYMAIL_TEST_MAILGUN_API_KEY: ${{ secrets.ANYMAIL_TEST_MAILGUN_API_KEY }} ANYMAIL_TEST_MAILGUN_DOMAIN: ${{ secrets.ANYMAIL_TEST_MAILGUN_DOMAIN }} ANYMAIL_TEST_MAILJET_API_KEY: ${{ secrets.ANYMAIL_TEST_MAILJET_API_KEY }} ANYMAIL_TEST_MAILJET_DOMAIN: ${{ vars.ANYMAIL_TEST_MAILJET_DOMAIN }} ANYMAIL_TEST_MAILJET_SECRET_KEY: ${{ secrets.ANYMAIL_TEST_MAILJET_SECRET_KEY }} ANYMAIL_TEST_MAILJET_TEMPLATE_ID: ${{ vars.ANYMAIL_TEST_MAILJET_TEMPLATE_ID }} ANYMAIL_TEST_MANDRILL_API_KEY: ${{ secrets.ANYMAIL_TEST_MANDRILL_API_KEY }} ANYMAIL_TEST_MANDRILL_DOMAIN: ${{ secrets.ANYMAIL_TEST_MANDRILL_DOMAIN }} ANYMAIL_TEST_POSTMARK_DOMAIN: ${{ secrets.ANYMAIL_TEST_POSTMARK_DOMAIN }} ANYMAIL_TEST_POSTMARK_SERVER_TOKEN: ${{ secrets.ANYMAIL_TEST_POSTMARK_SERVER_TOKEN }} ANYMAIL_TEST_POSTMARK_TEMPLATE_ID: ${{ secrets.ANYMAIL_TEST_POSTMARK_TEMPLATE_ID }} ANYMAIL_TEST_RESEND_API_KEY: ${{ secrets.ANYMAIL_TEST_RESEND_API_KEY }} ANYMAIL_TEST_RESEND_DOMAIN: ${{ secrets.ANYMAIL_TEST_RESEND_DOMAIN }} ANYMAIL_TEST_SENDGRID_API_KEY: ${{ secrets.ANYMAIL_TEST_SENDGRID_API_KEY }} ANYMAIL_TEST_SENDGRID_DOMAIN: ${{ secrets.ANYMAIL_TEST_SENDGRID_DOMAIN }} ANYMAIL_TEST_SENDGRID_TEMPLATE_ID: ${{ secrets.ANYMAIL_TEST_SENDGRID_TEMPLATE_ID }} ANYMAIL_TEST_SPARKPOST_API_KEY: ${{ secrets.ANYMAIL_TEST_SPARKPOST_API_KEY }} ANYMAIL_TEST_SPARKPOST_DOMAIN: ${{ secrets.ANYMAIL_TEST_SPARKPOST_DOMAIN }} ANYMAIL_TEST_UNISENDER_GO_API_KEY: ${{ secrets.ANYMAIL_TEST_UNISENDER_GO_API_KEY }} ANYMAIL_TEST_UNISENDER_GO_API_URL: ${{ vars.ANYMAIL_TEST_UNISENDER_GO_API_URL }} ANYMAIL_TEST_UNISENDER_GO_DOMAIN: ${{ vars.ANYMAIL_TEST_UNISENDER_GO_DOMAIN }} ANYMAIL_TEST_UNISENDER_GO_TEMPLATE_ID: ${{ vars.ANYMAIL_TEST_UNISENDER_GO_TEMPLATE_ID }} django-anymail-13.0/.github/workflows/release.yml000066400000000000000000000065641477357323300221200ustar00rootroot00000000000000name: release # To release this package: # 1. Update the version number and changelog in the source. # Commit and push (to branch main or a vX.Y patch branch), # and wait for tests to complete. # 2. Tag with "vX.Y" or "vX.Y.Z": either create and push tag # directly via git, or create and publish a GitHub release. # # This workflow will run in response to the new tag, and will: # - Verify the source code and git tag version numbers match # - Publish the package to PyPI # - Create or update the release on GitHub on: push: tags: ["v[0-9]*"] workflow_dispatch: jobs: build: runs-on: ubuntu-22.04 outputs: anchor: ${{ steps.version.outputs.anchor }} tag: ${{ steps.version.outputs.tag }} version: ${{ steps.version.outputs.version }} steps: - name: Get code uses: actions/checkout@v4 - name: Setup Python uses: actions/setup-python@v5 with: python-version: "3.13" - name: Install build requirements run: | python -m pip install --upgrade build hatch twine - name: Get version # (This will end the workflow if git and source versions don't match.) id: version run: | VERSION="$(python -m hatch version)" TAG="v$VERSION" GIT_TAG="$(git tag -l --points-at "$GITHUB_REF" 'v*')" if [ "x$GIT_TAG" != "x$TAG" ]; then echo "::error ::package version '$TAG' does not match git tag '$GIT_TAG'" exit 1 fi echo "version=$VERSION" >> $GITHUB_OUTPUT echo "tag=$TAG" >> $GITHUB_OUTPUT echo "anchor=${TAG//[^[:alnum:]]/-}" >> $GITHUB_OUTPUT - name: Build distribution run: | rm -rf build dist django_anymail.egg-info python -m build - name: Check metadata run: | python -m twine check dist/* - name: Upload build artifacts uses: actions/upload-artifact@v4 with: name: dist path: dist/ retention-days: 7 publish: needs: [build] runs-on: ubuntu-22.04 environment: name: pypi url: https://pypi.org/p/django-anymail permissions: # Required for PyPI trusted publishing id-token: write steps: - name: Download build artifacts uses: actions/download-artifact@v4 with: name: dist path: dist/ - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 release: needs: [build, publish] runs-on: ubuntu-22.04 permissions: # `gh release` requires write permission on repo contents contents: write steps: - name: Download build artifacts uses: actions/download-artifact@v4 with: name: dist path: dist/ - name: Release to GitHub env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ needs.build.outputs.tag }} TITLE: ${{ needs.build.outputs.tag }} NOTES: | [Changelog](https://anymail.dev/en/stable/changelog/#${{ needs.build.outputs.anchor }}) run: | if ! gh release edit "$TAG" --verify-tag --target "$GITHUB_SHA" --title "$TITLE" --notes "$NOTES"; then gh release create "$TAG" --verify-tag --target "$GITHUB_SHA" --title "$TITLE" --notes "$NOTES" fi gh release upload "$TAG" ./dist/* django-anymail-13.0/.github/workflows/test.yml000066400000000000000000000044031477357323300214450ustar00rootroot00000000000000name: test on: pull_request: push: 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-22.04 outputs: envlist: ${{ steps.generate-envlist.outputs.envlist }} steps: - name: Get code uses: actions/checkout@v4 - name: Setup default Python # Change default Python version to something consistent # for installing/running tox uses: actions/setup-python@v5 with: python-version: "3.13" - name: Install tox-gh-matrix run: | python -m pip install 'tox<4' 'tox-gh-matrix<0.3' python -m tox --version - name: Generate tox envlist id: generate-envlist run: | python -m tox --gh-matrix python -m tox --gh-matrix-dump # for debugging test: runs-on: ubuntu-22.04 needs: get-envlist strategy: matrix: tox: ${{ fromJSON(needs.get-envlist.outputs.envlist) }} fail-fast: false name: ${{ matrix.tox.name }} ${{ matrix.tox.ignore_outcome && 'allow-failures' || '' }} timeout-minutes: 15 steps: - name: Get code uses: actions/checkout@v4 - name: Setup Python ${{ matrix.tox.python.version }} # Ensure matrix Python version is installed and available for tox uses: actions/setup-python@v5 with: python-version: ${{ matrix.tox.python.spec }} cache: "pip" - name: Setup default Python # Change default Python version back to something consistent # for installing/running tox uses: actions/setup-python@v5 with: python-version: "3.13" - name: Install tox run: | set -x python -VV python -m pip install 'tox<4' python -m tox --version - name: Test ${{ matrix.tox.name }} run: | python -m tox -e ${{ matrix.tox.name }} continue-on-error: ${{ matrix.tox.ignore_outcome == true }} env: CONTINUOUS_INTEGRATION: true TOX_OVERRIDE_IGNORE_OUTCOME: false django-anymail-13.0/.gitignore000066400000000000000000000004501477357323300163340ustar00rootroot00000000000000.DS_Store ._* *.pyc *.egg *.egg-info .eggs/ .tox/ build/ dist/ docs/_build/ local.py # Because pipenv was only used to manage a local development # environment, it was not helpful to track its lock file Pipfile.lock # Use pyenv-virtualenv to manage a venv for local development .python-version django-anymail-13.0/.pre-commit-config.yaml000066400000000000000000000030141477357323300206240ustar00rootroot00000000000000repos: - repo: https://github.com/psf/black rev: 24.8.0 hooks: - id: black - repo: https://github.com/pycqa/isort rev: 5.13.2 hooks: - id: isort - repo: https://github.com/pycqa/flake8 rev: 7.1.1 hooks: - id: flake8 - repo: https://github.com/pycqa/doc8 rev: v1.1.1 hooks: - id: doc8 - repo: https://github.com/pre-commit/mirrors-prettier # rev: see # https://github.com/pre-commit/mirrors-prettier/issues/29#issuecomment-1332667344 rev: v2.7.1 hooks: - id: prettier files: '\.(css|html|jsx?|md|tsx?|ya?ml)$' additional_dependencies: - prettier@2.8.3 - repo: https://github.com/pre-commit/pygrep-hooks rev: v1.10.0 hooks: - id: python-check-blanket-noqa - id: python-check-blanket-type-ignore - id: python-no-eval - id: python-no-log-warn - id: python-use-type-annotations # - id: rst-backticks # (no: some docs source uses single backticks expecting Sphinx default_role) - id: rst-directive-colons - id: rst-inline-touching-normal - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: - id: check-json - id: check-toml - id: check-yaml - id: end-of-file-fixer exclude: "\\.(bin|raw)$" - id: fix-byte-order-marker - id: fix-encoding-pragma args: [--remove] - id: forbid-submodules - id: mixed-line-ending - id: requirements-txt-fixer - id: trailing-whitespace django-anymail-13.0/.readthedocs.yml000066400000000000000000000006311477357323300174330ustar00rootroot00000000000000# Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details version: 2 build: os: ubuntu-22.04 tools: # "last stable CPython version": python: "3" sphinx: configuration: docs/conf.py builder: dirhtml # Additional formats to build: formats: all python: install: - path: . method: pip - requirements: docs/requirements.txt django-anymail-13.0/ADDING_ESPS.md000066400000000000000000000226361477357323300164600ustar00rootroot00000000000000# Adding a new ESP Some developer notes on adding support for a new ESP. Please refer to the comments in Anymail's code---most of the extension points are (reasonably) well documented, and will indicate what you need to implement and what Anymail provides 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 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 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 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. (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.) Similarly, Anymail is best suited to ESPs that offer some value-added features beyond simply sending email. If you'd get exactly the same results by pointing Django's built-in SMTP EmailBackend at the ESP's 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. ## Boilerplate You should add entries for your ESP in: - pyproject.toml: - in the `[project]` metadata section under `description` and `keywords` - in the `[project.optional-dependencies]` section - integration-test.yml in the test matrix - tox.ini in the `[testenv]` section under `setenv` - if your ESP requires any extra dependencies, also update the tox.ini `[testenv] extras` and the "partial installation" at the bottom of `[tox] envlist` - README.rst in the list of ESPs ## EmailBackend and payload Anymail abstracts a lot of common functionality into its base classes; 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`): implements Django's email API, orchestrates the overall sending process for multiple messages. - 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 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. Definitely avoid those. - 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 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, or authorization schemes that are non-trivial to implement in Requests. 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 Different API endpoints for (e.g.,) template vs. regular send? 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 If the client lib supports the notion of a reusable API "connection" (or session), you should override `open()` and `close()` to provide 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 passed `anymail.utils.EmailAddress` objects, so you don't have to parse them. `email.display_name` is the name, `email.addr_spec` is the email, and `str(email)` is both fully formatted, with a properly-quoted display name. 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)` #### Attachments The payload `set_attachments()`/`add_attachment()` methods are passed `anymail.utils.Attachment` objects, which are normalized so you don't have to handle the variety of formats Django allows. All have `name`, `content` (as a bytestream) and `mimetype` properties. Use `att.inline` to determine if the attachment is meant to be inline. (Don't just check for content-id.) If so, `att.content_id` is the Content-ID 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. 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. 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. 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. 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. ## 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. ### Tracking webhooks Good starting points: - 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. (more to come) ## Project goals Anymail aims to: - 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 least-common-denominator functionality, or prevent use of newly-released ESP features) - 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 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. Anything that would add Django models to Anymail is probably out of scope. (But could be a great companion package.) django-anymail-13.0/CHANGELOG.rst000066400000000000000000001721601477357323300163750ustar00rootroot00000000000000Changelog ========= Anymail releases follow `semantic versioning `_. Among other things, this means that minor updates (1.x to 1.y) should always be backwards-compatible, and breaking changes will always increment the major version number (1.x to 2.0). .. _semver: https://semver.org .. This changelog is designed to be readable standalone on GitHub, as well as included in the Sphinx docs. Do *not* use Sphinx references; links into the docs must use absolute urls to https://anymail.dev/ (generally to en/stable/, though linking to a specific older version may be appropriate for features that have been retired). .. You can use docutils 1.0 markup, but *not* any Sphinx additions. GitHub rst supports code-block, but *no other* block directives. .. default-role:: literal Release history ^^^^^^^^^^^^^^^ .. This extra heading level keeps the ToC from becoming unmanageably long v13.0 ----- *2025-04-03* Breaking changes ~~~~~~~~~~~~~~~~ * **Postal:** Require Python 3.9 or later for Postal tracking webhook support. (Postal's signature verification uses the "cryptography" package, which is no longer reliably installable with Python 3.8.) Fixes ~~~~~ * **Mailjet:** Avoid a Mailjet API error when sending an inline image without a filename. (Anymail now substitutes ``"attachment"`` for the missing filename.) (Thanks to `@chickahoona`_ for reporting the issue.) * **Mailjet:** Fix a JSON parsing error on Mailjet 429 "too many requests" API responses. (Thanks to `@rodrigondec`_ for reporting the issue.) * **Postmark:** Fix a parsing error when Postmark indicates a sent message has been delayed, which can occur if your message stream is paused or throttled or when Postmark is experiencing service issues. These messages will now report "queued" in the ``anymail_status`` (rather than throwing an error or reporting "sent"). (Thanks to `@jmduke`_ for reporting the issue.) * **Postmark:** Fix an error in inbound handling with long email address display names that include non-ASCII characters. * **SendGrid:** Improve handling of non-string values in ``merge_data`` when using legacy templates or inline merge fields. To avoid a confusing SendGrid API error message, Anymail now converts numeric merge data values to strings, but will raise an AnymailSerializationError for other non-string data in SendGrid substitutions. (SendGrid's newer *dynamic* transactional templates do not have this limitation.) (Thanks to `@PlusAsh`_ for reporting the issue.) Other ~~~~~ * Officially support Django 5.2. * **Resend:** Remove Anymail's workaround for an earlier Resend API bug with punctuation in address display names. Resend has fixed the bug. * **SendGrid:** Remove Anymail's workaround for an earlier SendGrid API bug with punctuation in address display names. SendGrid has fixed the bug. v12.0 ----- *2024-09-09* Breaking changes ~~~~~~~~~~~~~~~~ * Require **Django 4.0 or later** and Python 3.8 or later. Features ~~~~~~~~ * **Resend:** Add support for ``send_at``. Fixes ~~~~~ * **Unisender Go:** Fix several problems in Anymail's Unisender Go status tracking webhook. Rework signature checking to fix false validation errors (particularly on "clicked" and "opened" events). Properly handle "use single event" webhook option. Correctly verify WEBHOOK_SECRET when set. Provide Unisender Go's ``delivery_status`` code and unsubscribe form ``comment`` in Anymail's ``event.description``. Treat soft bounces as "deferred" rather than "bounced". (Thanks to `@MikeVL`_ for fixing the signature validation problem.) Other ~~~~~ * **Mandrill (docs):** Explain how ``cc`` and ``bcc`` handling depends on Mandrill's "preserve recipients" option. (Thanks to `@dgilmanAIDENTIFIED`_ for reporting the issue.) * **Postal (docs):** Update links to Postal's new documentation site. (Thanks to `@jmduke`_.) v11.1 ----- *2024-08-07* Features ~~~~~~~~ * **Brevo:** Support Brevo's new "Complaint," "Error" and "Loaded by proxy" tracking events. (Thanks to `@originell`_ for the update.) Deprecations ~~~~~~~~~~~~ * This will be the last Anymail release to support Django 3.0, 3.1 and 3.2 (which reached end of extended support on 2021-04-06, 2021-12-07 and 2024-04-01, respectively). * This will be the last Anymail release to support Python 3.7 (which reached end-of-life on 2023-06-27, and is not supported by Django 4.0 or later). v11.0.1 ------- *2024-07-11* (This release updates only documentation and package metadata; the code is identical to v11.0.) Fixes ~~~~~ * **Amazon SES (docs):** Correct IAM policies required for using the Amazon SES v2 API. See `Migrating to the SES v2 API `__. (Thanks to `@scur-iolus`_ for identifying the problem.) v11.0 ----- *2024-06-23* Breaking changes ~~~~~~~~~~~~~~~~ * **Amazon SES:** Drop support for the Amazon SES v1 API. If your ``EMAIL_BACKEND`` setting uses ``amazon_sesv1``, or if you are upgrading from Anymail 9.x or earlier directly to 11.0 or later, see `Migrating to the SES v2 API `__. (Anymail 10.0 switched to the SES v2 API by default. If your ``EMAIL_BACKEND`` setting has ``amazon_sesv2``, change that to just ``amazon_ses``.) * **SparkPost:** When sending with a ``template_id``, Anymail now raises an error if the message uses features that SparkPost will silently ignore. See `docs `__. Features ~~~~~~~~ * Add new ``merge_headers`` option for per-recipient headers with batch sends. This can be helpful to send individual *List-Unsubscribe* headers (for example). Supported for all current ESPs *except* MailerSend, Mandrill and Postal. See `docs `__. (Thanks to `@carrerasrodrigo`_ for the idea, and for the base and Amazon SES implementations.) * **Amazon SES:** Allow extra headers, ``metadata``, ``merge_metadata``, and ``tags`` when sending with a ``template_id``. (Requires boto3 v1.34.98 or later.) * **MailerSend:** Allow all extra headers. (Note that MailerSend limits use of this feature to "Enterprise accounts only.") Fixes ~~~~~ * **Amazon SES:** Fix a bug that could result in sending a broken address header if it had a long display name containing both non-ASCII characters and commas. (Thanks to `@andresmrm`_ for isolating and reporting the issue.) * **SendGrid:** In the tracking webhook, correctly report "bounced address" (recipients dropped due to earlier bounces) as reject reason ``"bounced"``. (Thanks to `@vitaliyf`_.) v10.3 ----- *2024-03-12* Features ~~~~~~~~ * **Brevo:** Add support for batch sending (`docs `__). * **Resend:** Add support for batch sending (`docs `__). * **Unisender Go:** Newly supported ESP (`docs `__). (Thanks to `@Arondit`_ for the implementation.) Fixes ~~~~~ * **Mailgun:** Avoid an error when Mailgun posts null delivery-status to the event tracking webhook. (Thanks to `@izimobil`_ for the fix.) Deprecations ~~~~~~~~~~~~ * **Brevo (SendinBlue):** Rename "SendinBlue" to "Brevo" throughout Anymail's code, reflecting their rebranding. This affects the email backend path, settings names, and webhook URLs. The old names will continue to work for now, but are deprecated. See `Updating code from SendinBlue to Brevo `__ for details. v10.2 ----- *2023-10-25* Features ~~~~~~~~ * **Resend**: Add support for this ESP (`docs `__). Fixes ~~~~~ * Correctly merge global ``SEND_DEFAULTS`` with message ``esp_extra`` for ESP APIs that use a nested structure (including Mandrill and SparkPost). Clarify intent of global defaults merging code for other message properties. (Thanks to `@mounirmesselmeni`_ for reporting the issue.) Other ~~~~~ * **Mailgun (docs):** Clarify account-level "Mailgun API keys" vs. domain-level "sending API keys." (Thanks to `@sdarwin`_ for reporting the issue.) * Test against prerelease versions of Django 5.0 and Python 3.12. v10.1 ----- *2023-07-31* Features ~~~~~~~~ * **Inbound:** Improve `AnymailInboundMessage`'s handling of inline content: * Rename `inline_attachments` to `content_id_map`, more accurately reflecting its function. * Add new `inlines` property that provides a complete list of inline content, whether or not it includes a *Content-ID*. This is helpful for accessing inline images that appear directly in a *multipart/mixed* body, such as those created by the Apple Mail app. * Rename `is_inline_attachment()` to just `is_inline()`. The renamed items are still available, but deprecated, under their old names. See `docs `__. (Thanks to `@martinezleoml`_.) * **Inbound:** `AnymailInboundMessage` now derives from Python's `email.message.EmailMessage`, which provides improved compatibility with email standards. (Thanks to `@martinezleoml`_.) * **Brevo (Sendinblue):** Sendinblue has rebranded to "Brevo." Change default API endpoint to ``api.brevo.com``, and update docs to reflect new name. Anymail still uses ``sendinblue`` in the backend name, for settings, etc., so there should be no impact on your code. (Thanks to `@sblondon`_.) * **Brevo (Sendinblue):** Add support for inbound email. (See `docs `__.) * **SendGrid:** Support multiple ``reply_to`` addresses. (Thanks to `@gdvalderrama`_ for pointing out the new API.) Deprecations ~~~~~~~~~~~~ * **Inbound:** `AnymailInboundMessage.inline_attachments` and `.is_inline_attachment()` have been renamed---see above. v10.0 ----- *2023-05-07* Breaking changes ~~~~~~~~~~~~~~~~ * **Amazon SES:** The Amazon SES backend now sends using the SES v2 API. Most projects should not require code changes, but you may need to update your IAM permissions. See `Migrating to the SES v2 API `__. If you were using SES v2 under Anymail 9.1 or 9.2, change your ``EMAIL_BACKEND`` setting from ``amazon_sesv2`` to just ``amazon_ses``. (If you are not ready to migrate to SES v2, an ``amazon_sesv1`` EmailBackend is available. But Anymail will drop support for that later this year. See `Using SES v1 (deprecated) `__.) * **Amazon SES:** The "extra name" for installation must now be spelled with a hyphen rather than an underscore: ``django-anymail[amazon-ses]``. Be sure to update any dependencies specification (pip install, requirements.txt, etc.) that had been using ``[amazon_ses]``. (This change is due to package name normalization rules enforced by modern Python packaging tools.) * **Mandrill:** Remove support for Mandrill-specific message attributes left over from Djrill. These attributes have raised DeprecationWarnings since Anymail 0.3 (in 2016), but are now silently ignored. See `Migrating from Djrill `__. * Require Python 3.7 or later. * Require urllib3 1.25 or later. (Drop a workaround for older urllib3 releases. urllib3 is a requests dependency; version 1.25 was released 2019-04-29. Unless you are pinning an earlier urllib3, this change should have no impact.) Features ~~~~~~~~ * **Postmark inbound:** * Handle Postmark's "Include raw email content in JSON payload" inbound option. We recommend enabling this in Postmark's dashboard to get the most accurate representation of received email. * Obtain ``envelope_sender`` from *Return-Path* Postmark now provides. (Replaces potentially faulty *Received-SPF* header parsing.) * Add *Bcc* header to inbound message if provided. Postmark adds bcc when the delivered-to address does not appear in the *To* header. Other ~~~~~ * Modernize packaging. (Change from setup.py and setuptools to pyproject.toml and hatchling.) Other than the ``amazon-ses`` naming normalization noted above, the new packaging should have no impact. If you have trouble installing django-anymail v10 where v9 worked, please report an issue including the exact install command and pip version you are using. v9.2 ----- *2023-05-02* Fixes ~~~~~ * Fix misleading error messages when sending with ``fail_silently=True`` and session creation fails (e.g., with Amazon SES backend and missing credentials). (Thanks to `@technolingo`_.) * **Postmark inbound:** Fix spurious AnymailInvalidAddress in ``message.cc`` when inbound message has no Cc recipients. (Thanks to `@Ecno92`_.) * **Postmark inbound:** Add workaround for malformed test data sent by Postmark's inbound webhook "Check" button. (See `#304`_. Thanks to `@Ecno92`_.) Deprecations ~~~~~~~~~~~~ * This will be the last Anymail release to support Python 3.6 (which reached end-of-life on 2021-12-23). Other ~~~~~ * Test against Django 4.2 release. v9.1 ---- *2023-03-11* Features ~~~~~~~~ * **Amazon SES:** Add support for sending through the Amazon SES v2 API (not yet enabled by default; see Deprecations below; `docs `__). * **MailerSend:** Add support for this ESP (`docs `__). Deprecations ~~~~~~~~~~~~ * **Amazon SES:** Anymail will be switching to the Amazon SES v2 API. Support for the original SES v1 API is now deprecated, and will be dropped in a future Anymail release (likely in late 2023). Many projects will not require code changes, but you may need to update your IAM permissions. See `Migrating to the SES v2 API `__. Other ~~~~~ * Test against Django 4.2 prerelease, Python 3.11 (with Django 4.2), and PyPy 3.9. * Use black, isort and doc8 to format code, enforced via pre-commit. (Thanks to `@tim-schilling`_.) v9.0 ---- *2022-12-18* Breaking changes ~~~~~~~~~~~~~~~~ * Require **Django 3.0 or later** and Python 3.6 or later. (For compatibility with Django 2.x or Python 3.5, stay on the Anymail `v8.6 LTS`_ extended support branch by setting your requirements to `django-anymail~=8.6`.) Features ~~~~~~~~ * **Sendinblue:** Support delayed sending using Anymail's `send_at` option. (Thanks to `@dimitrisor`_ for noting Sendinblue's public beta release of this capability.) * Support customizing the requests.Session for requests-based backends, and document how this can be used to mount an adapter that simplifies automatic retry logic. (Thanks to `@dgilmanAIDENTIFIED`_.) * Confirm support for Django 4.1 and resolve deprecation warning regarding ``django.utils.timezone.utc``. (Thanks to `@tim-schilling`_.) Fixes ~~~~~ * **Postmark:** Handle Postmark's SubscriptionChange events as Anymail unsubscribe, subscribe, or bounce tracking events, rather than "unknown". (Thanks to `@puru02`_ for the fix.) * **Sendinblue:** Work around recent (unannounced) Sendinblue API change that caused "Invalid headers" API error with non-string custom header values. Anymail now converts int and float header values to strings. Other ~~~~~ * Test on Python 3.11 with Django development (Django 4.2) branch. v8.6 LTS -------- *2022-05-15* This is an extended support release. Anymail v8.6 will receive security updates and fixes for any breaking ESP API changes through at least May, 2023. Fixes ~~~~~ * **Mailgun and SendGrid inbound:** Work around a Django limitation that drops attachments with certain filenames. The missing attachments are now simply omitted from the resulting inbound message. (In earlier releases, they would cause a MultiValueDictKeyError in Anymail's inbound webhook.) Anymail documentation now recommends using Mailgun's and SendGrid's "raw MIME" inbound options, which avoid the problem and preserve all attachments. See `Mailgun inbound `__ and `SendGrid inbound `__ for details. (Thanks to `@erikdrums`_ for reporting and helping investigate the problem.) Other ~~~~~ * **Mailgun:** Document Mailgun's incorrect handling of display names containing both non-ASCII characters and punctuation. (Thanks to `@Flexonze`_ for spotting and reporting the issue, and to Mailgun's `@b0d0nne11`_ for investigating.) * **Mandrill:** Document Mandrill's incorrect handling of non-ASCII attachment filenames. (Thanks to `@Thorbenl`_ for reporting the issue and following up with MailChimp.) * Documentation (for all releases) is now hosted at anymail.dev (moved from anymail.info). Deprecations ~~~~~~~~~~~~ * This will be the last Anymail release to support Django 2.0--2.2 and Python 3.5. If these deprecations affect you and you cannot upgrade, set your requirements to `django-anymail~=8.6` (a "compatible release" specifier, equivalent to `>=8.6,==8.*`). v8.5 ---- *2022-01-19* Fixes ~~~~~ * Allow `attach_alternative("content", "text/plain")` in place of setting an EmailMessage's `body`, and generally improve alternative part handling for consistency with Django's SMTP EmailBackend. (Thanks to `@cjsoftuk`_ for reporting the issue.) * Remove "sending a message from *sender* to *recipient*" from `AnymailError` text, as this can unintentionally leak personal information into logs. [Note that `AnymailError` *does* still include any error description from your ESP, and this often contains email addresses and other content from the sent message. If this is a concern, you can adjust Django's logging config to limit collection from Anymail or implement custom PII filtering.] (Thanks to `@coupa-anya`_ for reporting the issue.) Other ~~~~~ * **Postmark:** Document limitation on `track_opens` overriding Postmark's server-level setting. (See `docs `__.) * Expand `testing documentation `__ to cover tracking events and inbound handling, and to clarify test EmailBackend behavior. * In Anymail's test EmailBackend, add `is_batch_send` boolean to `anymail_test_params` to help tests check whether a sent message would fall under Anymail's batch-send logic. v8.4 ---- *2021-06-15* Features ~~~~~~~~ * **Postal:** Add support for this self-hosted ESP (`docs `__). Thanks to `@tiltec`_ for researching, implementing, testing and documenting Postal support. v8.3 ---- *2021-05-19* Fixes ~~~~~ * **Amazon SES:** Support receiving and tracking mail in non-default (or multiple) AWS regions. Anymail now always confirms an SNS subscription in the region where the SNS topic exists, which may be different from the boto3 default. (Thanks to `@mark-mishyn`_ for reporting this.) * **Postmark:** Fix two different errors when sending with a template but no merge data. (Thanks to `@kareemcoding`_ and `@Tobeyforce`_ for reporting them.) * **Postmark:** Fix silent failure when sending with long metadata keys and some other errors Postmark detects at send time. Report invalid 'cc' and 'bcc' addresses detected at send time the same as 'to' recipients. (Thanks to `@chrisgrande`_ for reporting the problem.) v8.2 ----- *2021-01-27* Features ~~~~~~~~ * **Mailgun:** Add support for AMP for Email (via ``message.attach_alternative(..., "text/x-amp-html")``). Fixes ~~~~~ * **SparkPost:** Drop support for multiple `from_email` addresses. SparkPost has started issuing a cryptic "No sending domain specified" error for this case; with this fix, Anymail will now treat it as an unsupported feature. Other ~~~~~ * **Mailgun:** Improve error messages for some common configuration issues. * Test against Django 3.2 prerelease (including support for Python 3.9) * Document how to send AMP for Email with Django, and note which ESPs support it. (See `docs `__.) * Move CI testing to GitHub Actions (and stop using Travis-CI). * Internal: catch invalid recipient status earlier in ESP response parsing v8.1 ---- *2020-10-09* Features ~~~~~~~~ * **SparkPost:** Add option for event tracking webhooks to map SparkPost's "Initial Open" event to Anymail's normalized "opened" type. (By default, only SparkPost's "Open" is reported as Anymail "opened", and "Initial Open" maps to "unknown" to avoid duplicates. See `docs `__. Thanks to `@slinkymanbyday`_.) * **SparkPost:** In event tracking webhooks, map AMP open and click events to the corresponding Anymail normalized event types. (Previously these were treated as as "unknown" events.) v8.0 ---- *2020-09-11* Breaking changes ~~~~~~~~~~~~~~~~ * Require **Django 2.0 or later** and Python 3. (For compatibility with Django 1.11 and Python 2.7, stay on the Anymail `v7.2 LTS`_ extended support branch by setting your requirements to `django-anymail~=7.2`.) * **Mailjet:** Upgrade to Mailjet's newer v3.1 send API. Most Mailjet users will not be affected by this change, with two exceptions: (1) Mailjet's v3.1 API does not allow multiple reply-to addresses, and (2) if you are using Anymail's `esp_extra`, you will need to update it for compatibility with the new API. (See `docs `__.) * **SparkPost:** Call the SparkPost API directly, without using the (now unmaintained) Python SparkPost client library. The "sparkpost" package is no longer necessary and can be removed from your project requirements. Most SparkPost users will not be affected by this change, with two exceptions: (1) You must provide a ``SPARKPOST_API_KEY`` in your Anymail settings (Anymail does not check environment variables); and (2) if you use Anymail's `esp_extra` you will need to update it with SparkPost Transmissions API parameters. As part of this change esp_extra now allows use of several SparkPost features, such as A/B testing, that were unavailable through the Python SparkPost library. (See `docs `__.) * Remove Anymail internal code related to supporting Python 2 and older Django versions. This does not change the documented API, but may affect you if your code borrowed from Anymail's undocumented internals. (You should be able to switch to the Python standard library equivalents, as Anymail has done.) * AnymailMessageMixin now correctly subclasses Django's EmailMessage. If you use it as part of your own custom EmailMessage-derived class, and you start getting errors about "consistent method resolution order," you probably need to change your class's inheritance. (For some helpful background, see this comment about `mixin superclass ordering `__.) Features ~~~~~~~~ * **SparkPost:** Add support for subaccounts (new ``"SPARKPOST_SUBACCOUNT"`` Anymail setting), AMP for Email (via ``message.attach_alternative(..., "text/x-amp-html")``), and A/B testing and other SparkPost sending features (via ``esp_extra``). (See `docs `__.) v7.2.1 ------ *2020-08-05* Fixes ~~~~~ * **Inbound:** Fix a Python 2.7-only UnicodeEncodeError when attachments have non-ASCII filenames. (Thanks to `@kika115`_ for reporting it.) v7.2 LTS -------- *2020-07-25* This is an extended support release. Anymail v7.2 will receive security updates and fixes for any breaking ESP API changes through at least July, 2021. Fixes ~~~~~ * **Amazon SES:** Fix bcc, which wasn't working at all on non-template sends. (Thanks to `@mwheels`_ for reporting the issue.) * **Mailjet:** Fix TypeError when sending to or from addresses with display names containing commas (introduced in Django 2.2.15, 3.0.9, and 3.1). * **SendGrid:** Fix UnicodeError in inbound webhook, when receiving message using charsets other than utf-8, and *not* using SendGrid's "post raw" inbound parse option. Also update docs to recommend "post raw" with SendGrid inbound. (Thanks to `@tcourtqtm`_ for reporting the issue.) Features ~~~~~~~~ * Test against Django 3.1 release candidates Deprecations ~~~~~~~~~~~~ * This will be the last Anymail release to support Django 1.11 and Python 2.7. If these deprecations affect you and you cannot upgrade, set your requirements to `django-anymail~=7.2` (a "compatible release" specifier, equivalent to `>=7.2,==7.*`). v7.1 ----- *2020-04-13* Fixes ~~~~~ * **Postmark:** Fix API error when sending with template to single recipient. (Thanks to `@jc-ee`_ for finding and fixing the issue.) * **SendGrid:** Allow non-batch template send to multiple recipients when `merge_global_data` is set without `merge_data`. (Broken in v6.0. Thanks to `@vgrebenschikov`_ for the bug report.) Features ~~~~~~~~ * Add `DEBUG_API_REQUESTS` setting to dump raw ESP API requests, which can assist in debugging or reporting problems to ESPs. (See `docs `__. This setting has was quietly added in Anymail v4.3, and is now officially documented.) * **Sendinblue:** Now supports file attachments on template sends, when using their new template language. (Sendinblue removed this API limitation on 2020-02-18; the change works with Anymail v7.0 and later. Thanks to `@sebashwa`_ for noting the API change and updating Anymail's docs.) Other ~~~~~ * Test against released Django 3.0. * **SendGrid:** Document unpredictable behavior in the SendGrid API that can cause text attachments to be sent with the wrong character set. (See `docs `__ under "Wrong character set on text attachments." Thanks to `@nuschk`_ and `@swrobel`_ for helping track down the issue and reporting it to SendGrid.) * Docs: Fix a number of typos and some outdated information. (Thanks `@alee`_ and `@Honza-m`_.) v7.0 ---- *2019-09-07* Breaking changes ~~~~~~~~~~~~~~~~ * **Sendinblue templates:** Support Sendinblue's new (ESP stored) Django templates and new API for template sending. This removes most of the odd limitations in the older (now-deprecated) SendinBlue template send API, but involves two breaking changes: * You *must* `convert `_ each old Sendinblue template to the new language as you upgrade to Anymail v7.0, or certain features may be silently ignored on template sends (notably `reply_to` and recipient display names). * Sendinblue's API no longer supports sending attachments when using templates. [Note: Sendinblue removed this API limitation on 2020-02-18.] Ordinary, non-template sending is not affected by these changes. See `docs `__ for more info and alternatives. (Thanks `@Thorbenl`_.) Features ~~~~~~~~ * **Mailgun:** Support Mailgun's new (ESP stored) handlebars templates via `template_id`. See `docs `__. (Thanks `@anstosa`_.) * **Sendinblue:** Support multiple `tags`. (Thanks `@Thorbenl`_.) Other ~~~~~ * **Mailgun:** Disable Anymail's workaround for a Requests/urllib3 issue with non-ASCII attachment filenames when a newer version of urllib3--which fixes the problem--is installed. (Workaround was added in Anymail v4.3; fix appears in urllib3 v1.25.) v6.1 ---- *2019-07-07* Features ~~~~~~~~ * **Mailgun:** Add new `MAILGUN_WEBHOOK_SIGNING_KEY` setting for verifying tracking and inbound webhook calls. Mailgun's webhook signing key can become different from your `MAILGUN_API_KEY` if you have ever rotated either key. See `docs `__. (More in `#153`_. Thanks to `@dominik-lekse`_ for reporting the problem and Mailgun's `@mbk-ok`_ for identifying the cause.) v6.0.1 ------ *2019-05-19* Fixes ~~~~~ * Support using `AnymailMessage` with django-mailer and similar packages that pickle messages. (See `#147`_. Thanks to `@ewingrj`_ for identifying the problem.) * Fix UnicodeEncodeError error while reporting invalid email address on Python 2.7. (See `#148`_. Thanks to `@fdemmer`_ for reporting the problem.) v6.0 ---- *2019-02-23* Breaking changes ~~~~~~~~~~~~~~~~ * **Postmark:** Anymail's `message.anymail_status.recipients[email]` no longer lowercases the recipient's email address. For consistency with other ESPs, it now uses the recipient email with whatever case was used in the sent message. If your code is doing something like `message.anymail_status.recipients[email.lower()]`, you should remove the `.lower()` * **SendGrid:** In batch sends, Anymail's SendGrid backend now assigns a separate `message_id` for each "to" recipient, rather than sharing a single id for all recipients. This improves accuracy of tracking and statistics (and matches the behavior of many other ESPs). If your code uses batch sending (merge_data with multiple to-addresses) and checks `message.anymail_status.message_id` after sending, that value will now be a *set* of ids. You can obtain each recipient's individual message_id with `message.anymail_status.recipients[to_email].message_id`. See `docs `__. Features ~~~~~~~~ * Add new `merge_metadata` option for providing per-recipient metadata in batch sends. Available for all supported ESPs *except* Amazon SES and SendinBlue. See `docs `__. (Thanks `@janneThoft`_ for the idea and SendGrid implementation.) * **Mailjet:** Remove limitation on using `cc` or `bcc` together with `merge_data`. Fixes ~~~~~ * **Mailgun:** Better error message for invalid sender domains (that caused a cryptic "Mailgun API response 200: OK Mailgun Magnificent API" error in earlier releases). * **Postmark:** Don't error if a message is sent with only Cc and/or Bcc recipients (but no To addresses). Also, `message.anymail_status.recipients[email]` now includes send status for Cc and Bcc recipients. (Thanks to `@ailionx`_ for reporting the error.) * **SendGrid:** With legacy templates, stop (ab)using "sections" for merge_global_data. This avoids potential conflicts with a template's own use of SendGrid section tags. v5.0 ---- *2018-11-07* Breaking changes ~~~~~~~~~~~~~~~~ * **Mailgun:** Anymail's status tracking webhooks now report Mailgun "temporary failure" events as Anymail's normalized "deferred" `event_type`. (Previously they were reported as "bounced", lumping them in with permanent failures.) The new behavior is consistent with how Anymail handles other ESP's tracking notifications. In the unlikely case your code depended on "temporary failure" showing up as "bounced" you will need to update it. (Thanks `@costela`_.) Features ~~~~~~~~ * **Postmark:** Allow either template alias (string) or numeric template id for Anymail's `template_id` when sending with Postmark templates. Fixes ~~~~~ * **Mailgun:** Improve error reporting when an inbound route is accidentally pointed at Anymail's tracking webhook url or vice versa. v4.3 ---- *2018-10-11* Features ~~~~~~~~ * Treat MIME attachments that have a *Content-ID* but no explicit *Content-Disposition* header as inline, matching the behavior of many email clients. For maximum compatibility, you should always set both (or use Anymail's inline helper functions). (Thanks `@costela`_.) Fixes ~~~~~ * **Mailgun:** Raise `AnymailUnsupportedFeature` error when attempting to send an attachment without a filename (or inline attachment without a *Content-ID*), because Mailgun silently drops these attachments from the sent message. (See `docs `__. Thanks `@costela`_ for identifying this undocumented Mailgun API limitation.) * **Mailgun:** Fix problem where attachments with non-ASCII filenames would be lost. (Works around Requests/urllib3 issue encoding multipart/form-data filenames in a way that isn't RFC 7578 compliant. Thanks to `@decibyte`_ for catching the problem.) Other ~~~~~ * Add (undocumented) DEBUG_API_REQUESTS Anymail setting. When enabled, prints raw API request and response during send. Currently implemented only for Requests-based backends (all but Amazon SES and SparkPost). Because this can expose API keys and other sensitive info in log files, it should not be used in production. v4.2 ---- *2018-09-07* Features ~~~~~~~~ * **Postmark:** Support per-recipient template `merge_data` and batch sending. (Batch sending can be used with or without a template. See `docs `__.) Fixes ~~~~~ * **Postmark:** When using `template_id`, ignore empty subject and body. (Postmark issues an error if Django's default empty strings are used with template sends.) v4.1 ---- *2018-08-27* Features ~~~~~~~~ * **SendGrid:** Support both new "dynamic" and original "legacy" transactional templates. (See `docs `__.) * **SendGrid:** Allow merging `esp_extra["personalizations"]` dict into other message-derived personalizations. (See `docs `__.) v4.0 ---- *2018-08-19* Breaking changes ~~~~~~~~~~~~~~~~ * Drop support for Django versions older than Django 1.11. (For compatibility back to Django 1.8, stay on the Anymail `v3.0`_ extended support branch.) * **SendGrid:** Remove the legacy SendGrid *v2* EmailBackend. (Anymail's default since v0.8 has been SendGrid's newer v3 API.) If your settings.py `EMAIL_BACKEND` still references "sendgrid_v2," you must `upgrade to v3 `__. Features ~~~~~~~~ * **Mailgun:** Add support for new Mailgun webhooks. (Mailgun's original "legacy webhook" format is also still supported. See `docs `__.) * **Mailgun:** Document how to use new European region. (This works in earlier Anymail versions, too.) * **Postmark:** Add support for Anymail's normalized `metadata` in sending and webhooks. Fixes ~~~~~ * Avoid problems with Gmail blocking messages that have inline attachments, when sent from a machine whose local hostname ends in *.com*. Change Anymail's `attach_inline_image()` default *Content-ID* domain to the literal text "inline" (rather than Python's default of the local hostname), to work around a limitation of some ESP APIs that don't permit distinct content ID and attachment filenames (Mailgun, Mailjet, Mandrill and SparkPost). See `#112`_ for more details. * **Amazon SES:** Work around an `Amazon SES bug `__ that can corrupt non-ASCII message bodies if you are using SES's open or click tracking. (See `#115`_ for more details. Thanks to `@varche1`_ for isolating the specific conditions that trigger the bug.) Other ~~~~~ * Maintain changelog in the repository itself (rather than in GitHub release notes). * Test against released versions of Python 3.7 and Django 2.1. v3.0 ---- *2018-05-30* This is an extended support release. Anymail v3.x will receive security updates and fixes for any breaking ESP API changes through at least April, 2019. Breaking changes ~~~~~~~~~~~~~~~~ * Drop support for Python 3.3 (see `#99`_). * **SendGrid:** Fix a problem where Anymail's status tracking webhooks didn't always receive the same `event.message_id` as the sent `message.anymail_status.message_id`, due to unpredictable behavior by SendGrid's API. Anymail now generates a UUID for each sent message and attaches it as a SendGrid custom arg named anymail_id. For most users, this change should be transparent. But it could be a breaking change if you are relying on a specific message_id format, or relying on message_id matching the *Message-ID* mail header or SendGrid's "smtp-id" event field. (More details in the `docs `__; also see `#108`_.) Thanks to `@joshkersey`_ for the report and the fix. Features ~~~~~~~~ * Support Django 2.1 prerelease. Fixes ~~~~~ * **Mailjet:** Fix tracking webhooks to work correctly when Mailjet "group events" option is disabled (see `#106`_). Deprecations ~~~~~~~~~~~~ * This will be the last Anymail release to support Django 1.8, 1.9, and 1.10 (see `#110`_). * This will be the last Anymail release to support the legacy SendGrid v2 EmailBackend (see `#111`_). (SendGrid's newer v3 API has been the default since Anymail v0.8.) If these deprecations affect you and you cannot upgrade, set your requirements to `django-anymail~=3.0` (a "compatible release" specifier, equivalent to `>=3.0,==3.*`). v2.2 ---- *2018-04-16* Fixes ~~~~~ * Fix a breaking change accidentally introduced in v2.1: The boto3 package is no longer required if you aren't using Amazon SES. v2.1 ---- *2018-04-11* **NOTE:** v2.1 accidentally introduced a **breaking change:** enabling Anymail webhooks with `include('anymail.urls')` causes an error if boto3 is not installed, even if you aren't using Amazon SES. This is fixed in v2.2. Features ~~~~~~~~ * **Amazon SES:** Add support for this ESP (`docs `__). * **SparkPost:** Add SPARKPOST_API_URL setting to support SparkPost EU and SparkPost Enterprise (`docs `__). * **Postmark:** Update for Postmark "modular webhooks." This should not impact client code. (Also, older versions of Anymail will still work correctly with Postmark's webhook changes.) Fixes ~~~~~ * **Inbound:** Fix several issues with inbound messages, particularly around non-ASCII headers and body content. Add workarounds for some limitations in older Python email packages. Other ~~~~~ * Use tox to manage Anymail test environments (see contributor `docs `__). Deprecations ~~~~~~~~~~~~ * This will be the last Anymail release to support Python 3.3. See `#99`_ for more information. v2.0 ---- *2018-03-08* Breaking changes ~~~~~~~~~~~~~~~~ * Drop support for deprecated WEBHOOK_AUTHORIZATION setting. If you are using webhooks and still have this Anymail setting, you must rename it to WEBHOOK_SECRET. See the `v1.4`_ release notes. * Handle *Reply-To,* *From,* and *To* in EmailMessage `extra_headers` the same as Django's SMTP EmailBackend if supported by your ESP, otherwise raise an unsupported feature error. Fixes the SparkPost backend to be consistent with other backends if both `headers["Reply-To"]` and `reply_to` are set on the same message. If you are setting a message's `headers["From"]` or `headers["To"]` (neither is common), the new behavior is likely a breaking change. See `docs `__ and `#91`_. * Treat EmailMessage `extra_headers` keys as case-\ *insensitive* in all backends, for consistency with each other (and email specs). If you are specifying duplicate headers whose names differ only in case, this may be a breaking change. See `docs `__. Features ~~~~~~~~ * **SendinBlue:** Add support for this ESP (`docs `__). Thanks to `@RignonNoel`_ for the implementation. * Add EmailMessage `envelope_sender` attribute, which can adjust the message's *Return-Path* if supported by your ESP (`docs `__). * Add universal wheel to PyPI releases for faster installation. Other ~~~~~ * Update setup.py metadata, clean up implementation. (Hadn't really been touched since original Djrill version.) * Prep for Python 3.7. v1.4 ---- *2018-02-08* Security ~~~~~~~~ * Fix a low severity security issue affecting Anymail v0.2–v1.3: rename setting WEBHOOK_AUTHORIZATION to WEBHOOK_SECRET to prevent inclusion in Django error reporting. (`CVE-2018-1000089 `__) *More information* Django error reporting includes the value of your Anymail WEBHOOK_AUTHORIZATION setting. In a properly-configured deployment, this should not be cause for concern. But if you have somehow exposed your Django error reports (e.g., by mis-deploying with DEBUG=True or by sending error reports through insecure channels), anyone who gains access to those reports could discover your webhook shared secret. An attacker could use this to post fabricated or malicious Anymail tracking/inbound events to your app, if you are using those Anymail features. The fix renames Anymail's webhook shared secret setting so that Django's error reporting mechanism will `sanitize `__ it. If you are using Anymail's event tracking and/or inbound webhooks, you should upgrade to this release and change "WEBHOOK_AUTHORIZATION" to "WEBHOOK_SECRET" in the ANYMAIL section of your settings.py. You may also want to `rotate the shared secret `__ value, particularly if you have ever exposed your Django error reports to untrusted individuals. If you are only using Anymail's EmailBackends for sending email and have not set up Anymail's webhooks, this issue does not affect you. The old WEBHOOK_AUTHORIZATION setting is still allowed in this release, but will issue a system-check warning when running most Django management commands. It will be removed completely in a near-future release, as a breaking change. Thanks to Charlie DeTar (`@yourcelf`_) for responsibly reporting this security issue through private channels. v1.3 ---- *2018-02-02* Security ~~~~~~~~ * v1.3 includes the v1.2.1 security fix released at the same time. Please review the `v1.2.1`_ release notes, below, if you are using Anymail's tracking webhooks. Features ~~~~~~~~ * **Inbound handling:** Add normalized inbound message event, signal, and webhooks for all supported ESPs. (See new `Receiving mail `__ docs.) This hasn't been through much real-world testing yet; bug reports and feedback are very welcome. * **API network timeouts:** For Requests-based backends (all but SparkPost), use a default timeout of 30 seconds for all ESP API calls, to avoid stalling forever on a bad connection. Add a REQUESTS_TIMEOUT Anymail setting to override. (See `#80`_.) * **Test backend improvements:** Generate unique tracking `message_id` when using the `test backend `__; add console backend for use in development. (See `#85`_.) .. _release_1_2_1: v1.2.1 ------ *2018-02-02* Security ~~~~~~~~ * Fix a **moderate severity** security issue affecting Anymail v0.2–v1.2: prevent timing attack on WEBHOOK_AUTHORIZATION secret. (`CVE-2018-6596 `__) *More information* If you are using Anymail's tracking webhooks, you should upgrade to this release, and you may want to rotate to a new WEBHOOK_AUTHORIZATION shared secret (see `docs `__). You should definitely change your webhook auth if your logs indicate attempted exploit. (If you are only sending email using an Anymail EmailBackend, and have not set up Anymail's event tracking webhooks, this issue does not affect you.) Anymail's webhook validation was vulnerable to a timing attack. A remote attacker could use this to obtain your WEBHOOK_AUTHORIZATION shared secret, potentially allowing them to post fabricated or malicious email tracking events to your app. There have not been any reports of attempted exploit. (The vulnerability was discovered through code review.) Attempts would be visible in HTTP logs as a very large number of 400 responses on Anymail's webhook urls (by default "/anymail/*esp_name*/tracking/"), and in Python error monitoring as a very large number of AnymailWebhookValidationFailure exceptions. v1.2 ---- *2017-11-02* Features ~~~~~~~~ * **Postmark:** Support new click webhook in normalized tracking events v1.1 ---- *2017-10-28* Fixes ~~~~~ * **Mailgun:** Support metadata in opened/clicked/unsubscribed tracking webhooks, and fix potential problems if metadata keys collided with Mailgun event parameter names. (See `#76`_, `#77`_) Other ~~~~~ * Rework Anymail's ParsedEmail class and rename to EmailAddress to align it with similar functionality in the Python 3.6 email package, in preparation for future inbound support. ParsedEmail was not documented for use outside Anymail's internals (so this change does not bump the semver major version), but if you were using it in an undocumented way you will need to update your code. v1.0 ---- *2017-09-18* It's official: Anymail is no longer "pre-1.0." The API has been stable for many months, and there's no reason not to use Anymail in production. Breaking changes ~~~~~~~~~~~~~~~~ * There are no *new* breaking changes in the 1.0 release, but a breaking change introduced several months ago in v0.8 is now strictly enforced. If you still have an EMAIL_BACKEND setting that looks like "anymail.backends.*espname*.\ *EspName*\ Backend", you'll need to change it to just "anymail.backends.*espname*.EmailBackend". (Earlier versions had issued a DeprecationWarning. See the `v0.8`_ release notes.) Features ~~~~~~~~ * Clean up and document Anymail's `Test EmailBackend `__ * Add notes on `handling transient ESP errors `__ and improving `batch send performance `__ * **SendGrid:** handle Python 2 `long` integers in metadata and extra headers v1.0.rc0 -------- *2017-09-09* Breaking changes ~~~~~~~~~~~~~~~~ * **All backends:** The old *EspName*\ Backend names that were deprecated in v0.8 have been removed. Attempting to use the old names will now fail, rather than issue a DeprecationWarning. See the `v0.8`_ release notes. Features ~~~~~~~~ * Anymail's Test EmailBackend is now `documented `__ (and cleaned up) v0.11.1 ------- *2017-07-24* Fixes ~~~~~ * **Mailjet:** Correct settings docs. v0.11 ----- *2017-07-13* Features ~~~~~~~~ * **Mailjet:** Add support for this ESP. Thanks to `@Lekensteyn`_ and `@calvin`_. (`Docs `__) * In webhook handlers, AnymailTrackingEvent.metadata now defaults to `{}`, and .tags defaults to `[]`, if the ESP does not supply these fields with the event. (See `#67`_.) v0.10 ----- *2017-05-22* Features ~~~~~~~~ * **Mailgun, SparkPost:** Support multiple from addresses, as a comma-separated `from_email` string. (*Not* a list of strings, like the recipient fields.) RFC-5322 allows multiple from email addresses, and these two ESPs support it. Though as a practical matter, multiple from emails are either ignored or treated as a spam signal by receiving mail handlers. (See `#60`_.) Fixes ~~~~~ * Fix crash sending forwarded email messages as attachments. (See `#59`_.) * **Mailgun:** Fix webhook crash on bounces from some receiving mail handlers. (See `#62`_.) * Improve recipient-parsing error messages and consistency with Django's SMTP backend. In particular, Django (and now Anymail) allows multiple, comma-separated email addresses in a single recipient string. v0.9 ---- *2017-04-04* Breaking changes ~~~~~~~~~~~~~~~~ * **Mandrill, Postmark:** Normalize soft-bounce webhook events to event_type 'bounced' (rather than 'deferred'). Features ~~~~~~~~ * Officially support released Django 1.11, including under Python 3.6. .. _release_0_8: v0.8 ---- *2017-02-02* Breaking changes ~~~~~~~~~~~~~~~~ * **All backends:** Rename all Anymail backends to just `EmailBackend`, matching Django's naming convention. E.g., you should update: `EMAIL_BACKEND = "anymail.backends.mailgun.MailgunBackend" # old` to: `EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend" # new` The old names still work, but will issue a DeprecationWarning and will be removed in some future release (Apologies for this change; the old naming was a holdover from Djrill, and I wanted to establish consistency with other Django EmailBackends before Anymail 1.0. See `#49`_.) * **SendGrid:** Update SendGrid backend to their newer Web API v3. This should be a transparent change for most projects. Exceptions: if you use SendGrid username/password auth, Anymail's `esp_extra` with "x-smtpapi", or multiple Reply-To addresses, please review the `porting notes `__. The SendGrid v2 EmailBackend `remains available `__ if you prefer it, but is no longer the default. .. SendGrid v2 backend removed after Anymail v3.0; links frozen to that doc version Features ~~~~~~~~ * Test on Django 1.11 prerelease, including under Python 3.6. Fixes ~~~~~ * **Mandrill:** Fix bug in webhook signature validation when using basic auth via the WEBHOOK_AUTHORIZATION setting. (If you were using the MANDRILL_WEBHOOK_URL setting to work around this problem, you should be able to remove it. See `#48`_.) v0.7 ---- *2016-12-30* Breaking changes ~~~~~~~~~~~~~~~~ * Fix a long-standing bug validating email addresses. If an address has a display name containing a comma or parentheses, RFC-5322 *requires* double-quotes around the display name (`'"Widgets, Inc." '`). Anymail now raises a new `AnymailInvalidAddress` error for misquoted display names and other malformed addresses. (Previously, it silently truncated the address, leading to obscure exceptions or unexpected behavior. If you were unintentionally relying on that buggy behavior, this may be a breaking change. See `#44`_.) In general, it's safest to always use double-quotes around all display names. Features ~~~~~~~~ * **Postmark:** Support Postmark's new message delivery event in Anymail normalized tracking webhook. (Update your Postmark config to enable the new event. See `docs `__.) * Handle virtually all uses of Django lazy translation strings as EmailMessage properties. (In earlier releases, these could sometimes lead to obscure exceptions or unexpected behavior with some ESPs. See `#34`_.) * **Mandrill:** Simplify and document two-phase process for setting up Mandrill webhooks (`docs `__). v0.6.1 ------ *2016-11-01* Fixes ~~~~~ * **Mailgun, Mandrill:** Support older Python 2.7.x versions in webhook validation (`#39`_; thanks `@sebbacon`_). * **Postmark:** Handle older-style 'Reply-To' in EmailMessage `headers` (`#41`_). v0.6 ---- *2016-10-25* Breaking changes ~~~~~~~~~~~~~~~~ * **SendGrid:** Fix missing html or text template body when using `template_id` with an empty Django EmailMessage body. In the (extremely-unlikely) case you were relying on the earlier quirky behavior to *not* send your saved html or text template, you may want to verify that your SendGrid templates have matching html and text. (`docs `__ -- also see `#32`_.) Features ~~~~~~~~ * **Postmark:** Add support for `track_clicks` (`docs `__) * Initialize AnymailMessage.anymail_status to empty status, rather than None; clarify docs around `anymail_status` availability (`docs `__) v0.5 ---- *2016-08-22* Features ~~~~~~~~ * **Mailgun:** Add MAILGUN_SENDER_DOMAIN setting. (`docs `__) v0.4.2 ------ *2016-06-24* Fixes ~~~~~ * **SparkPost:** Fix API error "Both content object and template_id are specified" when using `template_id` (`#24`_). v0.4.1 ------ *2016-06-23* Features ~~~~~~~~ * **SparkPost:** Add support for this ESP. (`docs `__) * Test with Django 1.10 beta * Requests-based backends (all but SparkPost) now raise AnymailRequestsAPIError for any requests.RequestException, for consistency and proper fail_silently behavior. (The exception will also be a subclass of the original RequestException, so no changes are required to existing code looking for specific requests failures.) v0.4 ---- *(not released)* v0.3.1 ------ *2016-05-18* Fixes ~~~~~ * **SendGrid:** Fix API error that `to` is required when using `merge_data` (see `#14`_; thanks `@lewistaylor`_). v0.3 ---- *2016-05-13* Features ~~~~~~~~ * Add support for ESP stored templates and batch sending/merge. Exact capabilities vary widely by ESP -- be sure to read the notes for your ESP. (`docs `__) * Add pre_send and post_send signals. `docs `__ * **Mandrill:** add support for esp_extra; deprecate Mandrill-specific message attributes left over from Djrill. See `migrating from Djrill `__. v0.2 ---- *2016-04-30* Breaking changes ~~~~~~~~~~~~~~~~ * **Mailgun:** eliminate automatic JSON encoding of complex metadata values like lists and dicts. (Was based on misreading of Mailgun docs; behavior now matches metadata handling for all other ESPs.) * **Mandrill:** remove obsolete wehook views and signal inherited from Djrill. See `Djrill migration notes `__ if you were relying on that code. Features ~~~~~~~~ * Add support for ESP event-tracking webhooks, including normalized AnymailTrackingEvent. (`docs `__) * Allow get_connection kwargs overrides of most settings for individual backend instances. Can be useful for, e.g., working with multiple SendGrid subusers. (`docs `__) * **SendGrid:** Add SENDGRID_GENERATE_MESSAGE_ID setting to control workarounds for ensuring unique tracking ID on SendGrid messages/events (default enabled). `docs `__ * **SendGrid:** improve handling of 'filters' in esp_extra, making it easier to mix custom SendGrid app filter settings with Anymail normalized message options. Other ~~~~~ * Drop pre-Django 1.8 test code. (Wasn't being used, as Anymail requires Django 1.8+.) * **Mandrill:** note limited support in docs (because integration tests no longer available). v0.1 ---- *2016-03-14* Although this is an early release, it provides functional Django EmailBackends and passes integration tests with all supported ESPs (Mailgun, Mandrill, Postmark, SendGrid). It has (obviously) not yet undergone extensive real-world testing, and you are encouraged to monitor it carefully if you choose to use it in production. Please report bugs and problems here in GitHub. Features ~~~~~~~~ * **Postmark:** Add support for this ESP. * **SendGrid:** Add support for username/password auth. * Simplified install: no need to name the ESP (`pip install django-anymail` -- not `... django-anymail[mailgun]`) 0.1.dev2 -------- *2016-03-12* Features ~~~~~~~~ * **SendGrid:** Add support for this ESP. * Add attach_inline_image_file helper Fixes ~~~~~ * Change inline-attachment handling to look for `Content-Disposition: inline`, and to preserve filenames where supported by the ESP. 0.1.dev1 -------- *2016-03-10* Features ~~~~~~~~ * **Mailgun, Mandrill:** initial supported ESPs. * Initial docs .. GitHub issue and user links (GitHub auto-linking doesn't work in Sphinx) .. _#14: https://github.com/anymail/django-anymail/issues/14 .. _#24: https://github.com/anymail/django-anymail/issues/24 .. _#32: https://github.com/anymail/django-anymail/issues/32 .. _#34: https://github.com/anymail/django-anymail/issues/34 .. _#39: https://github.com/anymail/django-anymail/issues/39 .. _#41: https://github.com/anymail/django-anymail/issues/41 .. _#44: https://github.com/anymail/django-anymail/issues/44 .. _#48: https://github.com/anymail/django-anymail/issues/48 .. _#49: https://github.com/anymail/django-anymail/issues/49 .. _#59: https://github.com/anymail/django-anymail/issues/59 .. _#60: https://github.com/anymail/django-anymail/issues/60 .. _#62: https://github.com/anymail/django-anymail/issues/62 .. _#67: https://github.com/anymail/django-anymail/issues/67 .. _#76: https://github.com/anymail/django-anymail/issues/76 .. _#77: https://github.com/anymail/django-anymail/issues/77 .. _#80: https://github.com/anymail/django-anymail/issues/80 .. _#85: https://github.com/anymail/django-anymail/issues/85 .. _#91: https://github.com/anymail/django-anymail/issues/91 .. _#99: https://github.com/anymail/django-anymail/issues/99 .. _#106: https://github.com/anymail/django-anymail/issues/106 .. _#108: https://github.com/anymail/django-anymail/issues/108 .. _#110: https://github.com/anymail/django-anymail/issues/110 .. _#111: https://github.com/anymail/django-anymail/issues/111 .. _#112: https://github.com/anymail/django-anymail/issues/112 .. _#115: https://github.com/anymail/django-anymail/issues/115 .. _#147: https://github.com/anymail/django-anymail/issues/147 .. _#148: https://github.com/anymail/django-anymail/issues/148 .. _#153: https://github.com/anymail/django-anymail/issues/153 .. _#304: https://github.com/anymail/django-anymail/issues/304 .. _@ailionx: https://github.com/ailionx .. _@alee: https://github.com/alee .. _@andresmrm: https://github.com/andresmrm .. _@anstosa: https://github.com/anstosa .. _@Arondit: https://github.com/Arondit .. _@b0d0nne11: https://github.com/b0d0nne11 .. _@calvin: https://github.com/calvin .. _@carrerasrodrigo: https://github.com/carrerasrodrigo .. _@chickahoona: https://github.com/chickahoona .. _@chrisgrande: https://github.com/chrisgrande .. _@cjsoftuk: https://github.com/cjsoftuk .. _@costela: https://github.com/costela .. _@coupa-anya: https://github.com/coupa-anya .. _@decibyte: https://github.com/decibyte .. _@dgilmanAIDENTIFIED: https://github.com/dgilmanAIDENTIFIED .. _@dimitrisor: https://github.com/dimitrisor .. _@dominik-lekse: https://github.com/dominik-lekse .. _@Ecno92: https://github.com/Ecno92 .. _@erikdrums: https://github.com/erikdrums .. _@ewingrj: https://github.com/ewingrj .. _@fdemmer: https://github.com/fdemmer .. _@Flexonze: https://github.com/Flexonze .. _@gdvalderrama: https://github.com/gdvalderrama .. _@Honza-m: https://github.com/Honza-m .. _@izimobil: https://github.com/izimobil .. _@janneThoft: https://github.com/janneThoft .. _@jc-ee: https://github.com/jc-ee .. _@jmduke: https://github.com/jmduke .. _@joshkersey: https://github.com/joshkersey .. _@kareemcoding: https://github.com/kareemcoding .. _@kika115: https://github.com/kika115 .. _@Lekensteyn: https://github.com/Lekensteyn .. _@lewistaylor: https://github.com/lewistaylor .. _@mark-mishyn: https://github.com/mark-mishyn .. _@martinezleoml: https://github.com/martinezleoml .. _@mbk-ok: https://github.com/mbk-ok .. _@MikeVL: https://github.com/MikeVL .. _@mounirmesselmeni: https://github.com/mounirmesselmeni .. _@mwheels: https://github.com/mwheels .. _@nuschk: https://github.com/nuschk .. _@originell: https://github.com/originell .. _@PlusAsh: https://github.com/PlusAsh .. _@puru02: https://github.com/puru02 .. _@RignonNoel: https://github.com/RignonNoel .. _@rodrigondec: https://github.com/rodrigondec .. _@sblondon: https://github.com/sblondon .. _@scur-iolus: https://github.com/scur-iolus .. _@sdarwin: https://github.com/sdarwin .. _@sebashwa: https://github.com/sebashwa .. _@sebbacon: https://github.com/sebbacon .. _@slinkymanbyday: https://github.com/slinkymanbyday .. _@swrobel: https://github.com/swrobel .. _@tcourtqtm: https://github.com/tcourtqtm .. _@technolingo: https://github.com/technolingo .. _@Thorbenl: https://github.com/Thorbenl .. _@tiltec: https://github.com/tiltec .. _@tim-schilling: https://github.com/tim-schilling .. _@Tobeyforce: https://github.com/Tobeyforce .. _@varche1: https://github.com/varche1 .. _@vgrebenschikov: https://github.com/vgrebenschikov .. _@vitaliyf: https://github.com/vitaliyf .. _@yourcelf: https://github.com/yourcelf django-anymail-13.0/LICENSE000066400000000000000000000030071477357323300153520ustar00rootroot00000000000000BSD 3-Clause License Copyright (c) 2016-2024, Anymail Contributors Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. django-anymail-13.0/README.rst000066400000000000000000000151521477357323300160400ustar00rootroot00000000000000Anymail: Django email integration for transactional ESPs ======================================================== .. This README is reused in multiple places: * Github: project page, exactly as it appears here * Docs: shared-intro section gets included in docs/index.rst quickstart section gets included in docs/quickstart.rst * PyPI: project page (via pyproject.toml readme; see also hatch_build.py which edits in the release version number) You can use docutils 1.0 markup, but *not* any Sphinx additions. GitHub rst supports code-block, but *no other* block directives. .. default-role:: literal .. _shared-intro: .. This shared-intro section is also included in docs/index.rst Anymail lets you send and receive email in Django using your choice of transactional email service providers (ESPs). It extends the standard `django.core.mail` with many common ESP-added features, providing a consistent API that avoids locking your code to one specific ESP (and making it easier to change ESPs later if needed). Anymail currently supports these ESPs: * **Amazon SES** * **Brevo** (formerly SendinBlue) * **MailerSend** * **Mailgun** (Sinch transactional email) * **Mailjet** (Sinch transactional email) * **Mandrill** (MailChimp transactional email) * **Postal** (self-hosted ESP) * **Postmark** (ActiveCampaign transactional email) * **Resend** * **SendGrid** (Twilio transactional email) * **SparkPost** (Bird transactional email) * **Unisender Go** Anymail includes: * Integration of each ESP's sending APIs into `Django's built-in email `_ package, including support for HTML, attachments, extra headers, and other standard email features * Extensions to expose common ESP-added functionality, like tags, metadata, and tracking, with code that's portable between ESPs * Simplified inline images for HTML email * Normalized sent-message status and tracking notification, by connecting your ESP's webhooks to Django signals * "Batch transactional" sends using your ESP's merge and template features * Inbound message support, to receive email through your ESP's webhooks, with simplified, portable access to attachments and other inbound content Anymail maintains compatibility with all Django versions that are in mainstream or extended support, plus (usually) a few older Django versions, and is extensively tested on all Python versions supported by Django. (Even-older Django versions may still be covered by an Anymail extended support release; consult the `changelog `_ for details.) Anymail releases follow `semantic versioning `_. The package is released under the BSD license. .. END shared-intro .. image:: https://github.com/anymail/django-anymail/workflows/test/badge.svg?branch=main :target: https://github.com/anymail/django-anymail/actions?query=workflow:test+branch:main :alt: test status in GitHub Actions .. image:: https://github.com/anymail/django-anymail/workflows/integration-test/badge.svg?branch=main :target: https://github.com/anymail/django-anymail/actions?query=workflow:integration-test+branch:main :alt: integration test status in GitHub Actions .. image:: https://readthedocs.org/projects/anymail/badge/?version=stable :target: https://anymail.dev/en/stable/ :alt: documentation build status on ReadTheDocs **Resources** * Full documentation: https://anymail.dev/en/stable/ * Help and troubleshooting: https://anymail.dev/en/stable/help/ * Package on PyPI: https://pypi.org/project/django-anymail/ * Project on Github: https://github.com/anymail/django-anymail * Changelog: https://anymail.dev/en/stable/changelog/ Anymail 1-2-3 ------------- .. _quickstart: .. This quickstart section is also included in docs/quickstart.rst Here's how to send a message. This example uses Mailgun, but you can substitute Mailjet or Postmark or SendGrid or SparkPost or any other supported ESP where you see "mailgun": 1. Install Anymail from PyPI: .. code-block:: console $ pip install "django-anymail[mailgun]" (The `[mailgun]` part installs any additional packages needed for that ESP. Mailgun doesn't have any, but some other ESPs do.) 2. Edit your project's ``settings.py``: .. code-block:: python INSTALLED_APPS = [ # ... "anymail", # ... ] ANYMAIL = { # (exact settings here depend on your ESP...) "MAILGUN_API_KEY": "", "MAILGUN_SENDER_DOMAIN": 'mg.example.com', # your Mailgun domain, if needed } EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend" # or sendgrid.EmailBackend, or... DEFAULT_FROM_EMAIL = "you@example.com" # if you don't already have this in settings SERVER_EMAIL = "your-server@example.com" # ditto (default from-email for Django errors) 3. Now the regular `Django email functions `_ will send through your chosen ESP: .. code-block:: python from django.core.mail import send_mail send_mail("It works!", "This will get sent through Mailgun", "Anymail Sender ", ["to@example.com"]) You could send an HTML message, complete with an inline image, custom tags and metadata: .. code-block:: python from django.core.mail import EmailMultiAlternatives from anymail.message import attach_inline_image_file msg = EmailMultiAlternatives( subject="Please activate your account", body="Click to activate your account: https://example.com/activate", from_email="Example ", to=["New User ", "account.manager@example.com"], reply_to=["Helpdesk "]) # Include an inline image in the html: logo_cid = attach_inline_image_file(msg, "/path/to/logo.jpg") html = """Logo

Please activate your account

""".format(logo_cid=logo_cid) msg.attach_alternative(html, "text/html") # Optional Anymail extensions: msg.metadata = {"user_id": "8675309", "experiment_variation": 1} msg.tags = ["activation", "onboarding"] msg.track_clicks = True # Send it: msg.send() .. END quickstart See the `full documentation `_ for more features and options, including receiving messages and tracking sent message status. django-anymail-13.0/anymail/000077500000000000000000000000001477357323300157775ustar00rootroot00000000000000django-anymail-13.0/anymail/__init__.py000066400000000000000000000001341477357323300201060ustar00rootroot00000000000000from ._version import VERSION, __version__ __all__ = [ "VERSION", "__version__", ] django-anymail-13.0/anymail/_version.py000066400000000000000000000003621477357323300201760ustar00rootroot00000000000000# Don't import this file directly (unless you are a build system). # Instead, load version info from the package root. #: major.minor or major.minor.patch (optionally with .devN suffix) __version__ = "13.0" VERSION = __version__.split(",") django-anymail-13.0/anymail/apps.py000066400000000000000000000005331477357323300173150ustar00rootroot00000000000000from django.apps import AppConfig from django.core import checks from .checks import check_deprecated_settings, check_insecure_settings class AnymailBaseConfig(AppConfig): name = "anymail" verbose_name = "Anymail" def ready(self): checks.register(check_deprecated_settings) checks.register(check_insecure_settings) django-anymail-13.0/anymail/backends/000077500000000000000000000000001477357323300175515ustar00rootroot00000000000000django-anymail-13.0/anymail/backends/__init__.py000066400000000000000000000000001477357323300216500ustar00rootroot00000000000000django-anymail-13.0/anymail/backends/amazon_ses.py000066400000000000000000000541521477357323300222710ustar00rootroot00000000000000import email.charset import email.encoders import email.policy from requests.structures import CaseInsensitiveDict from .. import __version__ as ANYMAIL_VERSION from ..exceptions import AnymailAPIError, AnymailImproperlyInstalled from ..message import AnymailRecipientStatus 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", install_extra="amazon-ses" ) from err # boto3 has several root exception classes; this is meant to cover all of them BOTO_BASE_ERRORS = (BotoCoreError, ClientError, ConnectionError) class EmailBackend(AnymailBaseBackend): """ Amazon SES v2 Email Backend (using boto3) """ esp_name = "Amazon SES" 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( esp_name=self.esp_name, 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( "sesv2", **self.client_params ) except Exception: if not self.fail_silently: raise else: return True # created client def close(self): if self.client is None: return self.client.close() self.client = None def _send(self, message): if self.client: return super()._send(message) elif self.fail_silently: # (Probably missing boto3 credentials in open().) return False else: class_name = self.__class__.__name__ raise RuntimeError( "boto3 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 ) ) def build_message_payload(self, message, defaults): if getattr(message, "template_id", UNSET) is not UNSET: # For simplicity, use SESv2 SendBulkEmail for all templated messages # (even though SESv2 SendEmail has a template option). return AmazonSESV2SendBulkEmailPayload(message, defaults, self) else: return AmazonSESV2SendEmailPayload(message, defaults, self) def post_to_esp(self, payload, message): payload.finalize_payload() try: client_send_api = getattr(self.client, payload.api_name) except AttributeError: raise NotImplementedError( f"{self.client!r} does not have method {payload.api_name!r}." ) from None try: response = client_send_api(**payload.params) 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 return response def parse_recipient_status(self, response, payload, message): return payload.parse_recipient_status(response) class AmazonSESBasePayload(BasePayload): #: Name of the boto3 SES/SESv2 client method to call api_name = "SUBCLASS_MUST_OVERRIDE" def init_payload(self): self.params = {} if self.backend.configuration_set_name is not None: self.params["ConfigurationSetName"] = self.backend.configuration_set_name def finalize_payload(self): pass def parse_recipient_status(self, response): # response is the parsed (dict) JSON returned from the API call raise NotImplementedError() def set_esp_extra(self, extra): # e.g., ConfigurationSetName, FromEmailAddressIdentityArn, # FeedbackForwardingEmailAddress, ListManagementOptions self.params.update(extra) class AmazonSESV2SendEmailPayload(AmazonSESBasePayload): api_name = "send_email" def init_payload(self): super().init_payload() self.all_recipients = [] # for parse_recipient_status self.mime_message = self.message.message() def finalize_payload(self): # (The boto3 SES client handles base64 encoding raw_message.) raw_message = self.generate_raw_message() self.params["Content"] = {"Raw": {"Data": raw_message}} def generate_raw_message(self): """ Serialize self.mime_message as an RFC-5322/-2045 MIME message, encoded as 7bit-clean, us-ascii byte data. """ # Amazon SES discourages `Content-Transfer-Encoding: 8bit`. And using # 8bit with SES open or click tracking results in mis-encoded characters. # To avoid this, convert any 8bit parts to 7bit quoted printable or base64. # (We own self.mime_message, so destructively modifying it should be OK.) for part in self.mime_message.walk(): if part["Content-Transfer-Encoding"] == "8bit": del part["Content-Transfer-Encoding"] if part.get_content_maintype() == "text": # (Avoid base64 for text parts, which can trigger spam filters) email.encoders.encode_quopri(part) else: email.encoders.encode_base64(part) # (All message and part headers should already be 7bit clean, # so there's no need to try to override email.policy here.) return self.mime_message.as_bytes() def parse_recipient_status(self, response): try: message_id = response["MessageId"] except (KeyError, TypeError) as err: raise AnymailAPIError( f"{err!s} parsing Amazon SES send result {response!r}", 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 } # Standard EmailMessage attrs... # 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( f"Anymail send defaults for '{attr}' with Amazon SES" ) def set_from_email(self, email): # If params["FromEmailAddress"] is not provided, SES will parse it from the raw # mime_message headers. (And setting it replaces any From header. Note that # v2 SendEmail doesn't have an equivalent to v1 SendRawEmail's Sender param.) self._no_send_defaults("from_email") def set_recipients(self, recipient_type, emails): # Although Amazon SES can parse the 'to' and 'cc' recipients from the raw # mime_message headers, providing them in the Destination param makes it # explicit (and is required for 'bcc' and for spoofed 'to'). self.all_recipients += emails # save for parse_recipient_status self._no_send_defaults(recipient_type) if emails: # params["Destination"] = {"ToAddresses": [...], "CcAddresses": etc.} # (Unlike most SendEmail params, these _don't_ replace the corresponding # raw mime_message headers.) assert recipient_type in ("to", "cc", "bcc") destination_key = f"{recipient_type.capitalize()}Addresses" self.params.setdefault("Destination", {})[destination_key] = [ email.address for email in emails ] def set_subject(self, subject): # included in mime_message self._no_send_defaults("subject") def set_reply_to(self, emails): # included in mime_message # (and setting params["ReplyToAddresses"] replaces any Reply-To header) self._no_send_defaults("reply_to") def set_extra_headers(self, headers): # included in mime_message self._no_send_defaults("extra_headers") def set_text_body(self, body): # included in mime_message self._no_send_defaults("body") def set_html_body(self, body): # included in mime_message self._no_send_defaults("body") def set_alternatives(self, alternatives): # included in mime_message self._no_send_defaults("alternatives") def set_attachments(self, attachments): # included in mime_message self._no_send_defaults("attachments") # Anymail-specific payload construction def set_envelope_sender(self, email): # Amazon SES will generate a unique mailfrom, and then forward any delivery # problem reports that address receives to the address specified here: self.params["FeedbackForwardingEmailAddress"] = email.addr_spec def set_spoofed_to_header(self, header_to): # django.core.mail.EmailMessage.message() has already set # self.mime_message["To"] = header_to # and performed any necessary header sanitization. # # The actual "to" is already in params["Destination"]["ToAddresses"]. # # So, nothing to do here, except prevent the default # "unsupported feature" error. pass def set_metadata(self, metadata): # Amazon SES has two mechanisms for adding custom data to a message: # * Custom message headers are available to webhooks (SNS notifications), # but not in CloudWatch metrics/dashboards or Kinesis Firehose streams. # Custom headers can be sent only with SendRawEmail. # * "Message Tags" are available to CloudWatch and Firehose, and to SNS # 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/ # and https://forums.aws.amazon.com/thread.jspa?messageID=782922.) # To support reliable retrieval in webhooks, just use custom headers for # metadata. self.mime_message["X-Metadata"] = self.serialize_json(metadata) def set_merge_headers(self, merge_headers): self.unsupported_feature("merge_headers without template_id") 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. # (There are no restrictions on number or content for custom header tags.) for tag in tags: # 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.) 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.params.setdefault("EmailTags", []).append( {"Name": self.backend.message_tag_name, "Value": tags[0]} ) def set_template_id(self, template_id): raise NotImplementedError( f"{self.__class__.__name__} should not have been used with template_id" ) def set_merge_data(self, merge_data): self.unsupported_feature("merge_data without template_id") def set_merge_global_data(self, merge_global_data): self.unsupported_feature("global_merge_data without template_id") class AmazonSESV2SendBulkEmailPayload(AmazonSESBasePayload): api_name = "send_bulk_email" def init_payload(self): super().init_payload() # late-bind in finalize_payload: self.recipients = {"to": [], "cc": [], "bcc": []} self.merge_data = {} self.headers = {} self.merge_headers = {} self.metadata = {} self.merge_metadata = {} self.tags = [] def finalize_payload(self): # Build BulkEmailEntries from recipients and merge_data. # Any cc and bcc recipients should be included in every entry: cc_and_bcc_addresses = {} if 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"] ] # Construct an entry with merge data for each "to" recipient: self.params["BulkEmailEntries"] = [] for to in self.recipients["to"]: entry = { "Destination": dict(ToAddresses=[to.address], **cc_and_bcc_addresses), "ReplacementEmailContent": { "ReplacementTemplate": { "ReplacementTemplateData": self.serialize_json( self.merge_data.get(to.addr_spec, {}) ), } }, } replacement_headers = [] if self.headers or to.addr_spec in self.merge_headers: headers = CaseInsensitiveDict(self.headers) headers.update(self.merge_headers.get(to.addr_spec, {})) replacement_headers += [ {"Name": key, "Value": value} for key, value in headers.items() ] if self.metadata or to.addr_spec in self.merge_metadata: metadata = self.metadata.copy() metadata.update(self.merge_metadata.get(to.addr_spec, {})) if metadata: replacement_headers.append( {"Name": "X-Metadata", "Value": self.serialize_json(metadata)} ) if self.tags: replacement_headers += [ {"Name": "X-Tag", "Value": tag} for tag in self.tags ] if replacement_headers: entry["ReplacementHeaders"] = replacement_headers self.params["BulkEmailEntries"].append(entry) def parse_recipient_status(self, response): try: results = response["BulkEmailEntryResults"] ses_status_set = set(result["Status"] for result in results) anymail_statuses = [ AnymailRecipientStatus( message_id=result.get("MessageId", None), status="queued" if result["Status"] == "SUCCESS" else "failed", ) for result in results ] except (KeyError, TypeError) as err: raise AnymailAPIError( f"{err!s} parsing Amazon SES send result {response!r}", backend=self.backend, email_message=self.message, payload=self, ) from None # If all BulkEmailEntryResults[].Status are the same non-success status, # raise an APIError to expose the error message/reason (matching behavior # of non-template SendEmail call). if len(ses_status_set) == 1 and ses_status_set != {"SUCCESS"}: raise AnymailAPIError( # use Error text if available, else the Status enum, from first result results[0].get("Error", results[0]["Status"]), backend=self.backend, email_message=self.message, payload=self, response=response, ) # Otherwise, return per-recipient status (just "queued" or "failed") for # all-success, mixed success/error, or all-error mixed-reason cases. # The BulkEmailEntryResults are in the same order as the Destination param # (which is in the same order as recipients["to"]). to_addrs = [to.addr_spec for to in self.recipients["to"]] if len(anymail_statuses) != len(to_addrs): raise AnymailAPIError( f"Sent to {len(to_addrs)} destinations," f" but only {len(anymail_statuses)} statuses" f" in Amazon SES send result {response!r}", backend=self.backend, email_message=self.message, payload=self, ) return dict(zip(to_addrs, anymail_statuses)) def set_from_email(self, email): # this will RFC2047-encode display_name if needed: self.params["FromEmailAddress"] = email.address def set_recipients(self, recipient_type, emails): # late-bound in finalize_payload assert recipient_type in ("to", "cc", "bcc") self.recipients[recipient_type] = emails def set_subject(self, subject): # (subject can only come from template; you can use substitution vars in that) if subject: self.unsupported_feature("overriding template subject") def set_reply_to(self, emails): if emails: self.params["ReplyToAddresses"] = [email.address for email in emails] def set_extra_headers(self, headers): self.headers = headers def set_text_body(self, body): if body: self.unsupported_feature("overriding template body content") def set_html_body(self, body): if body: self.unsupported_feature("overriding template body content") def set_attachments(self, attachments): if attachments: self.unsupported_feature("attachments with template") # Anymail-specific payload construction def set_envelope_sender(self, email): # Amazon SES will generate a unique mailfrom, and then forward any delivery # problem reports that address receives to the address specified here: self.params["FeedbackForwardingEmailAddress"] = email.addr_spec def set_metadata(self, metadata): self.metadata = metadata def set_merge_metadata(self, merge_metadata): self.merge_metadata = merge_metadata def set_tags(self, tags): self.tags = tags # 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.) 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.params["DefaultEmailTags"] = [ {"Name": self.backend.message_tag_name, "Value": tags[0]} ] def set_template_id(self, template_id): # DefaultContent.Template.TemplateName self.params.setdefault("DefaultContent", {}).setdefault("Template", {})[ "TemplateName" ] = template_id def set_merge_data(self, merge_data): # late-bound in finalize_payload self.merge_data = merge_data def set_merge_headers(self, merge_headers): # late-bound in finalize_payload self.merge_headers = merge_headers def set_merge_global_data(self, merge_global_data): # DefaultContent.Template.TemplateData self.params.setdefault("DefaultContent", {}).setdefault("Template", {})[ "TemplateData" ] = self.serialize_json(merge_global_data) def _get_anymail_boto3_params(esp_name=EmailBackend.esp_name, kwargs=None): """Returns 2 dicts of params for boto3.session.Session() and .client() Incorporates ANYMAIL["AMAZON_SES_SESSION_PARAMS"] and ANYMAIL["AMAZON_SES_CLIENT_PARAMS"] settings. Converts config dict to botocore.client.Config if needed 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={} ) # 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=ANYMAIL_VERSION ) ) if "config" in client_params: # convert config dict to botocore.client.Config if needed client_params_config = client_params["config"] if not isinstance(client_params_config, Config): client_params_config = Config(**client_params_config) config = config.merge(client_params_config) client_params["config"] = config return session_params, client_params django-anymail-13.0/anymail/backends/base.py000066400000000000000000000622341477357323300210440ustar00rootroot00000000000000import json from datetime import date, datetime, timezone from django.conf import settings from django.core.mail.backends.base import BaseEmailBackend from django.utils.timezone import get_current_timezone, is_naive, make_aware from requests.structures import CaseInsensitiveDict from ..exceptions import ( AnymailCancelSend, AnymailError, AnymailRecipientsRefused, AnymailSerializationError, AnymailUnsupportedFeature, ) from ..message import AnymailStatus from ..signals import post_send, pre_send from ..utils import ( UNSET, Attachment, concat_lists, force_non_lazy, force_non_lazy_dict, force_non_lazy_list, get_anymail_setting, is_lazy, last, merge_dicts_deep, merge_dicts_one_level, merge_dicts_shallow, parse_address_list, parse_single_address, ) class AnymailBaseBackend(BaseEmailBackend): """ Base Anymail email backend """ 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", 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 ) if esp_send_defaults is not None: send_defaults = send_defaults.copy() send_defaults.update(esp_send_defaults) self.send_defaults = send_defaults def open(self): """ Open and persist a connection to the ESP's API, and whether a new connection was created. Callers must ensure they later call close, if (and only if) open returns True. """ # Subclasses should use an instance property to maintain a cached # connection, and return True iff they initialize that instance # property in _this_ open call. (If the cached connection already # exists, just do nothing and return False.) # # Subclasses should swallow operational errors if self.fail_silently # (e.g., network errors), but otherwise can raise any errors. # # (Returning a bool to indicate whether connection was created is # borrowed from django.core.email.backends.SMTPBackend) return False def close(self): """ Close the cached connection created by open. You must only call close if your code called open and it returned True. """ # Subclasses should tear down the cached connection and clear # the instance property. # # Subclasses should swallow operational errors if self.fail_silently # (e.g., network errors), but otherwise can raise any errors. pass def send_messages(self, email_messages): """ Sends one or more EmailMessage objects and returns the number of email messages sent. """ # This API is specified by Django's core BaseEmailBackend # (so you can't change it to, e.g., return detailed status). # Subclasses shouldn't need to override. num_sent = 0 if not email_messages: return num_sent created_session = self.open() try: for message in email_messages: try: sent = self._send(message) except AnymailError: if self.fail_silently: sent = False else: raise if sent: num_sent += 1 finally: if created_session: self.close() return num_sent def _send(self, message): """Sends the EmailMessage message, and returns True if the message was sent. This should only be called by the base send_messages loop. Implementations must raise exceptions derived from AnymailError for anticipated failures that should be suppressed in fail_silently mode. """ message.anymail_status = AnymailStatus() if not self.run_pre_send(message): # (might modify message) return False # cancel send without error if not message.recipients(): return False payload = self.build_message_payload(message, self.send_defaults) response = self.post_to_esp(payload, message) message.anymail_status.esp_response = response recipient_status = self.parse_recipient_status(response, payload, message) 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 ) return True def run_pre_send(self, message): """Send pre_send signal, and return True if message should still be sent""" try: pre_send.send(self.__class__, message=message, esp_name=self.esp_name) return True except AnymailCancelSend: return False # abort without causing error 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: if isinstance(response, Exception): raise response def build_message_payload(self, message, defaults): """Returns a payload that will allow message to be sent via the ESP. Derived classes must implement, and should subclass :class:BasePayload to get standard Anymail options. Raises :exc:AnymailUnsupportedFeature for message options that cannot be communicated to the ESP. :param message: :class:EmailMessage :param defaults: dict :return: :class:BasePayload """ 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. payload is the result of build_message_payload message is the original EmailMessage return should be a raw response 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__) ) 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__) ) def raise_for_recipient_status(self, anymail_status, response, payload, message): """ 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.) if anymail_status.status.issubset({"invalid", "rejected"}): raise AnymailRecipientsRefused( email_message=message, payload=payload, response=response, backend=self, ) @property def esp_name(self): """ Read-only name of the ESP for this backend. Concrete backends must override with class attr. E.g.: 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__) ) class BasePayload: # Listing of EmailMessage/EmailMultiAlternatives attributes # to process into Payload. Each item is in the form: # (attr, combiner, converter) # attr: the property name # combiner: optional function(default_value, value) -> value # to combine settings defaults with the EmailMessage property value # (use `None` if settings defaults aren't supported) # converter: optional function(value) -> value transformation # (can be a callable or the string name of a Payload method, or `None`) # The converter must force any Django lazy translation strings to text. # The Payload's `set_` method will be called with # 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", concat_lists, parse_address_list), ("cc", concat_lists, parse_address_list), ("bcc", concat_lists, parse_address_list), ("subject", last, force_non_lazy), ("reply_to", concat_lists, parse_address_list), ("extra_headers", merge_dicts_shallow, force_non_lazy_dict), ("body", last, force_non_lazy), # set_body handles content_subtype ("alternatives", concat_lists, "prepped_alternatives"), ("attachments", concat_lists, "prepped_attachments"), ) anymail_message_attrs = ( # Anymail expando-props ("envelope_sender", last, parse_single_address), ("metadata", merge_dicts_shallow, force_non_lazy_dict), ("send_at", last, "aware_datetime"), ("tags", concat_lists, force_non_lazy_list), ("track_clicks", last, None), ("track_opens", last, None), ("template_id", last, force_non_lazy), ("merge_data", merge_dicts_one_level, force_non_lazy_dict), ("merge_global_data", merge_dicts_shallow, force_non_lazy_dict), ("merge_headers", None, None), ("merge_metadata", merge_dicts_one_level, force_non_lazy_dict), ("esp_extra", merge_dicts_deep, 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_headers", "merge_metadata") def __init__(self, message, defaults, backend): self.message = message self.defaults = defaults self.backend = backend self.esp_name = backend.esp_name self._batch_attrs_used = {attr: UNSET for attr in self.batch_attrs} 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 ) 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: self.validate_not_bare_string(attr, value) if combiner is not None: default_value = self.defaults.get(attr, UNSET) value = combiner(default_value, value) if value is not UNSET: if converter is not None: if not callable(converter): converter = getattr(self, converter) if converter in (parse_address_list, parse_single_address): # hack to include field name in error message value = converter(value, field=attr) else: value = converter(value) if value is not UNSET: if attr == "from_email": setter = self.set_from_email_list 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) setter(value) if attr in self.batch_attrs: self._batch_attrs_used[attr] = value is not UNSET def is_batch(self): """ Return True if the message should be treated as a batch send. Intended to be used inside serialize_data or similar, after all relevant attributes have been processed. Will error if called before that (e.g., 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" 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, ) 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.) # 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']") ) 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 # 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") self.set_spoofed_to_header(header_to) if headers: self.set_extra_headers(headers) # # Attribute validators # def validate_not_bare_string(self, attr, value): """EmailMessage to, cc, bcc, and reply_to are specced to be lists of strings. This catches the common error where a single string is used instead. (See also checks in EmailMessage.__init__.) """ # Note: this actually only runs for reply_to. If to, cc, or bcc are # set to single strings, you'll end up with an earlier cryptic TypeError # from EmailMesssage.recipients (called from EmailMessage.send) before # the Anymail backend even gets involved: # TypeError: must be str, not list # 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) ) # # Attribute converters # def prepped_alternatives(self, 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 ] def aware_datetime(self, value): """Converts a date or datetime or timestamp to an aware datetime. Naive datetimes are assumed to be in Django's current_timezone. Dates are interpreted as midnight that date, in Django's current_timezone. Integers are interpreted as POSIX timestamps (which are inherently UTC). Anything else (e.g., str) is returned unchanged, which won't be portable. """ if isinstance(value, datetime): dt = value else: if isinstance(value, date): dt = datetime(value.year, value.month, value.day) # naive, midnight else: try: dt = datetime.fromtimestamp(value, timezone.utc) except (TypeError, ValueError): return value if is_naive(dt): dt = make_aware(dt, get_current_timezone()) return dt # # Abstract implementation # def init_payload(self): 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 len(emails) > 1: self.unsupported_feature("multiple from emails") # fall through if ignoring unsupported features if len(emails) > 0: 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__) ) def set_to(self, emails): return self.set_recipients("to", emails) def set_cc(self, emails): return self.set_recipients("cc", emails) def set_bcc(self, 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__) ) def set_subject(self, subject): 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") def set_extra_headers(self, headers): # headers is a CaseInsensitiveDict, and is a copy (so is safe to modify) self.unsupported_feature("extra_headers") def set_body(self, body): # Interpret message.body depending on message.content_subtype. # (Subclasses should generally implement set_text_body and set_html_body # rather than overriding this.) content_subtype = self.message.content_subtype if content_subtype == "plain": self.set_text_body(body) elif content_subtype == "html": self.set_html_body(body) else: 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__) ) def set_html_body(self, body): 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. # (Subclasses should generally implement add_alternative # rather than overriding this.) has_plain_body = self.message.content_subtype == "plain" and self.message.body has_html_body = self.message.content_subtype == "html" and self.message.body for content, mimetype in alternatives: if mimetype == "text/plain" and not has_plain_body: self.set_text_body(content) has_plain_body = True elif mimetype == "text/html" and not has_html_body: self.set_html_body(content) has_html_body = True else: self.add_alternative(content, mimetype) def add_alternative(self, content, mimetype): if mimetype == "text/plain": self.unsupported_feature("multiple plaintext parts") elif mimetype == "text/html": self.unsupported_feature("multiple html parts") else: self.unsupported_feature("alternative part with type '%s'" % mimetype) def set_attachments(self, attachments): for attachment in attachments: 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__) ) 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. self.unsupported_feature("spoofing `To` header") # Anymail-specific payload construction def set_envelope_sender(self, email): self.unsupported_feature("envelope_sender") def set_metadata(self, metadata): self.unsupported_feature("metadata") def set_send_at(self, send_at): self.unsupported_feature("send_at") def set_tags(self, tags): self.unsupported_feature("tags") def set_track_clicks(self, track_clicks): self.unsupported_feature("track_clicks") def set_track_opens(self, track_opens): self.unsupported_feature("track_opens") def set_template_id(self, template_id): self.unsupported_feature("template_id") def set_merge_data(self, merge_data): self.unsupported_feature("merge_data") def set_merge_headers(self, merge_headers): self.unsupported_feature("merge_headers") def set_merge_global_data(self, merge_global_data): self.unsupported_feature("merge_global_data") def set_merge_metadata(self, merge_metadata): self.unsupported_feature("merge_metadata") # ESP-specific payload construction def set_esp_extra(self, extra): self.unsupported_feature("esp_extra") # # Helpers for concrete implementations # def serialize_json(self, data): """Returns data serialized to json, raising appropriate errors. Essentially json.dumps with added context in any errors. Useful for implementing, e.g., serialize_data in a subclass, """ try: 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 @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__ ) django-anymail-13.0/anymail/backends/base_requests.py000066400000000000000000000174321477357323300227770ustar00rootroot00000000000000from urllib.parse import urljoin import requests from anymail.utils import get_anymail_setting from .._version import __version__ from ..exceptions import AnymailRequestsAPIError from .base import AnymailBaseBackend, BasePayload class AnymailRequestsBackend(AnymailBaseBackend): """ Base Anymail email backend for ESPs that use an HTTP API via requests """ 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 ) super().__init__(**kwargs) self.session = None def open(self): if self.session: return False # already exists try: self.session = self.create_session() except Exception: if not self.fail_silently: raise return True def close(self): if self.session is None: return try: self.session.close() except requests.RequestException: if not self.fail_silently: raise finally: self.session = None def _send(self, message): if self.session: return super()._send(message) elif self.fail_silently: # create_session failed return False else: class_name = self.__class__.__name__ 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 ) ) def create_session(self): """Create the requests.Session object used by this instance of the backend. If subclassed, you can modify the Session returned from super() to give it your own configuration. This must return an instance of requests.Session.""" 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", ""), ) if self.debug_api_requests: session.hooks["response"].append(self._dump_api_request) return session def post_to_esp(self, payload, message): """Post payload to ESP send API endpoint, and return the raw response. payload is the result of build_message_payload message is the original EmailMessage return should be a requests.Response Can raise AnymailRequestsAPIError for HTTP errors in the post """ params = payload.get_request_params(self.api_url) 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)), {} ) raise exc_class( "Error posting to %s:" % params.get("url", ""), email_message=message, payload=payload, ) from err self.raise_for_status(response, payload, message) return response def raise_for_status(self, response, payload, message): """Raise AnymailRequestsAPIError if response is an HTTP error Subclasses can override for custom error checking (though should defer parsing/deserialization of the body to parse_recipient_status) """ if response.status_code < 200 or response.status_code >= 300: raise AnymailRequestsAPIError( 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. Useful for implementing deserialize_response """ 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 @staticmethod def _dump_api_request(response, **kwargs): """Print the request and response for debugging""" # (This is not byte-for-byte, but a readable text representation that assumes # UTF-8 encoding if encoded, and that omits the CR in CRLF line endings. # If you need the raw bytes, configure HTTPConnection logging as shown # 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() ), ) ) 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") 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 ) ) 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, ): self.method = method self.params = params self.data = data self.headers = headers self.files = files self.auth = auth super().__init__(message, defaults, backend) def get_request_params(self, api_url): """Returns a dict of requests.request params that will send payload to the ESP. :param api_url: the base api_url for the backend :return: dict """ api_endpoint = self.get_api_endpoint() if api_endpoint is not None: url = urljoin(api_url, api_endpoint) else: url = api_url return dict( method=self.method, url=url, params=self.params, data=self.serialize_data(), headers=self.headers, files=self.files, auth=self.auth, # json= is not here, because we prefer to do our own serialization # to provide extra context in error messages ) def get_api_endpoint(self): """ Returns a str that should be joined to the backend's api_url for sending this payload. """ return None def serialize_data(self): """Performs any necessary serialization on self.data, and returns the result.""" return self.data django-anymail-13.0/anymail/backends/brevo.py000066400000000000000000000204751477357323300212500ustar00rootroot00000000000000from requests.structures import CaseInsensitiveDict from ..exceptions import AnymailRequestsAPIError from ..message import AnymailRecipientStatus from ..utils import BASIC_NUMERIC_TYPES, get_anymail_setting from .base_requests import AnymailRequestsBackend, RequestsPayload class EmailBackend(AnymailRequestsBackend): """ Brevo v3 API Email Backend """ esp_name = "Brevo" 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://api.brevo.com/v3/", ) if not api_url.endswith("/"): api_url += "/" super().__init__(api_url, **kwargs) def build_message_payload(self, message, defaults): return BrevoPayload(message, defaults, self) def parse_recipient_status(self, response, payload, message): # Brevo doesn't give any detail on a success, other than messageId # https://developers.brevo.com/reference/sendtransacemail message_id = None message_ids = [] if response.content != b"": parsed_response = self.deserialize_json_response(response, payload, message) try: message_id = parsed_response["messageId"] except (KeyError, TypeError): try: # batch send message_ids = parsed_response["messageIds"] except (KeyError, TypeError) as err: raise AnymailRequestsAPIError( "Invalid Brevo API response format", email_message=message, payload=payload, response=response, backend=self, ) from err status = AnymailRecipientStatus(message_id=message_id, status="queued") recipient_status = { recipient.addr_spec: status for recipient in payload.all_recipients } if message_ids: for to, message_id in zip(payload.to_recipients, message_ids): recipient_status[to.addr_spec] = AnymailRecipientStatus( message_id=message_id, status="queued" ) return recipient_status class BrevoPayload(RequestsPayload): def __init__(self, message, defaults, backend, *args, **kwargs): self.all_recipients = [] # used for backend.parse_recipient_status self.to_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" super().__init__( message, defaults, backend, headers=http_headers, *args, **kwargs ) def get_api_endpoint(self): return "smtp/email" def init_payload(self): self.data = {"headers": CaseInsensitiveDict()} # becomes json self.merge_data = {} self.metadata = {} self.merge_metadata = {} self.merge_headers = {} def serialize_data(self): """Performs any necessary serialization on self.data, and returns the result.""" if self.is_batch(): # Burst data["to"] into data["messageVersions"] to_list = self.data.pop("to", []) self.data["messageVersions"] = [] for to in to_list: to_email = to["email"] version = {"to": [to]} headers = CaseInsensitiveDict() if to_email in self.merge_data: version["params"] = self.merge_data[to_email] if to_email in self.merge_metadata: # Merge global metadata with any per-recipient metadata. # (Top-level X-Mailin-custom header already has global metadata, # and will apply for recipients without version headers.) recipient_metadata = self.metadata.copy() recipient_metadata.update(self.merge_metadata[to_email]) headers["X-Mailin-custom"] = self.serialize_json(recipient_metadata) if to_email in self.merge_headers: headers.update(self.merge_headers[to_email]) if headers: version["headers"] = headers self.data["messageVersions"].append(version) if not self.data["headers"]: del self.data["headers"] # don't send empty headers return self.serialize_json(self.data) # # Payload construction # @staticmethod def email_object(email): """Converts EmailAddress to Brevo API array""" email_object = dict() email_object["email"] = email.addr_spec if email.display_name: email_object["name"] = email.display_name return email_object def set_from_email(self, email): self.data["sender"] = self.email_object(email) def set_recipients(self, recipient_type, emails): assert recipient_type in ["to", "cc", "bcc"] if emails: self.data[recipient_type] = [self.email_object(email) for email in emails] self.all_recipients += emails # used for backend.parse_recipient_status if recipient_type == "to": self.to_recipients = emails # used for backend.parse_recipient_status def set_subject(self, subject): if subject != "": # see note in set_text_body about template rendering self.data["subject"] = subject def set_reply_to(self, emails): # Brevo only supports a single address in the reply_to API param. if len(emails) > 1: self.unsupported_feature("multiple reply_to addresses") if len(emails) > 0: self.data["replyTo"] = self.email_object(emails[0]) def set_extra_headers(self, headers): # Brevo 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 def set_template_id(self, template_id): self.data["templateId"] = template_id def set_text_body(self, body): if 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 def add_attachment(self, attachment): """Converts attachments to Brevo API {name, base64} array""" att = { "name": attachment.name or "", "content": attachment.b64content, } if attachment.inline: self.unsupported_feature("inline attachments") self.data.setdefault("attachment", []).append(att) def set_esp_extra(self, extra): self.data.update(extra) def set_merge_data(self, merge_data): # Late bound in serialize_data: self.merge_data = merge_data def set_merge_global_data(self, merge_global_data): self.data["params"] = merge_global_data def set_metadata(self, metadata): # Brevo expects a single string payload self.data["headers"]["X-Mailin-custom"] = self.serialize_json(metadata) self.metadata = metadata # needed in serialize_data for batch send def set_merge_metadata(self, merge_metadata): # Late-bound in serialize_data: self.merge_metadata = merge_metadata def set_merge_headers(self, merge_headers): # Late-bound in serialize_data: self.merge_headers = merge_headers 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 django-anymail-13.0/anymail/backends/console.py000066400000000000000000000025241477357323300215700ustar00rootroot00000000000000import uuid from django.core.mail.backends.console import EmailBackend as DjangoConsoleBackend from ..exceptions import AnymailError from .test import EmailBackend as AnymailTestBackend class EmailBackend(AnymailTestBackend, DjangoConsoleBackend): """ Anymail backend that prints messages to the console, while retaining anymail statuses and signals. """ esp_name = "Console" def get_esp_message_id(self, message): # Generate a guaranteed-unique ID for the message return str(uuid.uuid4()) def send_messages(self, email_messages): if not email_messages: return msg_count = 0 with self._lock: try: stream_created = self.open() for message in email_messages: try: sent = self._send(message) except AnymailError: if self.fail_silently: sent = False else: raise if sent: self.write_message(message) self.stream.flush() # flush after each message msg_count += 1 finally: if stream_created: self.close() return msg_count django-anymail-13.0/anymail/backends/mailersend.py000066400000000000000000000325771477357323300222640ustar00rootroot00000000000000import mimetypes from ..exceptions import AnymailRequestsAPIError, AnymailUnsupportedFeature from ..message import AnymailRecipientStatus from ..utils import CaseInsensitiveCasePreservingDict, get_anymail_setting, update_deep from .base_requests import AnymailRequestsBackend, RequestsPayload class EmailBackend(AnymailRequestsBackend): """ MailerSend Email Backend """ esp_name = "MailerSend" def __init__(self, **kwargs): """Init options from Django settings""" esp_name = self.esp_name self.api_token = get_anymail_setting( "api_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.mailersend.com/v1/", ) if not api_url.endswith("/"): api_url += "/" #: Can set to "use-bulk-email" or "expose-to-list" or default None self.batch_send_mode = get_anymail_setting( "batch_send_mode", default=None, esp_name=esp_name, kwargs=kwargs ) super().__init__(api_url, **kwargs) def build_message_payload(self, message, defaults): return MailerSendPayload(message, defaults, self) def parse_recipient_status(self, response, payload, message): # The "email" API endpoint responds with an empty text/html body # if no warnings, otherwise json with suppression info. # The "bulk-email" API endpoint always returns json. if response.headers["Content-Type"] == "application/json": parsed_response = self.deserialize_json_response(response, payload, message) else: parsed_response = {} try: # "email" API endpoint success or SOME_SUPPRESSED message_id = response.headers["X-Message-Id"] default_status = "queued" except KeyError: try: # "bulk-email" API endpoint bulk_id = parsed_response["bulk_email_id"] # Add "bulk:" prefix to distinguish from actual message_id. message_id = f"bulk:{bulk_id}" # Status is determined later; must query API to find out default_status = "unknown" except KeyError: # "email" API endpoint with ALL_SUPPRESSED message_id = None default_status = "failed" # Don't swallow errors (which should have been handled with a non-2xx # status, earlier) or any warnings that we won't consume below. errors = parsed_response.get("errors", []) warnings = parsed_response.get("warnings", []) if errors or any( warning["type"] not in ("ALL_SUPPRESSED", "SOME_SUPPRESSED") for warning in warnings ): raise AnymailRequestsAPIError( "Unexpected MailerSend API response errors/warnings", email_message=message, payload=payload, response=response, backend=self, ) # Collect a list of all problem recipients from any suppression warnings. # (warnings[].recipients[].reason[] will contain some combination of # "hard_bounced", "spam_complaint", "unsubscribed", and/or # "blocklisted", all of which map to Anymail's "rejected" status.) try: # warning["type"] is guaranteed to be {ALL,SOME}_SUPPRESSED at this point. rejected_emails = [ recipient["email"] for warning in warnings for recipient in warning["recipients"] ] except (KeyError, TypeError) as err: raise AnymailRequestsAPIError( f"Unexpected MailerSend API response format: {err!s}", email_message=message, payload=payload, response=response, backend=self, ) from None recipient_status = CaseInsensitiveCasePreservingDict( { recipient.addr_spec: AnymailRecipientStatus( message_id=message_id, status=default_status ) for recipient in payload.all_recipients } ) for rejected_email in rejected_emails: recipient_status[rejected_email] = AnymailRecipientStatus( message_id=None, status="rejected" ) return dict(recipient_status) class MailerSendPayload(RequestsPayload): def __init__(self, message, defaults, backend, *args, **kwargs): headers = { "Content-Type": "application/json", "Accept": "application/json", # Token may be changed in set_esp_extra below: "Authorization": f"Bearer {backend.api_token}", } self.all_recipients = [] # needed for parse_recipient_status self.merge_data = {} # late bound self.merge_global_data = None # late bound self.batch_send_mode = backend.batch_send_mode # can override in esp_extra super().__init__(message, defaults, backend, headers=headers, *args, **kwargs) def get_api_endpoint(self): if self.is_batch(): # MailerSend's "email" endpoint supports per-recipient customizations # (merge_data) for batch sending, but exposes the complete "To" list # to all recipients. This conflicts with Anymail's batch send model, which # expects each recipient can only see their own "To" email. # # MailerSend's "bulk-email" endpoint can send separate messages to each # "To" email, but doesn't return a message_id. (It returns a batch_email_id # that can later resolve to message_ids by polling a status API.) # # Since either of these would cause unexpected behavior, require the user # to opt into one via batch_send_mode. if self.batch_send_mode == "use-bulk-email": return "bulk-email" elif self.batch_send_mode == "expose-to-list": return "email" elif len(self.data["to"]) <= 1: # With only one "to", exposing the recipient list is moot. # (This covers the common case of single-recipient template merge.) return "email" else: # Unconditionally raise, even if IGNORE_UNSUPPORTED_FEATURES enabled. # We can't guess which API to use for this send. raise AnymailUnsupportedFeature( f"{self.esp_name} requires MAILERSEND_BATCH_SEND_MODE set to either" f" 'use-bulk-email' or 'expose-to-list' for using batch send" f" (merge_data) with multiple recipients. See the Anymail docs." ) else: return "email" def serialize_data(self): api_endpoint = self.get_api_endpoint() needs_personalization = self.merge_data or self.merge_global_data if api_endpoint == "email": if needs_personalization: self.data["personalization"] = [ self.personalization_for_email(to["email"]) for to in self.data["to"] ] data = self.data elif api_endpoint == "bulk-email": # Burst the payload into individual bulk-email recipients: data = [] for to in self.data["to"]: recipient_data = self.data.copy() recipient_data["to"] = [to] if needs_personalization: recipient_data["personalization"] = [ self.personalization_for_email(to["email"]) ] data.append(recipient_data) else: raise AssertionError( f"MailerSendPayload.serialize_data missing" f" case for api_endpoint {api_endpoint!r}" ) return self.serialize_json(data) def personalization_for_email(self, email): """ Return a MailerSend personalization object for email address. Composes merge_global_data and merge_data[email]. """ if email in self.merge_data: if self.merge_global_data: recipient_data = self.merge_global_data.copy() recipient_data.update(self.merge_data[email]) else: recipient_data = self.merge_data[email] elif self.merge_global_data: recipient_data = self.merge_global_data else: recipient_data = {} return {"email": email, "data": recipient_data} # # Payload construction # def make_mailersend_email(self, email): """Return MailerSend email/name object for an EmailAddress""" obj = {"email": email.addr_spec} if email.display_name: obj["name"] = email.display_name return obj def init_payload(self): self.data = {} # becomes json def set_from_email(self, email): self.data["from"] = self.make_mailersend_email(email) def set_recipients(self, recipient_type, emails): assert recipient_type in ["to", "cc", "bcc"] if emails: self.data[recipient_type] = [ self.make_mailersend_email(email) for email in emails ] self.all_recipients += emails def set_subject(self, subject): self.data["subject"] = subject def set_reply_to(self, emails): if len(emails) > 1: self.unsupported_feature("multiple reply_to emails") elif emails: self.data["reply_to"] = self.make_mailersend_email(emails[0]) def set_extra_headers(self, headers): # MailerSend has individual API params for In-Reply-To and Precedence: bulk. # The general "headers" option "is available to Enterprise accounts only". # (headers is a CaseInsensitiveDict, and is a copy so safe to modify.) in_reply_to = headers.pop("In-Reply-To", None) if in_reply_to is not None: self.data["in_reply_to"] = in_reply_to precedence = headers.pop("Precedence", None) if precedence is not None: # Overrides MailerSend domain-level setting is_bulk = precedence.lower() in ("bulk", "junk", "list") self.data["precedence_bulk"] = is_bulk if headers: self.data["headers"] = [ {"name": field, "value": value} for field, value in headers.items() ] def set_text_body(self, body): self.data["text"] = body def set_html_body(self, body): if "html" in self.data: # second html body could show up through multiple alternatives, # or html body + alternative self.unsupported_feature("multiple html parts") self.data["html"] = body def add_attachment(self, attachment): # Add a MailerSend attachments[] object for attachment: attachment_object = { "filename": attachment.name, "content": attachment.b64content, "disposition": "attachment", } if not attachment_object["filename"]: # MailerSend requires filename, and determines mimetype from it # (even for inline attachments). For unnamed attachments, try # to generate a generic filename with the correct extension: ext = mimetypes.guess_extension(attachment.mimetype, strict=False) if ext is not None: attachment_object["filename"] = f"attachment{ext}" if attachment.inline: attachment_object["disposition"] = "inline" attachment_object["id"] = attachment.cid self.data.setdefault("attachments", []).append(attachment_object) # MailerSend doesn't have metadata # def set_metadata(self, metadata): def set_send_at(self, send_at): # Backend has converted pretty much everything to # a datetime by here; MailerSend expects unix timestamp self.data["send_at"] = int(send_at.timestamp()) # strip microseconds def set_tags(self, tags): if tags: self.data["tags"] = tags def set_track_clicks(self, track_clicks): self.data.setdefault("settings", {})["track_clicks"] = track_clicks def set_track_opens(self, track_opens): self.data.setdefault("settings", {})["track_opens"] = track_opens def set_template_id(self, template_id): self.data["template_id"] = template_id def set_merge_data(self, merge_data): # late bound in serialize_data self.merge_data = merge_data def set_merge_global_data(self, merge_global_data): # late bound in serialize_data self.merge_global_data = merge_global_data # MailerSend doesn't have metadata # def set_merge_metadata(self, merge_metadata): def set_esp_extra(self, extra): # Deep merge to allow (e.g.,) {"settings": {"track_content": True}}: update_deep(self.data, extra) # Allow overriding api_token on individual message: try: api_token = self.data.pop("api_token") except KeyError: pass else: self.headers["Authorization"] = f"Bearer {api_token}" # Allow overriding batch_send_mode on individual message: try: self.batch_send_mode = self.data.pop("batch_send_mode") except KeyError: pass django-anymail-13.0/anymail/backends/mailgun.py000066400000000000000000000460471477357323300215720ustar00rootroot00000000000000from datetime import datetime from urllib.parse import quote from ..exceptions import AnymailError, AnymailRequestsAPIError from ..message import AnymailRecipientStatus from ..utils import get_anymail_setting, rfc2822date from .base_requests import AnymailRequestsBackend, RequestsPayload class EmailBackend(AnymailRequestsBackend): """ Mailgun API Email Backend """ esp_name = "Mailgun" 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", ) if not api_url.endswith("/"): api_url += "/" super().__init__(api_url, **kwargs) def build_message_payload(self, message, defaults): return MailgunPayload(message, defaults, self) def raise_for_status(self, response, payload, message): # Mailgun issues a terse 404 for unrecognized sender domains. # Add some context: if response.status_code == 404 and "Domain not found" in response.text: raise AnymailRequestsAPIError( "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, ) super().raise_for_status(response, payload, message) # Mailgun issues a cryptic "Mailgun Magnificent API" success response # for invalid API endpoints. Convert that to a useful error: if response.status_code == 200 and "Mailgun Magnificent API" in response.text: raise AnymailRequestsAPIError( "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, ) def parse_recipient_status(self, response, payload, message): # The *only* 200 response from Mailgun seems to be: # { # "id": "<20160306015544.116301.25145@example.org>", # "message": "Queued. Thank you." # } # # That single message id applies to all recipients. # The only way to detect rejected, etc. is via webhooks. # (*Any* invalid recipient addresses will generate a 400 API error) parsed_response = self.deserialize_json_response(response, payload, message) try: 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 if not mailgun_message.startswith("Queued"): 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 self.all_recipients = [] # used for backend.parse_recipient_status # late-binding of recipient-variables: self.merge_data = {} self.merge_global_data = {} self.metadata = {} self.merge_metadata = {} self.merge_headers = {} self.to_emails = [] super().__init__(message, defaults, backend, auth=auth, *args, **kwargs) 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(): # 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="") def serialize_data(self): self.populate_recipient_variables() return self.data # A not-so-brief digression about Mailgun's batch sending, template personalization, # and metadata tracking capabilities... # # Mailgun has two kinds of templates: # * ESP-stored templates (handlebars syntax), referenced by template name in the # send API, with substitution data supplied as "custom data" variables. # Anymail's `template_id` maps to this feature. # * On-the-fly templating (`%recipient.KEY%` syntax), with template variables # appearing directly in the message headers and/or body, and data supplied # as "recipient variables" per-recipient personalizations. Mailgun docs also # sometimes refer to this data as "template variables," but it's distinct from # the substitution data used for stored handelbars templates. # # Mailgun has two mechanisms for supplying additional data with a message: # * "Custom data" is supplied via `v:KEY` and/or `h:X-Mailgun-Variables` fields. # Custom data is passed to tracking webhooks (as 'user-variables') and is # available for `{{substitutions}}` in ESP-stored handlebars templates. # Normally, the same custom data is applied to every recipient of a message. # * "Recipient variables" are supplied via the `recipient-variables` field, and # provide per-recipient data for batch sending. The recipient specific values # 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: # (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. # (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}}. # (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"]`.) # (6) Anymail's `merge_headers` (per-recipient headers) maps to recipient-variables # prepended with 'h:'. # # 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. def populate_recipient_variables(self): """ Populate Mailgun recipient-variables and custom data from merge data and metadata """ # (numbers refer to detailed explanation above) # Mailgun parameters to construct: recipient_variables = {} custom_data = {} # (1) metadata --> Mailgun custom_data custom_data.update(self.metadata) # (2) merge_metadata --> Mailgun custom_data via recipient_variables if self.merge_metadata: 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() } ) 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: # 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()) # 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: 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['key'] = '%recipient.key%' indirection: custom_data.update( {key: "%recipient.{}%".format(key) for key in merge_data_keys} ) # (6) merge_headers --> Mailgun recipient_variables via 'h:'-prefixed keys if self.merge_headers: def hkey(field_name): # 'h:Field-Name' return "h:{}".format(field_name.title()) merge_header_fields = flatset( recipient_headers.keys() for recipient_headers in self.merge_headers.values() ) merge_header_defaults = { # existing h:Field-Name value (from extra_headers), or empty string field: self.data.get(hkey(field), "") for field in merge_header_fields } self.data.update( # Set up 'h:Field-Name': '%recipient.h:Field-Name%' indirection { hvar: f"%recipient.{hvar}%" for hvar in [hkey(field) for field in merge_header_fields] } ) for email in self.to_emails: # Each recipient's recipient_variables needs _all_ merge header fields recipient_headers = merge_header_defaults.copy() recipient_headers.update(self.merge_headers.get(email, {})) recipient_variables_for_headers = { hkey(field): value for field, value in recipient_headers.items() } recipient_variables.setdefault(email, {}).update( recipient_variables_for_headers ) # populate Mailgun params 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) # # Payload construction # def init_payload(self): self.data = {} # {field: [multiple, values]} self.files = [] # [(field, multiple), (field, values)] self.headers = {} def set_from_email_list(self, emails): # Mailgun supports multiple From email addresses self.data["from"] = [email.address for email in emails] if self.sender_domain is None and len(emails) > 0: # try to intuit sender_domain from first from_email self.sender_domain = emails[0].domain or None def set_recipients(self, recipient_type, emails): assert recipient_type in ["to", "cc", "bcc"] if emails: self.data[recipient_type] = [email.address for email in emails] # 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 def set_reply_to(self, emails): if emails: reply_to = ", ".join([str(email) for email in emails]) self.data["h:Reply-To"] = reply_to def set_extra_headers(self, headers): for field, value in headers.items(): self.data["h:%s" % field.title()] = value def set_text_body(self, body): self.data["text"] = body def set_html_body(self, body): if "html" in self.data: # second html body could show up through multiple alternatives, # or html body + alternative self.unsupported_feature("multiple html parts") self.data["html"] = body def add_alternative(self, content, mimetype): if mimetype.lower() == "text/x-amp-html": if "amp-html" in self.data: self.unsupported_feature("multiple html parts") self.data["amp-html"] = content else: super().add_alternative(content, mimetype) def add_attachment(self, attachment): # http://docs.python-requests.org/en/v2.4.3/user/advanced/#post-multiple-multipart-encoded-files if attachment.inline: field = "inline" name = attachment.cid if not name: self.unsupported_feature("inline attachments without Content-ID") else: field = "attachment" name = attachment.name if not name: self.unsupported_feature("attachments without filenames") self.files.append((field, (name, attachment.content, attachment.mimetype))) def set_envelope_sender(self, email): # Only the domain is used self.sender_domain = email.domain def set_metadata(self, metadata): self.metadata = metadata # save for handling merge_metadata later for key, value in metadata.items(): self.data["v:%s" % key] = value def set_send_at(self, send_at): # Mailgun expects RFC-2822 format dates # (BasePayload has converted most date-like values to datetime by now; # if the caller passes a string, they'll need to format it themselves.) if isinstance(send_at, datetime): send_at = rfc2822date(send_at) self.data["o:deliverytime"] = send_at def set_tags(self, tags): self.data["o:tag"] = tags def set_track_clicks(self, track_clicks): # Mailgun also supports an "htmlonly" option, which Anymail doesn't offer self.data["o:tracking-clicks"] = "yes" if track_clicks else "no" def set_track_opens(self, track_opens): self.data["o:tracking-opens"] = "yes" if track_opens else "no" def set_template_id(self, template_id): self.data["template"] = template_id def set_merge_data(self, merge_data): # Processed at serialization time (to allow merging global data) self.merge_data = merge_data def set_merge_global_data(self, merge_global_data): # Processed at serialization time (to allow merging global data) self.merge_global_data = merge_global_data def set_merge_metadata(self, merge_metadata): # Processed at serialization time (to allow combining with merge_data) self.merge_metadata = merge_metadata def set_merge_headers(self, merge_headers): self.merge_headers = merge_headers def set_esp_extra(self, extra): self.data.update(extra) # Allow override of sender_domain via esp_extra # (but pop it out of params to send to Mailgun) self.sender_domain = self.data.pop("sender_domain", self.sender_domain) def isascii(s): """Returns True if str s is entirely ASCII characters. (Compare to Python 3.7 `str.isascii()`.) """ try: s.encode("ascii") except UnicodeEncodeError: return False return True def flatset(iterables): """Return a set of the items in a single-level flattening of iterables >>> flatset([1, 2], [2, 3]) set(1, 2, 3) """ return set(item for iterable in iterables for item in iterable) django-anymail-13.0/anymail/backends/mailjet.py000066400000000000000000000250061477357323300215530ustar00rootroot00000000000000from ..exceptions import AnymailRequestsAPIError from ..message import AnymailRecipientStatus from ..utils import get_anymail_setting, update_deep from .base_requests import AnymailRequestsBackend, RequestsPayload class EmailBackend(AnymailRequestsBackend): """ Mailjet API Email Backend """ esp_name = "Mailjet" 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/", ) if not api_url.endswith("/"): api_url += "/" super().__init__(api_url, **kwargs) def build_message_payload(self, message, defaults): return MailjetPayload(message, defaults, self) def raise_for_status(self, response, payload, message): content_type = ( response.headers.get("content-type", "").split(";")[0].strip().lower() ) if 400 <= response.status_code <= 499 and content_type == "application/json": # Mailjet uses 4xx status codes for partial failure in batch send; # we'll determine how to handle below in parse_recipient_status. return super().raise_for_status(response, payload, message) def parse_recipient_status(self, response, payload, message): parsed_response = self.deserialize_json_response(response, payload, message) # Global error? (no messages sent) if "ErrorCode" in parsed_response: raise AnymailRequestsAPIError( email_message=message, payload=payload, response=response, backend=self ) recipient_status = {} try: for result in parsed_response["Messages"]: # 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"] # 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 # 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" ) 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", } self.recipients = [] # for backend parse_recipient_status self.metadata = None super().__init__( message, defaults, backend, auth=auth, headers=http_headers, *args, **kwargs ) def get_api_endpoint(self): return "send" def serialize_data(self): return self.serialize_json(self.data) # # Payload construction # def init_payload(self): # The v3.1 Send payload. We use Globals for most parameters, # which simplifies batch sending if it's used (and if not, # still works as expected for ordinary send). # https://dev.mailjet.com/email/reference/send-emails#v3_1_post_send self.data = { "Globals": {}, "Messages": [], } def _burst_for_batch_send(self): """Expand the payload Messages into a separate object for each To address""" # This can be called multiple times -- if the payload has already been burst, # it will have no effect. # For simplicity, this assumes that "To" is the only Messages param we use # (because everything else goes in Globals). if len(self.data["Messages"]) == 1: to_recipients = self.data["Messages"][0].get("To", []) self.data["Messages"] = [{"To": [to]} for to in to_recipients] @staticmethod def _mailjet_email(email): """Expand an Anymail EmailAddress into Mailjet's {"Email", "Name"} dict""" result = {"Email": email.addr_spec} if email.display_name: result["Name"] = email.display_name return result def set_from_email(self, email): self.data["Globals"]["From"] = self._mailjet_email(email) def set_to(self, emails): # "To" is the one non-batch param we transmit in Messages rather than Globals. # (See also _burst_for_batch_send, set_merge_data, and set_merge_metadata.) if len(self.data["Messages"]) > 0: # 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.recipients += emails else: # Mailjet requires a To list; cc-only messages aren't possible self.unsupported_feature("messages without any `to` recipients") def set_cc(self, emails): if 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.recipients += emails def set_subject(self, subject): self.data["Globals"]["Subject"] = subject def set_reply_to(self, emails): if len(emails) > 0: self.data["Globals"]["ReplyTo"] = self._mailjet_email(emails[0]) if len(emails) > 1: self.unsupported_feature("Multiple reply_to addresses") def set_extra_headers(self, headers): self.data["Globals"]["Headers"] = headers def set_text_body(self, body): if body: # Django's default empty text body confuses Mailjet (esp. templates) self.data["Globals"]["TextPart"] = body 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 self.unsupported_feature("multiple html parts") self.data["Globals"]["HTMLPart"] = body def add_attachment(self, attachment): att = { "ContentType": attachment.mimetype, # Mailjet requires a non-empty Filename. "Filename": attachment.name or "attachment", "Base64Content": attachment.b64content, } if attachment.inline: field = "InlinedAttachments" att["ContentID"] = attachment.cid else: field = "Attachments" self.data["Globals"].setdefault(field, []).append(att) def set_envelope_sender(self, email): self.data["Globals"]["Sender"] = self._mailjet_email(email) def set_metadata(self, metadata): # Mailjet expects a single string payload self.data["Globals"]["EventPayload"] = self.serialize_json(metadata) self.metadata = metadata # save for set_merge_metadata def set_merge_metadata(self, merge_metadata): self._burst_for_batch_send() for message in self.data["Messages"]: email = message["To"][0]["Email"] if email in merge_metadata: if self.metadata: recipient_metadata = self.metadata.copy() recipient_metadata.update(merge_metadata[email]) else: recipient_metadata = merge_metadata[email] message["EventPayload"] = self.serialize_json(recipient_metadata) def set_merge_headers(self, merge_headers): self._burst_for_batch_send() for message in self.data["Messages"]: email = message["To"][0]["Email"] if email in merge_headers: message["Headers"] = merge_headers[email] def set_tags(self, tags): # The choices here are CustomID or Campaign, and Campaign seems closer # to how "tags" are handled by other ESPs -- e.g., you can view dashboard # statistics across all messages with the same Campaign. if len(tags) > 0: self.data["Globals"]["CustomCampaign"] = tags[0] if len(tags) > 1: self.unsupported_feature("multiple tags (%r)" % tags) def set_track_clicks(self, track_clicks): self.data["Globals"]["TrackClicks"] = "enabled" if track_clicks else "disabled" def set_track_opens(self, track_opens): self.data["Globals"]["TrackOpens"] = "enabled" if track_opens else "disabled" def set_template_id(self, template_id): # 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): self._burst_for_batch_send() for message in self.data["Messages"]: email = message["To"][0]["Email"] if email in merge_data: message["Variables"] = merge_data[email] def set_merge_global_data(self, merge_global_data): self.data["Globals"]["Variables"] = merge_global_data def set_esp_extra(self, extra): update_deep(self.data, extra) django-anymail-13.0/anymail/backends/mandrill.py000066400000000000000000000215141477357323300217300ustar00rootroot00000000000000from datetime import datetime from ..exceptions import AnymailRequestsAPIError from ..message import ANYMAIL_STATUSES, AnymailRecipientStatus from ..utils import get_anymail_setting from .base_requests import AnymailRequestsBackend, RequestsPayload class EmailBackend(AnymailRequestsBackend): """ Mandrill API Email Backend """ esp_name = "Mandrill" 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", ) if not api_url.endswith("/"): api_url += "/" super().__init__(api_url, **kwargs) def build_message_payload(self, message, defaults): return MandrillPayload(message, defaults, self) def parse_recipient_status(self, response, payload, message): 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 for item in parsed_response: email = item["email"] status = item["status"] if status not in ANYMAIL_STATUSES: 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 return recipient_status def encode_date_for_mandrill(dt): """Format a datetime for use as a Mandrill API date field Mandrill expects "YYYY-MM-DD HH:MM:SS" in UTC """ if isinstance(dt, datetime): dt = dt.replace(microsecond=0) if dt.utcoffset() is not None: dt = (dt - dt.utcoffset()).replace(tzinfo=None) 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: return "messages/send-template.json" else: return "messages/send.json" def serialize_data(self): self.process_esp_extra() if self.is_batch(): # hide recipients from each other self.data["message"]["preserve_recipients"] = False return self.serialize_json(self.data) # # Payload construction # def init_payload(self): self.data = { "key": self.backend.api_key, "message": {}, } def set_from_email(self, email): self.data["message"]["from_email"] = email.addr_spec if email.display_name: self.data["message"]["from_name"] = email.display_name def add_recipient(self, recipient_type, email): assert recipient_type in ["to", "cc", "bcc"] recipient_data = {"email": email.addr_spec, "type": recipient_type} if email.display_name: recipient_data["name"] = email.display_name to_list = self.data["message"].setdefault("to", []) to_list.append(recipient_data) def set_subject(self, subject): self.data["message"]["subject"] = subject def set_reply_to(self, emails): if emails: reply_to = ", ".join([str(email) for email in emails]) self.data["message"].setdefault("headers", {})["Reply-To"] = reply_to def set_extra_headers(self, headers): self.data["message"].setdefault("headers", {}).update(headers) def set_text_body(self, body): self.data["message"]["text"] = body 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 self.unsupported_feature("multiple html parts") self.data["message"]["html"] = body def add_attachment(self, attachment): if attachment.inline: field = "images" name = attachment.cid else: field = "attachments" name = attachment.name or "" 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 self.data["message"]["return_path_domain"] = email.domain def set_metadata(self, metadata): self.data["message"]["metadata"] = metadata def set_send_at(self, send_at): self.data["send_at"] = encode_date_for_mandrill(send_at) def set_tags(self, tags): self.data["message"]["tags"] = tags def set_track_clicks(self, track_clicks): self.data["message"]["track_clicks"] = track_clicks def set_track_opens(self, track_opens): self.data["message"]["track_opens"] = track_opens def set_template_id(self, template_id): self.data["template_name"] = template_id self.data.setdefault("template_content", []) # Mandrill requires something here def set_merge_data(self, merge_data): 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() ] 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} for rcpt, rcpt_data in merge_metadata.items() ] def set_esp_extra(self, extra): # late bind in serialize_data, so that obsolete Djrill attrs can contribute self.esp_extra = extra def process_esp_extra(self): if self.esp_extra is not None and len(self.esp_extra) > 0: esp_extra = self.esp_extra # Convert pythonic template_content dict to Mandrill name/content list try: template_content = esp_extra["template_content"] except KeyError: pass else: # if it's dict-like: if hasattr(template_content, "items"): if esp_extra is self.esp_extra: # 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"] except KeyError: pass else: # 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"]) except KeyError: pass django-anymail-13.0/anymail/backends/postal.py000066400000000000000000000101421477357323300214230ustar00rootroot00000000000000from ..exceptions import AnymailRequestsAPIError from ..message import AnymailRecipientStatus from ..utils import get_anymail_setting from .base_requests import AnymailRequestsBackend, RequestsPayload class EmailBackend(AnymailRequestsBackend): """ Postal v1 API Email Backend """ esp_name = "Postal" 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 ) # Required, as there is no hosted instance of Postal api_url = get_anymail_setting("api_url", esp_name=esp_name, kwargs=kwargs) if not api_url.endswith("/"): api_url += "/" super().__init__(api_url, **kwargs) def build_message_payload(self, message, defaults): return PostalPayload(message, defaults, self) def parse_recipient_status(self, response, payload, message): parsed_response = self.deserialize_json_response(response, payload, message) if parsed_response["status"] != "success": raise AnymailRequestsAPIError( email_message=message, payload=payload, response=response, backend=self ) # If we get here, the send call was successful. messages = parsed_response["data"]["messages"] return { email: AnymailRecipientStatus(message_id=details["id"], status="queued") for email, details in messages.items() } class PostalPayload(RequestsPayload): def __init__(self, message, defaults, backend, *args, **kwargs): http_headers = kwargs.pop("headers", {}) http_headers["X-Server-API-Key"] = 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 "api/v1/send/message" def init_payload(self): self.data = {} def serialize_data(self): return self.serialize_json(self.data) def set_from_email(self, email): self.data["from"] = str(email) def set_subject(self, subject): self.data["subject"] = subject def set_to(self, emails): self.data["to"] = [str(email) for email in emails] def set_cc(self, emails): self.data["cc"] = [str(email) for email in emails] def set_bcc(self, emails): self.data["bcc"] = [str(email) for email in emails] def set_reply_to(self, emails): if len(emails) > 1: self.unsupported_feature("multiple reply_to addresses") if len(emails) > 0: self.data["reply_to"] = str(emails[0]) def set_extra_headers(self, headers): self.data["headers"] = headers def set_text_body(self, body): self.data["plain_body"] = body def set_html_body(self, body): if "html_body" in self.data: self.unsupported_feature("multiple html parts") self.data["html_body"] = body def make_attachment(self, attachment): """Returns Postal attachment dict for attachment""" att = { "name": attachment.name or "", "data": attachment.b64content, "content_type": attachment.mimetype, } 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") return att def set_attachments(self, attachments): if attachments: self.data["attachments"] = [ self.make_attachment(attachment) for attachment in attachments ] def set_envelope_sender(self, email): self.data["sender"] = str(email) def set_tags(self, tags): if len(tags) > 1: self.unsupported_feature("multiple tags") if len(tags) > 0: self.data["tag"] = tags[0] def set_esp_extra(self, extra): self.data.update(extra) django-anymail-13.0/anymail/backends/postmark.py000066400000000000000000000414131477357323300217660ustar00rootroot00000000000000import re from requests.structures import CaseInsensitiveDict from ..exceptions import AnymailRequestsAPIError from ..message import AnymailRecipientStatus from ..utils import ( CaseInsensitiveCasePreservingDict, get_anymail_setting, parse_address_list, ) from .base_requests import AnymailRequestsBackend, RequestsPayload class EmailBackend(AnymailRequestsBackend): """ Postmark API Email Backend """ esp_name = "Postmark" 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/", ) if not api_url.endswith("/"): api_url += "/" super().__init__(api_url, **kwargs) def build_message_payload(self, message, defaults): return PostmarkPayload(message, defaults, self) def raise_for_status(self, response, payload, message): # We need to handle 422 responses in parse_recipient_status if response.status_code != 422: 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 } ) parsed_response = self.deserialize_json_response(response, payload, message) if not isinstance(parsed_response, list): # non-batch calls return a single response object parsed_response = [parsed_response] for one_response in parsed_response: try: # these fields should always be present # (but ErrorCode may be missing for Postmark service delays) error_code = one_response.get("ErrorCode", 0) 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 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 status = "sent" # "Message accepted, but delivery may be delayed." (See #392.) if "delivery may be delayed" in msg: status = "queued" # Assume all To recipients are "sent" unless proven otherwise below. # (Must use "To" from API response to get correct individual MessageIDs # in batch send.) try: to_header = one_response["To"] # (missing if cc- or bcc-only send) except KeyError: pass # cc- or bcc-only send; per-recipient status not available else: for to in parse_address_list(to_header): recipient_status[to.addr_spec] = AnymailRecipientStatus( message_id=message_id, status=status ) # 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=status ) # Change "sent" to "rejected" if Postmark reported an address as # "Inactive". Sadly, have to parse human-readable message to figure out # if everyone got it: # "Message OK, but will not deliver to these inactive addresses: # {addr_spec, ...}. Inactive recipients are ones that have generated # a hard bounce or a spam complaint." # Note that error message emails are addr_spec only (no display names) # and forced lowercase. reject_addr_specs = self._addr_specs_from_error_msg( msg, r"inactive addresses:\s*(.*)\.\s*Inactive recipients" ) for reject_addr_spec in reject_addr_specs: recipient_status[reject_addr_spec] = AnymailRecipientStatus( 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 ): # 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*'(.*)'" ) for invalid_addr_spec in invalid_addr_specs: recipient_status[invalid_addr_spec] = AnymailRecipientStatus( 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, ) 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. " reject_addr_specs = self._addr_specs_from_error_msg( msg, r"inactive addresses:\s*(.*)\.\s*Inactive recipients" ) for reject_addr_spec in reject_addr_specs: recipient_status[reject_addr_spec] = AnymailRecipientStatus( message_id=None, status="rejected" ) else: # Other error raise AnymailRequestsAPIError( email_message=message, payload=payload, response=response, backend=self, ) return dict(recipient_status) @staticmethod def _addr_specs_from_error_msg(error_msg, pattern): """Extract a list of email addr_specs from Postmark error_msg. pattern must be a re whose first group matches a comma-separated list of addr_specs in the message """ 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(",")] 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) } self.server_token = backend.server_token # esp_extra can override self.to_emails = [] self.cc_and_bcc_emails = [] # needed for parse_recipient_status self.merge_data = None self.merge_metadata = None self.merge_headers = {} 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 batch_send: return "email/batchWithTemplates" else: # This is the one Postmark API documented to have a trailing slash. # (Typo?) return "email/withTemplate/" else: if batch_send: return "email/batch" else: return "email" def get_request_params(self, api_url): params = super().get_request_params(api_url) params["headers"]["X-Postmark-Server-Token"] = self.server_token return params def serialize_data(self): api_endpoint = self.get_api_endpoint() if api_endpoint == "email": data = self.data elif api_endpoint == "email/batchWithTemplates": data = {"Messages": [self.data_for_recipient(to) for to in self.to_emails]} 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 data = self.data else: raise AssertionError( "PostmarkPayload.serialize_data missing" " case for api_endpoint %r" % api_endpoint ) return self.serialize_json(data) def data_for_recipient(self, to): data = self.data.copy() data["To"] = to.address if self.merge_data and to.addr_spec in self.merge_data: recipient_data = self.merge_data[to.addr_spec] if "TemplateModel" in data: # merge recipient_data into merge_global_data data["TemplateModel"] = data["TemplateModel"].copy() data["TemplateModel"].update(recipient_data) else: data["TemplateModel"] = recipient_data if self.merge_metadata and to.addr_spec in self.merge_metadata: recipient_metadata = self.merge_metadata[to.addr_spec] if "Metadata" in data: # merge recipient_metadata into toplevel metadata data["Metadata"] = data["Metadata"].copy() data["Metadata"].update(recipient_metadata) else: data["Metadata"] = recipient_metadata if to.addr_spec in self.merge_headers: if "Headers" in data: # merge global and recipient headers headers = CaseInsensitiveDict( (item["Name"], item["Value"]) for item in data["Headers"] ) headers.update(self.merge_headers[to.addr_spec]) else: headers = self.merge_headers[to.addr_spec] data["Headers"] = [ {"Name": name, "Value": value} for name, value in headers.items() ] return data # # Payload construction # def init_payload(self): self.data = {} # becomes json def set_from_email_list(self, emails): # Postmark accepts multiple From email addresses # (though truncates to just the first, on their end, as of 4/2017) self.data["From"] = ", ".join([email.address for email in emails]) def set_recipients(self, recipient_type, emails): assert recipient_type in ["to", "cc", "bcc"] if emails: field = recipient_type.capitalize() self.data[field] = ", ".join([email.address for email in emails]) if recipient_type == "to": self.to_emails = emails else: self.cc_and_bcc_emails += emails def set_subject(self, subject): self.data["Subject"] = subject def set_reply_to(self, emails): if emails: reply_to = ", ".join([email.address for email in emails]) self.data["ReplyTo"] = reply_to def set_extra_headers(self, headers): self.data["Headers"] = [ {"Name": key, "Value": value} for key, value in headers.items() ] def set_text_body(self, body): self.data["TextBody"] = body def set_html_body(self, body): if "HtmlBody" in self.data: # second html body could show up through multiple alternatives, # or html body + alternative self.unsupported_feature("multiple html parts") self.data["HtmlBody"] = body def make_attachment(self, attachment): """Returns Postmark attachment dict for attachment""" att = { "Name": attachment.name or "", "Content": attachment.b64content, "ContentType": attachment.mimetype, } if attachment.inline: att["ContentID"] = "cid:%s" % attachment.cid return att def set_attachments(self, attachments): if attachments: self.data["Attachments"] = [ self.make_attachment(attachment) for attachment in attachments ] def set_metadata(self, metadata): self.data["Metadata"] = metadata # Postmark doesn't support delayed sending # def set_send_at(self, send_at): def set_tags(self, tags): if len(tags) > 0: self.data["Tag"] = tags[0] if len(tags) > 1: self.unsupported_feature("multiple tags (%r)" % tags) def set_track_clicks(self, track_clicks): self.data["TrackLinks"] = "HtmlAndText" if track_clicks else "None" def set_track_opens(self, track_opens): self.data["TrackOpens"] = track_opens def set_template_id(self, template_id): try: self.data["TemplateId"] = int(template_id) except ValueError: self.data["TemplateAlias"] = template_id # Postmark requires TemplateModel (empty ok) when TemplateId/TemplateAlias # specified. (This may get overwritten by a real TemplateModel later.) self.data.setdefault("TemplateModel", {}) # Subject, TextBody, and HtmlBody aren't allowed with TemplateId; # delete Django default subject and body empty strings: for field in ("Subject", "TextBody", "HtmlBody"): if field in self.data and not self.data[field]: del self.data[field] def set_merge_data(self, merge_data): # late-bind self.merge_data = merge_data def set_merge_global_data(self, merge_global_data): self.data["TemplateModel"] = merge_global_data def set_merge_metadata(self, merge_metadata): # late-bind self.merge_metadata = merge_metadata def set_merge_headers(self, merge_headers): # late-bind self.merge_headers = merge_headers 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) django-anymail-13.0/anymail/backends/resend.py000066400000000000000000000220471477357323300214100ustar00rootroot00000000000000import mimetypes from ..exceptions import AnymailRequestsAPIError from ..message import AnymailRecipientStatus from ..utils import ( BASIC_NUMERIC_TYPES, CaseInsensitiveCasePreservingDict, get_anymail_setting, ) from .base_requests import AnymailRequestsBackend, RequestsPayload class EmailBackend(AnymailRequestsBackend): """ Resend (resend.com) API Email Backend """ esp_name = "Resend" 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://api.resend.com/", ) if not api_url.endswith("/"): api_url += "/" super().__init__(api_url, **kwargs) def build_message_payload(self, message, defaults): return ResendPayload(message, defaults, self) def parse_recipient_status(self, response, payload, message): parsed_response = self.deserialize_json_response(response, payload, message) try: message_id = parsed_response["id"] message_ids = None except (KeyError, TypeError): # Batch send? try: message_id = None message_ids = [item["id"] for item in parsed_response["data"]] except (KeyError, TypeError) as err: raise AnymailRequestsAPIError( "Invalid Resend API response format", email_message=message, payload=payload, response=response, backend=self, ) from err recipient_status = CaseInsensitiveCasePreservingDict( { recip.addr_spec: AnymailRecipientStatus( message_id=message_id, status="queued" ) for recip in payload.recipients } ) if message_ids: # batch send: ids are in same order as to_recipients for recip, message_id in zip(payload.to_recipients, message_ids): recipient_status[recip.addr_spec] = AnymailRecipientStatus( message_id=message_id, status="queued" ) return dict(recipient_status) class ResendPayload(RequestsPayload): def __init__(self, message, defaults, backend, *args, **kwargs): self.recipients = [] # for parse_recipient_status self.to_recipients = [] # for parse_recipient_status self.metadata = {} self.merge_metadata = {} self.merge_headers = {} headers = kwargs.pop("headers", {}) headers["Authorization"] = "Bearer %s" % backend.api_key headers["Content-Type"] = "application/json" headers["Accept"] = "application/json" super().__init__(message, defaults, backend, headers=headers, *args, **kwargs) def get_api_endpoint(self): if self.is_batch(): return "emails/batch" return "emails" def serialize_data(self): payload = self.data if self.is_batch(): # Burst payload across to addresses to_emails = self.data.pop("to", []) payload = [] for to_email, to in zip(to_emails, self.to_recipients): data = self.data.copy() data["to"] = [to_email] if to.addr_spec in self.merge_metadata: # Merge global metadata with any per-recipient metadata. recipient_metadata = self.metadata.copy() recipient_metadata.update(self.merge_metadata[to.addr_spec]) if "headers" in data: data["headers"] = data["headers"].copy() else: data["headers"] = {} data["headers"]["X-Metadata"] = self.serialize_json( recipient_metadata ) if to.addr_spec in self.merge_headers: if "headers" in data: # Merge global headers (or X-Metadata from above) headers = CaseInsensitiveCasePreservingDict(data["headers"]) headers.update(self.merge_headers[to.addr_spec]) else: headers = self.merge_headers[to.addr_spec] data["headers"] = headers payload.append(data) return self.serialize_json(payload) # # Payload construction # def init_payload(self): self.data = {} # becomes json def set_from_email(self, email): self.data["from"] = email.address def set_recipients(self, recipient_type, emails): assert recipient_type in ["to", "cc", "bcc"] if emails: field = recipient_type self.data[field] = [email.address for email in emails] self.recipients += emails if recipient_type == "to": self.to_recipients = emails def set_subject(self, subject): self.data["subject"] = subject def set_reply_to(self, emails): if emails: self.data["reply_to"] = [email.address for email in emails] def set_extra_headers(self, headers): # Resend requires header values to be strings (not integers) as of 2023-10-20. # Stringify ints and floats; anything else is the caller's responsibility. self.data.setdefault("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): self.data["text"] = body def set_html_body(self, body): if "html" in self.data: # second html body could show up through multiple alternatives, # or html body + alternative self.unsupported_feature("multiple html parts") self.data["html"] = body @staticmethod def make_attachment(attachment): """Returns Resend attachment dict for attachment""" filename = attachment.name or "" if not filename: # Provide default name with reasonable extension. # (Resend guesses content type from the filename extension; # there doesn't seem to be any other way to specify it.) ext = mimetypes.guess_extension(attachment.content_type) if ext is not None: filename = f"attachment{ext}" att = {"content": attachment.b64content, "filename": filename} # attachment.inline / attachment.cid not supported return att def set_attachments(self, attachments): if attachments: if any(att.content_id for att in attachments): self.unsupported_feature("inline content-id") self.data["attachments"] = [ self.make_attachment(attachment) for attachment in attachments ] def set_metadata(self, metadata): # Send metadata as json in a custom X-Metadata header. # (Resend's own "tags" are severely limited in character set) self.data.setdefault("headers", {})["X-Metadata"] = self.serialize_json( metadata ) self.metadata = metadata # may be needed for batch send in serialize_data def set_send_at(self, send_at): try: # Resend can't handle microseconds; truncate to milliseconds if necessary. send_at = send_at.isoformat( timespec="milliseconds" if send_at.microsecond else "seconds" ) except AttributeError: # User is responsible for formatting their own string pass self.data["scheduled_at"] = send_at def set_tags(self, tags): # Send tags using a custom X-Tags header. # (Resend's own "tags" are severely limited in character set) self.data.setdefault("headers", {})["X-Tags"] = self.serialize_json(tags) # Resend doesn't support changing click/open tracking per message # def set_track_clicks(self, track_clicks): # def set_track_opens(self, track_opens): # Resend doesn't support server-rendered templates. # (Their template feature is rendered client-side, # using React in node.js.) # def set_template_id(self, template_id): # def set_merge_global_data(self, merge_global_data): def set_merge_data(self, merge_data): # Empty merge_data is a request to use batch send; # any other merge_data is unsupported. if any(recipient_data for recipient_data in merge_data.values()): self.unsupported_feature("merge_data") def set_merge_metadata(self, merge_metadata): self.merge_metadata = merge_metadata # late bound in serialize_data def set_merge_headers(self, merge_headers): self.merge_headers = merge_headers # late bound in serialize_data def set_esp_extra(self, extra): self.data.update(extra) django-anymail-13.0/anymail/backends/sendgrid.py000066400000000000000000000411621477357323300217260ustar00rootroot00000000000000import uuid import warnings from requests.structures import CaseInsensitiveDict from ..exceptions import ( AnymailConfigurationError, AnymailSerializationError, 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): """ SendGrid v3 API Email Backend """ esp_name = "SendGrid" def __init__(self, **kwargs): """Init options from Django settings""" 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 ) if username or password: raise AnymailConfigurationError( "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.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 ) # 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/", ) if not api_url.endswith("/"): api_url += "/" super().__init__(api_url, **kwargs) def build_message_payload(self, message, defaults): 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 } 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 self.use_dynamic_template = False # how to represent merge_data self.message_ids = {} # recipient -> generated message_id mapping self.merge_field_format = backend.merge_field_format self.merge_data = {} # late-bound per-recipient data self.merge_global_data = {} self.merge_metadata = {} self.merge_headers = {} 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" def init_payload(self): self.data = { # becomes json "personalizations": [{}], "headers": CaseInsensitiveDict(), } def serialize_data(self): """Performs any necessary serialization on self.data, and returns the result.""" if self.is_batch(): self.expand_personalizations_for_batch() self.build_merge_data() self.build_merge_metadata() self.build_merge_headers() if self.generate_message_id: self.set_anymail_id() if not self.data["headers"]: del self.data["headers"] # don't send empty headers return self.serialize_json(self.data) def set_anymail_id(self): """ 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", []) ): self.message_ids[recipient["email"]] = message_id def expand_personalizations_for_batch(self): """Split data["personalizations"] into individual message for each recipient""" assert len(self.data["personalizations"]) == 1 base_personalization = self.data["personalizations"].pop() to_list = base_personalization.pop("to") # {email, name?} for each message.to for recipient in to_list: personalization = base_personalization.copy() personalization["to"] = [recipient] self.data["personalizations"].append(personalization) def build_merge_data(self): if self.merge_data or self.merge_global_data: # Always build dynamic_template_data first, # then convert it to legacy template format if needed only_global_merge_data = self.merge_global_data and not self.merge_data for personalization in self.data["personalizations"]: assert len(personalization["to"]) == 1 or only_global_merge_data recipient_email = personalization["to"][0]["email"] dynamic_template_data = self.merge_global_data.copy() dynamic_template_data.update(self.merge_data.get(recipient_email, {})) if dynamic_template_data: personalization["dynamic_template_data"] = dynamic_template_data if not self.use_dynamic_template: self.convert_dynamic_template_data_to_legacy_substitutions() def convert_dynamic_template_data_to_legacy_substitutions(self): """ Change personalizations[...]['dynamic_template_data'] to ...['substitutions] """ def transform_substitution_value(value): # SendGrid substitutions must be string or null, or SendGrid issues the # cryptic error `{"field": null, "message": "Bad Request", "help": null}`. # Anymail will convert numbers; treat anything else as an error. if isinstance(value, str) or value is None: return value if isinstance(value, BASIC_NUMERIC_TYPES): return str(value) raise AnymailSerializationError( "SendGrid legacy substitutions require string values." f" Don't know how to handle {type(value).__name__}." ) merge_field_format = self.merge_field_format or "{}" all_merge_fields = set() for personalization in self.data["personalizations"]: try: dynamic_template_data = personalization.pop("dynamic_template_data") except KeyError: pass # no substitutions for this recipient else: # Convert dynamic_template_data keys for substitutions, # using merge_field_format personalization["substitutions"] = { merge_field_format.format(field): transform_substitution_value(data) for field, data in dynamic_template_data.items() } all_merge_fields.update(dynamic_template_data.keys()) if self.merge_field_format is None: if all_merge_fields and all(field.isalnum() for field in all_merge_fields): 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, ) 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, ) def build_merge_metadata(self): if self.merge_metadata: for personalization in self.data["personalizations"]: assert len(personalization["to"]) == 1 recipient_email = personalization["to"][0]["email"] recipient_metadata = self.merge_metadata.get(recipient_email) if recipient_metadata: recipient_custom_args = self.transform_metadata(recipient_metadata) personalization["custom_args"] = recipient_custom_args def build_merge_headers(self): if self.merge_headers: for personalization in self.data["personalizations"]: assert len(personalization["to"]) == 1 recipient_email = personalization["to"][0]["email"] recipient_headers = self.merge_headers.get(recipient_email) if recipient_headers: personalization["headers"] = recipient_headers # # Payload construction # @staticmethod def email_object(email): """Converts EmailAddress to SendGrid API {email, name} dict""" obj = {"email": email.addr_spec} if email.display_name: obj["name"] = email.display_name return obj def set_from_email(self, email): self.data["from"] = self.email_object(email) def set_recipients(self, recipient_type, emails): assert recipient_type in ["to", "cc", "bcc"] if emails: # 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) for email in emails ] self.all_recipients += emails # used for backend.parse_recipient_status def set_subject(self, subject): if subject != "": # see note in set_text_body about template rendering self.data["subject"] = subject def set_reply_to(self, emails): if emails: self.data["reply_to_list"] = [self.email_object(email) for email in emails] 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() } ) def set_text_body(self, body): # Empty strings (the EmailMessage default) can cause unexpected SendGrid # template rendering behavior, such as ignoring the HTML template and # rendering HTML from the plaintext template instead. # 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, } ) 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." # 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 self.data.setdefault("content", []).append( { "type": mimetype, "value": content, } ) def add_attachment(self, attachment): att = { "content": attachment.b64content, "type": attachment.mimetype, # (filename is required -- submit empty string if unknown) "filename": attachment.name or "", } if attachment.inline: att["disposition"] = "inline" att["content_id"] = attachment.cid self.data.setdefault("attachments", []).append(att) def set_metadata(self, metadata): self.data["custom_args"] = self.transform_metadata(metadata) 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} # if they're not.) # 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() } def set_send_at(self, send_at): # Backend has converted pretty much everything to # a datetime by here; SendGrid expects unix timestamp self.data["send_at"] = int(send_at.timestamp()) # strip microseconds def set_tags(self, tags): self.data["categories"] = tags def set_track_clicks(self, track_clicks): self.data.setdefault("tracking_settings", {})["click_tracking"] = { "enable": track_clicks, } def set_track_opens(self, track_opens): # SendGrid's open_tracking setting also supports a "substitution_tag" parameter, # which Anymail doesn't offer directly. (You could add it through esp_extra.) self.data.setdefault("tracking_settings", {})["open_tracking"] = { "enable": track_opens, } def set_template_id(self, template_id): self.data["template_id"] = template_id try: self.use_dynamic_template = template_id.startswith("d-") except AttributeError: pass def set_merge_data(self, merge_data): # Becomes personalizations[...]['dynamic_template_data'] # or personalizations[...]['substitutions'] in build_merge_data, # after we know recipients, template type, and merge_field_format. self.merge_data = merge_data def set_merge_global_data(self, merge_global_data): # Becomes personalizations[...]['dynamic_template_data'] # or data['section'] in build_merge_data, after we know # template type and merge_field_format. self.merge_global_data = merge_global_data def set_merge_metadata(self, merge_metadata): # Becomes personalizations[...]['custom_args'] in # build_merge_data, after we know recipients, template type, # and merge_field_format. self.merge_metadata = merge_metadata def set_merge_headers(self, merge_headers): # Becomes personalizations[...]['headers'] in # build_merge_data self.merge_headers = merge_headers 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 ) if isinstance(extra.get("personalizations", None), Mapping): # merge personalizations *dict* into other message personalizations assert len(self.data["personalizations"]) == 1 self.data["personalizations"][0].update(extra.pop("personalizations")) 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." ) update_deep(self.data, extra) django-anymail-13.0/anymail/backends/sendinblue.py000066400000000000000000000010441477357323300222520ustar00rootroot00000000000000import warnings from ..exceptions import AnymailDeprecationWarning from .brevo import EmailBackend as BrevoEmailBackend class EmailBackend(BrevoEmailBackend): """ Deprecated compatibility backend for old Brevo name "SendinBlue". """ esp_name = "SendinBlue" def __init__(self, **kwargs): warnings.warn( "`anymail.backends.sendinblue.EmailBackend` has been renamed" " `anymail.backends.brevo.EmailBackend`.", AnymailDeprecationWarning, ) super().__init__(**kwargs) django-anymail-13.0/anymail/backends/sparkpost.py000066400000000000000000000327361477357323300221640ustar00rootroot00000000000000from django.conf import settings from django.utils.encoding import force_str 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): """ SparkPost Email Backend """ esp_name = "SparkPost" 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/", ) if not api_url.endswith("/"): api_url += "/" super().__init__(api_url, **kwargs) def build_message_payload(self, message, defaults): return SparkPostPayload(message, defaults, self) def parse_recipient_status(self, response, payload, message): parsed_response = self.deserialize_json_response(response, payload, message) try: results = parsed_response["results"] accepted = results["total_accepted_recipients"] 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 # SparkPost doesn't (yet*) tell us *which* recipients were accepted or rejected. # (* looks like undocumented 'rcpt_to_errors' might provide this info.) # If all are one or the other, we can report a specific status; # else just report 'unknown' for all recipients. recipient_count = len(payload.recipients) if accepted == recipient_count and rejected == 0: status = "queued" elif rejected == recipient_count and accepted == 0: 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 } class SparkPostPayload(RequestsPayload): def __init__(self, message, defaults, backend, *args, **kwargs): http_headers = { "Authorization": backend.api_key, "Content-Type": "application/json", } if backend.subaccount is not None: 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 ) def get_api_endpoint(self): return "transmissions/" def serialize_data(self): self._finalize_recipients() self._check_content_options() return self.serialize_json(self.data) 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. 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() # gets "metadata" and "substitutions" extra["address"] = { "email": email.addr_spec, "header_to": to_recipient["address"]["header_to"], } extra_recipients.append(extra) self.data["recipients"].extend(extra_recipients) else: # For non-batch sends, we need to patch up *everyone's* displayed # "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 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 ) # SparkPost silently ignores certain "content" payload fields # when a template_id is used. IGNORED_WITH_TEMPLATE_ID = { # SparkPost API content. -> feature name (for error message) "attachments": "attachments", "inline_images": "inline images", "headers": "extra headers and/or cc recipients", "from": "from_email", "reply_to": "reply_to", } def _check_content_options(self): if "template_id" in self.data["content"]: # subject, text, and html will cause 422 API Error: # "message": "Both content object and template_id are specified", # "code": "1301" # but others are silently ignored in a template send: ignored = [ feature_name for field, feature_name in self.IGNORED_WITH_TEMPLATE_ID.items() if field in self.data["content"] ] if ignored: self.unsupported_feature("template_id with %s" % ", ".join(ignored)) # # Payload construction # def init_payload(self): # The JSON payload: self.data = { "content": {}, "recipients": [], } def set_from_email(self, email): if email: self.data["content"]["from"] = email.address def set_to(self, emails): if emails: # In the recipient address, "email" is the addr spec to deliver to, # and "header_to" is a fully-composed "To" header to display. # (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 ) self.recipients += emails def set_cc(self, emails): # https://www.sparkpost.com/docs/faq/cc-bcc-with-rest-api/ if emails: # Add the Cc header, visible to all recipients: cc_header = ", ".join(email.address for email in emails) self.data["content"].setdefault("headers", {})["Cc"] = cc_header # Actual recipients are added later, in _finalize_recipients self.cc_and_bcc += emails self.recipients += emails def set_bcc(self, emails): if emails: # Actual recipients are added later, in _finalize_recipients self.cc_and_bcc += emails self.recipients += emails def set_subject(self, subject): self.data["content"]["subject"] = subject def set_reply_to(self, emails): if emails: self.data["content"]["reply_to"] = ", ".join( email.address for email in emails ) def set_extra_headers(self, headers): if headers: self.data["content"].setdefault("headers", {}).update(headers) def set_text_body(self, body): self.data["content"]["text"] = body 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 self.unsupported_feature("multiple html parts") self.data["content"]["html"] = body def add_alternative(self, content, mimetype): if mimetype.lower() == "text/x-amp-html": if "amp_html" in self.data["content"]: self.unsupported_feature("multiple html parts") self.data["content"]["amp_html"] = content else: 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 ] 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 ] if inline_images: self.data["content"]["inline_images"] = inline_images # Anymail-specific payload construction def set_envelope_sender(self, email): self.data["return_path"] = email.addr_spec def set_metadata(self, metadata): self.data["metadata"] = metadata def set_merge_metadata(self, merge_metadata): for recipient in self.data["recipients"]: to_email = recipient["address"]["email"] if to_email in merge_metadata: recipient["metadata"] = merge_metadata[to_email] def set_merge_headers(self, merge_headers): def header_var(field): return "Header__" + field.title().replace("-", "_") merge_header_fields = set() for recipient in self.data["recipients"]: to_email = recipient["address"]["email"] if to_email in merge_headers: recipient_headers = merge_headers[to_email] recipient.setdefault("substitution_data", {}).update( {header_var(key): value for key, value in recipient_headers.items()} ) merge_header_fields.update(recipient_headers.keys()) if merge_header_fields: headers = self.data.setdefault("content", {}).setdefault("headers", {}) # Global substitution_data supplies defaults for defined headers: self.data.setdefault("substitution_data", {}).update( { header_var(field): headers[field] for field in merge_header_fields if field in headers } ) # Indirect merge_headers through substitution_data: headers.update( {field: "{{%s}}" % header_var(field) for field in merge_header_fields} ) def set_send_at(self, send_at): try: start_time = send_at.replace(microsecond=0).isoformat() except (AttributeError, TypeError): start_time = send_at # assume user already formatted self.data.setdefault("options", {})["start_time"] = start_time def set_tags(self, tags): if len(tags) > 0: self.data["campaign_id"] = tags[0] if len(tags) > 1: self.unsupported_feature("multiple tags (%r)" % tags) def set_track_clicks(self, track_clicks): self.data.setdefault("options", {})["click_tracking"] = track_clicks def set_track_opens(self, track_opens): self.data.setdefault("options", {})["open_tracking"] = track_opens def set_template_id(self, template_id): self.data["content"]["template_id"] = template_id # Must remove empty string "content" params when using stored template. # (Non-empty params are left in place, to cause API error.) for content_param in ["subject", "text", "html"]: try: if not self.data["content"][content_param]: del self.data["content"][content_param] except KeyError: pass # "from" is also silently ignored. Strip it if empty or DEFAULT_FROM_EMAIL, # else leave in place to cause error in _check_content_options. try: from_email = self.data["content"]["from"] if not from_email or from_email == force_str(settings.DEFAULT_FROM_EMAIL): del self.data["content"]["from"] except KeyError: pass def set_merge_data(self, merge_data): for recipient in self.data["recipients"]: to_email = recipient["address"]["email"] if to_email in merge_data: recipient["substitution_data"] = merge_data[to_email] def set_merge_global_data(self, merge_global_data): self.data["substitution_data"] = merge_global_data # ESP-specific payload construction def set_esp_extra(self, extra): update_deep(self.data, extra) django-anymail-13.0/anymail/backends/test.py000066400000000000000000000132051477357323300211030ustar00rootroot00000000000000from django.core import mail 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). 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. """ esp_name = "Test" 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) super().__init__(*args, **kwargs) if not hasattr(mail, "outbox"): mail.outbox = [] # see django.core.mail.backends.locmem def get_esp_message_id(self, message): # Get a unique ID for the message. The message must have been added to # the outbox first. return mail.outbox.index(message) def build_message_payload(self, message, defaults): return self._payload_class(backend=self, message=message, defaults=defaults) def post_to_esp(self, payload, message): # Keep track of the sent messages and params (for test cases) message.anymail_test_params = payload.get_params() mail.outbox.append(message) try: # Tests can supply their own message.test_response: response = message.anymail_test_response if isinstance(response, AnymailAPIError): raise response except AttributeError: # Default is to return 'sent' for each recipient status = AnymailRecipientStatus( message_id=self.get_esp_message_id(message), status="sent" ) response = { "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"] except KeyError as err: raise AnymailAPIError("Unparsable test response") from err class TestPayload(BasePayload): # For test purposes, just keep a dict of the params we've received. # (This approach is also useful for native API backends -- think of # payload.params as collecting kwargs for esp_native_api.send().) def init_payload(self): self.params = {} self.recipient_emails = [] 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() return self.params def set_from_email(self, email): self.params["from"] = email def set_envelope_sender(self, email): self.params["envelope_sender"] = email.addr_spec def set_to(self, 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.recipient_emails += [email.addr_spec for email in emails] def set_bcc(self, emails): self.params["bcc"] = emails self.recipient_emails += [email.addr_spec for email in emails] def set_subject(self, subject): self.params["subject"] = subject def set_reply_to(self, emails): self.params["reply_to"] = emails def set_extra_headers(self, headers): self.params["extra_headers"] = headers def set_text_body(self, body): self.params["text_body"] = body def set_html_body(self, 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)) else: self.unsupported_feature("alternative part with type '%s'" % mimetype) def add_attachment(self, attachment): self.params.setdefault("attachments", []).append(attachment) def set_metadata(self, metadata): self.params["metadata"] = metadata def set_send_at(self, send_at): self.params["send_at"] = send_at def set_tags(self, tags): self.params["tags"] = tags def set_track_clicks(self, track_clicks): self.params["track_clicks"] = track_clicks def set_track_opens(self, track_opens): self.params["track_opens"] = track_opens def set_template_id(self, template_id): self.params["template_id"] = template_id def set_merge_data(self, merge_data): self.params["merge_data"] = merge_data def set_merge_headers(self, merge_headers): self.params["merge_headers"] = merge_headers def set_merge_metadata(self, 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 def set_esp_extra(self, extra): # Merge extra into params self.params.update(extra) django-anymail-13.0/anymail/backends/unisender_go.py000066400000000000000000000362651477357323300226200ustar00rootroot00000000000000from __future__ import annotations import re import typing import uuid from datetime import datetime, timezone from email.charset import QP, Charset from email.headerregistry import Address from django.core.mail import EmailMessage from requests import Response from requests.structures import CaseInsensitiveDict from anymail.backends.base_requests import AnymailRequestsBackend, RequestsPayload from anymail.message import AnymailRecipientStatus from anymail.utils import Attachment, EmailAddress, get_anymail_setting, update_deep # Used to force RFC-2047 encoded word # in address formatting workaround QP_CHARSET = Charset("utf-8") QP_CHARSET.header_encoding = QP class EmailBackend(AnymailRequestsBackend): """Unisender Go v1 Web API Email Backend""" esp_name = "Unisender Go" def __init__(self, **kwargs: typing.Any): """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.generate_message_id = get_anymail_setting( "generate_message_id", esp_name=esp_name, kwargs=kwargs, default=True ) # No default for api_url setting -- it depends on account's data center. E.g.: # - https://go1.unisender.ru/ru/transactional/api/v1 # - https://go2.unisender.ru/ru/transactional/api/v1 api_url = get_anymail_setting("api_url", esp_name=esp_name, kwargs=kwargs) if not api_url.endswith("/"): api_url += "/" # Undocumented setting to control workarounds for Unisender Go display-name issues # (see below). If/when Unisender Go fixes the problems, you can disable Anymail's # workarounds by adding `"UNISENDER_GO_WORKAROUND_DISPLAY_NAME_BUGS": False` # to your `ANYMAIL` settings. self.workaround_display_name_bugs = get_anymail_setting( "workaround_display_name_bugs", esp_name=esp_name, kwargs=kwargs, default=True, ) super().__init__(api_url, **kwargs) def build_message_payload( self, message: EmailMessage, defaults: dict ) -> UnisenderGoPayload: return UnisenderGoPayload(message=message, defaults=defaults, backend=self) # Map Unisender Go "failed_email" code -> AnymailRecipientStatus.status _unisender_failure_status = { # "duplicate": ignored (see parse_recipient_status) "invalid": "invalid", "permanent_unavailable": "rejected", "temporary_unavailable": "failed", "unsubscribed": "rejected", } def parse_recipient_status( self, response: Response, payload: UnisenderGoPayload, message: EmailMessage ) -> dict: """ Response example: { "status": "success", "job_id": "1ZymBc-00041N-9X", "emails": [ "user@example.com", "email@example.com", ], "failed_emails": { "email1@gmail.com": "temporary_unavailable", "bad@address": "invalid", "email@example.com": "duplicate", "root@example.org": "permanent_unavailable", "olduser@example.net": "unsubscribed" } } """ parsed_response = self.deserialize_json_response(response, payload, message) # job_id serves as message_id when not self.generate_message_id job_id = parsed_response.get("job_id") succeed_emails = { recipient: AnymailRecipientStatus( message_id=payload.message_ids.get(recipient, job_id), status="queued" ) for recipient in parsed_response["emails"] } failed_emails = { recipient: AnymailRecipientStatus( # Message wasn't sent to this recipient, so Unisender Go hasn't stored # any metadata (including message_id) message_id=None, status=self._unisender_failure_status.get(status, "failed"), ) for recipient, status in parsed_response.get("failed_emails", {}).items() if status != "duplicate" # duplicates are in both succeed and failed lists } return {**succeed_emails, **failed_emails} class UnisenderGoPayload(RequestsPayload): # Payload: see https://godocs.unisender.ru/web-api-ref#email-send data: dict def __init__( self, message: EmailMessage, defaults: dict, backend: EmailBackend, *args: typing.Any, **kwargs: typing.Any, ): self.generate_message_id = backend.generate_message_id self.message_ids = CaseInsensitiveDict() # recipient -> generated message_id http_headers = kwargs.pop("headers", {}) http_headers["Content-Type"] = "application/json" http_headers["Accept"] = "application/json" http_headers["X-API-KEY"] = backend.api_key super().__init__( message, defaults, backend, headers=http_headers, *args, **kwargs ) def get_api_endpoint(self) -> str: return "email/send.json" def init_payload(self) -> None: self.data = { # becomes json "headers": CaseInsensitiveDict(), "recipients": [], } def serialize_data(self) -> str: if self.generate_message_id: self.set_anymail_id() headers = self.data["headers"] if self.is_batch(): # Remove the all-recipient "to" header for batch sends. # Unisender Go will construct a single-recipient "to" for each recipient. # Unisender Go doesn't allow a "cc" header without an explicit "to" # header, so we cannot support "cc" for batch sends. headers.pop("to", None) if headers.pop("cc", None): self.unsupported_feature( "cc with batch send (merge_data, merge_metadata, or merge_headers)" ) if not headers: del self.data["headers"] # don't send empty headers return self.serialize_json({"message": self.data}) def set_anymail_id(self) -> None: """Ensure each personalization has a known anymail_id for event tracking""" for recipient in self.data["recipients"]: # This ensures duplicate recipients get same anymail_id # (because Unisender Go only sends to first instance of duplicate) email_address = recipient["email"] anymail_id = self.message_ids.get(email_address) or str(uuid.uuid4()) recipient.setdefault("metadata", {})["anymail_id"] = anymail_id self.message_ids[email_address] = anymail_id # # Payload construction # def set_from_email(self, email: EmailAddress) -> None: self.data["from_email"] = email.addr_spec if email.display_name: self.data["from_name"] = email.display_name def _format_email_address(self, address): """ Return EmailAddress address formatted for use with Unisender Go to/cc headers. Works around a bug in Unisender Go's API that rejects To or Cc headers containing commas, angle brackets, or @ in any display-name, despite those names being properly enclosed in "quotes" per RFC 5322. Workaround substitutes an RFC 2047 encoded word, which avoids the problem characters. Note that parens, quote chars, and other special characters appearing in "quoted strings" don't cause problems. (Unisender Go tech support has confirmed the problem is limited to , < > @.) This workaround is only necessary in the To and Cc headers. Unisender Go properly formats commas and other characters in `to_name` and `from_name`. (But see set_reply_to for a related issue.) """ formatted = address.address if self.backend.workaround_display_name_bugs: # Workaround: force RFC-2047 QP encoded word for display_name if it has # prohibited chars (and isn't already encoded in the formatted address) display_name = address.display_name if re.search(r"[,<>@]", display_name) and display_name in formatted: formatted = str( Address( display_name=QP_CHARSET.header_encode(address.display_name), addr_spec=address.addr_spec, ) ) return formatted def set_recipients(self, recipient_type: str, emails: list[EmailAddress]): for email in emails: recipient = {"email": email.addr_spec} if email.display_name: recipient["substitutions"] = {"to_name": email.display_name} self.data["recipients"].append(recipient) if emails and recipient_type in {"to", "cc"}: # Add "to" or "cc" header listing all recipients of type. # See https://godocs.unisender.ru/cc-and-bcc. # (For batch sends, these will be adjusted later in self.serialize_data.) self.data["headers"][recipient_type] = ", ".join( self._format_email_address(email) for email in emails ) def set_subject(self, subject: str) -> None: if subject: self.data["subject"] = subject def set_reply_to(self, emails: list[EmailAddress]) -> None: # Unisender Go only supports a single address in the reply_to API param. if len(emails) > 1: self.unsupported_feature("multiple reply_to addresses") if len(emails) > 0: reply_to = emails[0] self.data["reply_to"] = reply_to.addr_spec display_name = reply_to.display_name if display_name: if self.backend.workaround_display_name_bugs: # Unisender Go doesn't properly "quote" (RFC 5322) a `reply_to_name` # containing special characters (comma, parens, etc.), resulting # in an invalid Reply-To header that can cause problems when the # recipient tries to reply. (They *do* properly handle special chars # in `to_name` and `from_name`; this only affects `reply_to_name`.) if reply_to.address.startswith('"'): # requires quoted syntax # Workaround: force RFC-2047 encoded word display_name = QP_CHARSET.header_encode(display_name) self.data["reply_to_name"] = display_name def set_extra_headers(self, headers: dict[str, str]) -> None: self.data["headers"].update(headers) def set_text_body(self, body: str) -> None: if body: self.data.setdefault("body", {})["plaintext"] = body def set_html_body(self, body: str) -> None: if body: self.data.setdefault("body", {})["html"] = body def add_alternative(self, content: str, mimetype: str): if mimetype.lower() == "text/x-amp-html": if "amp" in self.data.get("body", {}): self.unsupported_feature("multiple amp-html parts") self.data.setdefault("body", {})["amp"] = content else: super().add_alternative(content, mimetype) def add_attachment(self, attachment: Attachment) -> None: name = attachment.cid if attachment.inline else attachment.name att = { "content": attachment.b64content, "type": attachment.mimetype, "name": name or "", # required - submit empty string if unknown } if attachment.inline: self.data.setdefault("inline_attachments", []).append(att) else: self.data.setdefault("attachments", []).append(att) def set_metadata(self, metadata: dict[str, str]) -> None: self.data["global_metadata"] = metadata def set_send_at(self, send_at: datetime | str) -> None: try: # "Date and time in the format “YYYY-MM-DD hh:mm:ss” in the UTC time zone." # If send_at is a datetime, it's guaranteed to be aware, but maybe not UTC. # Convert to UTC, then strip tzinfo to avoid isoformat "+00:00" at end. send_at_utc = send_at.astimezone(timezone.utc).replace(tzinfo=None) send_at_formatted = send_at_utc.isoformat(sep=" ", timespec="seconds") assert len(send_at_formatted) == 19 except (AttributeError, TypeError): # Not a datetime - caller is responsible for formatting send_at_formatted = send_at self.data.setdefault("options", {})["send_at"] = send_at_formatted def set_tags(self, tags: list[str]) -> None: self.data["tags"] = tags def set_track_clicks(self, track_clicks: typing.Any): self.data["track_links"] = 1 if track_clicks else 0 def set_track_opens(self, track_opens: typing.Any): self.data["track_read"] = 1 if track_opens else 0 def set_template_id(self, template_id: str) -> None: self.data["template_id"] = template_id def set_merge_data(self, merge_data: dict[str, dict[str, str]]) -> None: if not merge_data: return assert self.data["recipients"] # must be called after set_to for recipient in self.data["recipients"]: recipient_email = recipient["email"] if recipient_email in merge_data: # (substitutions may already be present with "to_email") recipient.setdefault("substitutions", {}).update( merge_data[recipient_email] ) def set_merge_global_data(self, merge_global_data: dict[str, str]) -> None: self.data["global_substitutions"] = merge_global_data def set_merge_metadata(self, merge_metadata: dict[str, str]) -> None: assert self.data["recipients"] # must be called after set_to for recipient in self.data["recipients"]: recipient_email = recipient["email"] if recipient_email in merge_metadata: recipient["metadata"] = merge_metadata[recipient_email] # Unisender Go supports header substitution only with List-Unsubscribe. # (See https://godocs.unisender.ru/web-api-ref#email-send under "substitutions".) SUPPORTED_MERGE_HEADERS = {"List-Unsubscribe"} def set_merge_headers(self, merge_headers: dict[str, dict[str, str]]) -> None: assert self.data["recipients"] # must be called after set_to if merge_headers: for recipient in self.data["recipients"]: recipient_email = recipient["email"] for key, value in merge_headers.get(recipient_email, {}).items(): field = key.title() # canonicalize field name capitalization if field in self.SUPPORTED_MERGE_HEADERS: # Set up a substitution for Header__Field_Name field_sub = "Header__" + field.replace("-", "_") recipient.setdefault("substitutions", {})[field_sub] = value self.data.setdefault("headers", {})[field] = ( "{{%s}}" % field_sub ) else: self.unsupported_feature(f"{field!r} in merge_headers") def set_esp_extra(self, extra: dict) -> None: update_deep(self.data, extra) django-anymail-13.0/anymail/checks.py000066400000000000000000000033171477357323300176150ustar00rootroot00000000000000from django.conf import settings from django.core import checks from anymail.utils import get_anymail_setting def check_deprecated_settings(app_configs, **kwargs): errors = [] anymail_settings = getattr(settings, "ANYMAIL", {}) # anymail.W001: reserved [was deprecation warning that became anymail.E001] # 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", ) ) 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", ) ) return errors def check_insecure_settings(app_configs, **kwargs): errors = [] # 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", ) ) return errors django-anymail-13.0/anymail/exceptions.py000066400000000000000000000160771477357323300205450ustar00rootroot00000000000000import json from traceback import format_exception_only from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation from requests import HTTPError class AnymailError(Exception): """Base class for exceptions raised by Anymail Overrides __str__ to provide additional information about the ESP API call and response. """ def __init__(self, *args, **kwargs): """ Optional kwargs: email_message: the original EmailMessage being sent status_code: HTTP status code of response to ESP send call backend: the backend instance involved payload: data arg (*not* json-stringified) for the ESP send call 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 ) if isinstance(self, HTTPError): # must leave response in kwargs for HTTPError self.response = kwargs.get("response", None) else: self.response = kwargs.pop("response", None) super().__init__(*args, **kwargs) def __str__(self): parts = [ " ".join([str(arg) for arg in self.args]), self.describe_cause(), self.describe_response(), ] return "\n".join(filter(None, parts)) def describe_response(self): """Return a formatted string of self.status_code and response, or None""" if self.status_code is None: return None # 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") except UnicodeDecodeError: reason = reason.decode("iso-8859-1") 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) except (AttributeError, KeyError, ValueError): # not JSON = ValueError try: description += ": %r" % self.response.text except AttributeError: pass return description def describe_cause(self): """Describe the original exception""" if self.__cause__ is None: return None return "".join( format_exception_only(type(self.__cause__), self.__cause__) ).strip() class AnymailAPIError(AnymailError): """Exception for unsuccessful response from ESP's API.""" class AnymailRequestsAPIError(AnymailAPIError, HTTPError): """Exception for unsuccessful response from a requests API.""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if self.response is not None: self.status_code = self.response.status_code class AnymailRecipientsRefused(AnymailError): """Exception for send where all recipients are invalid or rejected.""" def __init__(self, message=None, *args, **kwargs): if message is None: message = "All message recipients were rejected or invalid" super().__init__(message, *args, **kwargs) class AnymailInvalidAddress(AnymailError, ValueError): """Exception when using an invalidly-formatted email address""" class AnymailUnsupportedFeature(AnymailError, ValueError): """Exception for Anymail features that the ESP doesn't support. This is typically raised when attempting to send a Django EmailMessage that uses options or values you might expect to work, but that are silently ignored by or can't be communicated to the ESP's API. It's generally *not* raised for ESP-specific limitations, like the number of tags allowed on a message. (Anymail expects the ESP to return an API error for these where appropriate, and tries to avoid duplicating each ESP's validation logic locally.) """ class AnymailSerializationError(AnymailError, TypeError): """Exception for data that Anymail can't serialize for the ESP's API. This typically results from including something like a date or Decimal 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 ) if orig_err is not None: message += "\n%s" % str(orig_err) super().__init__(message, *args, **kwargs) class AnymailCancelSend(AnymailError): """Pre-send signal receiver can raise to prevent message send""" class AnymailWebhookValidationFailure(AnymailError, SuspiciousOperation): """Exception when a webhook cannot be validated. Django's SuspiciousOperation turns into an HTTP 400 error in production. """ 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 class AnymailImproperlyInstalled(AnymailConfigurationError, ImportError): """Exception for Anymail missing package dependencies""" def __init__(self, missing_package, install_extra=""): # install_extra must be the package "optional extras name" for the ESP # (not the backend's esp_name) 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 ESP name(s).)" % (missing_package, install_extra) ) super().__init__(message) # Warnings class AnymailWarning(Warning): """Base warning for Anymail""" class AnymailInsecureWebhookWarning(AnymailWarning): """Warns when webhook configured without any validation""" class AnymailDeprecationWarning(AnymailWarning, DeprecationWarning): """Warning for deprecated Anymail features""" # Helpers class _LazyError: """An object that sits inert unless/until used, then raises an error""" def __init__(self, error): self._error = error def __call__(self, *args, **kwargs): raise self._error def __getattr__(self, item): raise self._error django-anymail-13.0/anymail/inbound.py000066400000000000000000000361051477357323300200140ustar00rootroot00000000000000import warnings from base64 import b64decode from email.message import EmailMessage from email.parser import BytesParser, Parser from email.policy import default as default_policy from email.utils import unquote from django.core.files.uploadedfile import SimpleUploadedFile from .exceptions import AnymailDeprecationWarning from .utils import angle_wrap, parse_address_list, parse_rfc2822date class AnymailInboundMessage(EmailMessage): """ A normalized, parsed inbound email message. A subclass of email.message.EmailMessage, with some additional convenience properties. """ # Why Python email.message.EmailMessage rather than django.core.mail.EmailMessage? # Django's EmailMessage is really intended for constructing a (limited subset of) # an EmailMessage to send; Python's EmailMessage is better designed for representing # arbitrary messages: # # * Python's EmailMessage is easily parsed from raw mime (which is an inbound format # provided by many ESPs), and can accurately represent any mime email received # * Python's EmailMessage 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) super().__init__(*args, **kwargs) # Additional attrs provided by some ESPs: self.envelope_sender = None self.envelope_recipient = None self.stripped_text = None self.stripped_html = None self.spam_detected = None self.spam_score = None # # Convenience accessors # @property def from_email(self): """EmailAddress""" # equivalent to Python 3.2+ message['From'].addresses[0] from_email = self.get_address_header("From") if len(from_email) == 1: return from_email[0] elif len(from_email) == 0: return None else: # 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") @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") @property def bcc(self): """list of EmailAddress objects from Bcc header""" # equivalent to Python 3.2+ message['Bcc'].addresses return self.get_address_header("Bcc") @property def subject(self): """str value of Subject header, or None""" 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") @property def text(self): """Contents of the (first) text/plain body part, or None""" 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") @property def attachments(self): """list of attachments (as MIMEPart objects); excludes inlines""" return [part for part in self.walk() if part.is_attachment()] @property def inlines(self): """list of inline parts (as MIMEPart objects)""" return [part for part in self.walk() if part.is_inline()] @property def inline_attachments(self): """DEPRECATED: use content_id_map instead""" warnings.warn( "inline_attachments has been renamed to content_id_map and will be removed" " in the near future.", AnymailDeprecationWarning, ) return self.content_id_map @property def content_id_map(self): """dict of Content-ID: attachment (as MIMEPart objects)""" return { unquote(part["Content-ID"]): part for part in self.walk() if part.is_inline() 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 """ values = self.get_all(header) if values is not None: if "".join(values).strip() == "": values = None else: values = parse_address_list(values) return values or [] def get_date_header(self, header): """Return the value of header parsed into a datetime.date, or None""" value = self[header] if value is not None: value = parse_rfc2822date(value) 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. for part in self.walk(): if part.get_content_type() == content_type and not part.is_attachment(): return part.get_content_text() return None def is_inline(self): return self.get_content_disposition() == "inline" # New for Anymail def is_inline_attachment(self): """DEPRECATED: use in_inline instead""" warnings.warn( "is_inline_attachment has been renamed to is_inline and will be removed" " in the near future.", AnymailDeprecationWarning, ) return self.is_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. # (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": # 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()?)" ) 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. # (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": # 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()?)" ) 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" 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: return None # this part is not an attachment name = self.get_filename() content_type = self.get_content_type() content = self.get_content_bytes() return SimpleUploadedFile(name, content, content_type) # # Construction # # These methods are intended primarily for internal Anymail use # (in inbound webhook handlers) @classmethod def parse_raw_mime(cls, s): """Returns a new AnymailInboundMessage parsed from str s""" 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 Parser(cls, policy=default_policy).parsestr(s) @classmethod def parse_raw_mime_bytes(cls, b): """Returns a new AnymailInboundMessage parsed from bytes b""" return BytesParser(cls, policy=default_policy).parsebytes(b) @classmethod def parse_raw_mime_file(cls, fp): """Returns a new AnymailInboundMessage parsed from file-like object fp""" if isinstance(fp.read(0), bytes): return BytesParser(cls, policy=default_policy).parse(fp) else: return Parser(cls, policy=default_policy).parse(fp) @classmethod def construct( cls, raw_headers=None, from_email=None, to=None, cc=None, bcc=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.) :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 :param bcc: {str|None} value for Bcc header :param subject: {str|None} value for Subject header :param headers: {sequence[(str, str)]|mapping|None} additional headers :param text: {str|None} plaintext body :param text_charset: {str} charset of plaintext body; default utf-8 :param html: {str|None} html body :param html_charset: {str} charset of html body; default utf-8 :param attachments: {list[MIMEBase]|None} as returned by construct_attachment :return: {AnymailInboundMessage} """ if raw_headers is not None: 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 if to is not None: del msg["To"] msg["To"] = to if cc is not None: del msg["Cc"] msg["Cc"] = cc if bcc is not None: del msg["Bcc"] msg["Bcc"] = bcc if subject is not None: del msg["Subject"] msg["Subject"] = subject if headers is not None: try: header_items = headers.items() # mapping except AttributeError: header_items = headers # sequence of (key, value) for name, value in header_items: msg.add_header(name, value) # 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" msg.attach(related) alternatives = cls() # container for text and html bodies alternatives["Content-Type"] = "multipart/alternative" related.attach(alternatives) if text is not None: part = cls() 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.set_payload(html, charset=html_charset) alternatives.attach(part) if attachments is not None: for attachment in attachments: if attachment.is_inline(): related.attach(attachment) else: msg.attach(attachment) return msg @classmethod def construct_attachment_from_uploaded_file(cls, file, content_id=None): # This pulls the entire file into memory; it would be better to implement # 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=file.read(), filename=getattr(file, "name", None), content_id=content_id, charset=getattr(file, "charset", None), ) @classmethod 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" ) if filename is not None: 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) if base64: content = b64decode(content) payload = content 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): content = content.decode() payload = [cls.parse_raw_mime(content)] charset = None part.set_payload(payload, charset) return part django-anymail-13.0/anymail/message.py000066400000000000000000000142651477357323300200050ustar00rootroot00000000000000from email.mime.image import MIMEImage from email.utils import make_msgid, unquote from pathlib import Path from django.core.mail import EmailMessage, EmailMultiAlternatives from .utils import UNSET class AnymailMessageMixin(EmailMessage): """Mixin for EmailMessage that exposes Anymail features. Use of this mixin is optional. You can always just set Anymail attributes on any EmailMessage. (The mixin can be helpful with type checkers and other development tools that complain about accessing Anymail's added attributes on a regular 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_headers = kwargs.pop("merge_headers", 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 """ 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 ): """Add inline image and return its content id""" assert isinstance(self, EmailMessage) return attach_inline_image(self, content, filename, subtype, idstring, domain) class AnymailMessage(AnymailMessageMixin, EmailMultiAlternatives): pass def attach_inline_image_file(message, path, subtype=None, idstring="img", domain=None): """Add inline image from file path to an EmailMessage, and return its content id""" pathobj = Path(path) filename = pathobj.name content = pathobj.read_bytes() return attach_inline_image(message, content, filename, subtype, idstring, domain) 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 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) 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 ] class AnymailRecipientStatus: """Information about an EmailMessage's send status for a single recipient""" def __init__(self, message_id, status): try: # message_id must be something that can be put in a set # (see AnymailStatus.set_recipient_status) set([message_id]) except TypeError: raise TypeError("Invalid message_id %r is not scalar type" % message_id) if status is not None and status not in ANYMAIL_STATUSES: raise ValueError("Invalid status %r" % status) self.message_id = message_id # ESP message id self.status = status # one of ANYMAIL_STATUSES, or None for not yet sent to ESP def __repr__(self): return "AnymailRecipientStatus({message_id!r}, {status!r})".format( message_id=self.message_id, status=self.status ) class AnymailStatus: """Information about an EmailMessage's send status for all recipients""" def __init__(self): #: 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): def _repr(o): if isinstance(o, set): # force sorted order, for reproducible testing item_reprs = [repr(item) for item in sorted(o)] 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)) ) if 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] ) 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]) django-anymail-13.0/anymail/signals.py000066400000000000000000000102271477357323300200130ustar00rootroot00000000000000from django.dispatch import Signal #: Outbound message, before sending #: provides args: message, esp_name pre_send = Signal() #: 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 tracking = Signal() #: 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 ): #: 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): """Normalized delivery and tracking event for sent messages""" def __init__(self, **kwargs): super().__init__(**kwargs) 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): """Normalized inbound message event""" def __init__(self, **kwargs): super().__init__(**kwargs) #: anymail.inbound.AnymailInboundMessage self.message = kwargs.pop("message", None) class EventType: """Constants for normalized Anymail event types""" # Delivery (and non-delivery) event types # (these match message.ANYMAIL_STATUSES where appropriate) #: the ESP has accepted the message and will try to send it (possibly later) QUEUED = "queued" #: the ESP has sent the message (though it may or may not get delivered) SENT = "sent" #: the ESP refused to send the message #: (e.g., suppression list, policy, invalid email) REJECTED = "rejected" #: 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""" #: 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" django-anymail-13.0/anymail/urls.py000066400000000000000000000115701477357323300173420ustar00rootroot00000000000000from django.urls import path, re_path from .webhooks.amazon_ses import ( AmazonSESInboundWebhookView, AmazonSESTrackingWebhookView, ) from .webhooks.brevo import BrevoInboundWebhookView, BrevoTrackingWebhookView from .webhooks.mailersend import ( MailerSendInboundWebhookView, MailerSendTrackingWebhookView, ) from .webhooks.mailgun import MailgunInboundWebhookView, MailgunTrackingWebhookView from .webhooks.mailjet import MailjetInboundWebhookView, MailjetTrackingWebhookView from .webhooks.mandrill import MandrillCombinedWebhookView from .webhooks.postal import PostalInboundWebhookView, PostalTrackingWebhookView from .webhooks.postmark import PostmarkInboundWebhookView, PostmarkTrackingWebhookView from .webhooks.resend import ResendTrackingWebhookView from .webhooks.sendgrid import SendGridInboundWebhookView, SendGridTrackingWebhookView from .webhooks.sendinblue import ( SendinBlueInboundWebhookView, SendinBlueTrackingWebhookView, ) from .webhooks.sparkpost import ( SparkPostInboundWebhookView, SparkPostTrackingWebhookView, ) from .webhooks.unisender_go import UnisenderGoTrackingWebhookView app_name = "anymail" urlpatterns = [ path( "amazon_ses/inbound/", AmazonSESInboundWebhookView.as_view(), name="amazon_ses_inbound_webhook", ), path( "brevo/inbound/", BrevoInboundWebhookView.as_view(), name="brevo_inbound_webhook", ), path( "mailersend/inbound/", MailerSendInboundWebhookView.as_view(), name="mailersend_inbound_webhook", ), re_path( # Mailgun delivers inbound messages differently based on whether # the webhook url contains "mime" (anywhere). You can use either # ".../mailgun/inbound/" or ".../mailgun/inbound_mime/" depending # on the behavior you want. r"^mailgun/inbound(_mime)?/$", MailgunInboundWebhookView.as_view(), name="mailgun_inbound_webhook", ), path( "mailjet/inbound/", MailjetInboundWebhookView.as_view(), name="mailjet_inbound_webhook", ), path( "postal/inbound/", PostalInboundWebhookView.as_view(), name="postal_inbound_webhook", ), path( "postmark/inbound/", PostmarkInboundWebhookView.as_view(), name="postmark_inbound_webhook", ), path( "sendgrid/inbound/", SendGridInboundWebhookView.as_view(), name="sendgrid_inbound_webhook", ), path( # Compatibility for old SendinBlue esp_name; use Brevo in new code "sendinblue/inbound/", SendinBlueInboundWebhookView.as_view(), name="sendinblue_inbound_webhook", ), path( "sparkpost/inbound/", SparkPostInboundWebhookView.as_view(), name="sparkpost_inbound_webhook", ), path( "amazon_ses/tracking/", AmazonSESTrackingWebhookView.as_view(), name="amazon_ses_tracking_webhook", ), path( "brevo/tracking/", BrevoTrackingWebhookView.as_view(), name="brevo_tracking_webhook", ), path( "mailersend/tracking/", MailerSendTrackingWebhookView.as_view(), name="mailersend_tracking_webhook", ), path( "mailgun/tracking/", MailgunTrackingWebhookView.as_view(), name="mailgun_tracking_webhook", ), path( "mailjet/tracking/", MailjetTrackingWebhookView.as_view(), name="mailjet_tracking_webhook", ), path( "postal/tracking/", PostalTrackingWebhookView.as_view(), name="postal_tracking_webhook", ), path( "postmark/tracking/", PostmarkTrackingWebhookView.as_view(), name="postmark_tracking_webhook", ), path( "resend/tracking/", ResendTrackingWebhookView.as_view(), name="resend_tracking_webhook", ), path( "sendgrid/tracking/", SendGridTrackingWebhookView.as_view(), name="sendgrid_tracking_webhook", ), path( # Compatibility for old SendinBlue esp_name; use Brevo in new code "sendinblue/tracking/", SendinBlueTrackingWebhookView.as_view(), name="sendinblue_tracking_webhook", ), path( "sparkpost/tracking/", SparkPostTrackingWebhookView.as_view(), name="sparkpost_tracking_webhook", ), path( "unisender_go/tracking/", UnisenderGoTrackingWebhookView.as_view(), name="unisender_go_tracking_webhook", ), # Anymail uses a combined Mandrill webhook endpoint, # to simplify Mandrill's key-validation scheme: path("mandrill/", MandrillCombinedWebhookView.as_view(), name="mandrill_webhook"), # This url is maintained for backwards compatibility with earlier Anymail releases: path( "mandrill/tracking/", MandrillCombinedWebhookView.as_view(), name="mandrill_tracking_webhook", ), ] django-anymail-13.0/anymail/utils.py000066400000000000000000000556211477357323300175220ustar00rootroot00000000000000import base64 import mimetypes import re from base64 import b64encode from collections.abc import Mapping, MutableMapping from copy import copy, deepcopy from email.mime.base import MIMEBase from email.utils import formatdate, getaddresses, parsedate_to_datetime, unquote from urllib.parse import urlsplit, urlunsplit from django.conf import settings from django.core.mail.message import DEFAULT_ATTACHMENT_MIME_TYPE, sanitize_address from django.utils.encoding import force_str from django.utils.functional import Promise from requests.structures import CaseInsensitiveDict from .exceptions import AnymailConfigurationError, AnymailInvalidAddress BASIC_NUMERIC_TYPES = (int, float) UNSET = type("UNSET", (object,), {}) # Used as non-None default value def concat_lists(*args): """ Combines all non-UNSET args, by concatenating lists (or sequence-like types). Does not modify any args. >>> concat_lists([1, 2], UNSET, [3, 4], UNSET) [1, 2, 3, 4] >>> concat_lists([1, 2], None, [3, 4]) # None suppresses earlier args [3, 4] >>> concat_lists() UNSET """ result = UNSET for value in args: if value is None: # None is a request to suppress any earlier values result = UNSET elif value is not UNSET: if result is UNSET: result = list(value) else: result = result + list(value) # concatenate sequence-like return result def merge_dicts_shallow(*args): """ Shallow-merges all non-UNSET args. Does not modify any args. >>> merge_dicts_shallow({'a': 1, 'b': 2}, UNSET, {'b': 3, 'c': 4}, UNSET) {'a': 1, 'b': 3, 'c': 4} >>> merge_dicts_shallow({'a': {'a1': 1, 'a2': 2}}, {'a': {'a1': 11, 'a3': 33}}) {'a': {'a1': 11, 'a3': 33}} >>> merge_dicts_shallow({'a': 1}, None, {'b': 2}) # None suppresses earlier args {'b': 2} >>> merge_dicts_shallow() UNSET """ result = UNSET for value in args: if value is None: # None is a request to suppress any earlier values result = UNSET elif value is not UNSET: if result is UNSET: result = copy(value) else: result.update(value) return result def merge_dicts_deep(*args): """ Deep-merges all non-UNSET args. Does not modify any args. >>> merge_dicts_deep({'a': 1, 'b': 2}, UNSET, {'b': 3, 'c': 4}, UNSET) {'a': 1, 'b': 3, 'c': 4} >>> merge_dicts_deep({'a': {'a1': 1, 'a2': 2}}, {'a': {'a1': 11, 'a3': 33}}) {'a': {'a1': 11, 'a2': 2, 'a3': 33}} >>> merge_dicts_deep({'a': 1}, None, {'b': 2}) # None suppresses earlier args {'b': 2} >>> merge_dicts_deep() UNSET """ result = UNSET for value in args: if value is None: # None is a request to suppress any earlier values result = UNSET elif value is not UNSET: if result is UNSET: result = deepcopy(value) else: update_deep(result, value) return result def merge_dicts_one_level(*args): """ Mixture of merge_dicts_deep and merge_dicts_shallow: Deep merges first level, shallow merges second level. Does not modify any args. (Useful for {"email": {options...}, ...} style dicts, like merge_data: shallow merges the options for each email.) """ result = UNSET for value in args: if value is None: # None is a request to suppress any earlier values result = UNSET elif value is not UNSET: if result is UNSET: result = {} for k, v in value.items(): result.setdefault(k, {}).update(v) return result def last(*args): """Returns the last of its args which is not UNSET. >>> last(1, 2, UNSET, 3, UNSET, UNSET) 3 >>> last(1, 2, None, UNSET) # None suppresses earlier args UNSET >>> last() UNSET """ for value in reversed(args): if value is None: # None is a request to suppress any earlier values return UNSET elif value is not UNSET: return value return UNSET def getfirst(dct, keys, default=UNSET): """Returns the value of the first of keys found in dict dct. >>> getfirst({'a': 1, 'b': 2}, ['c', 'a']) 1 >>> getfirst({'a': 1, 'b': 2}, ['b', 'a']) 2 >>> getfirst({'a': 1, 'b': 2}, ['c']) KeyError >>> getfirst({'a': 1, 'b': 2}, ['c'], None) None """ for key in keys: try: return dct[key] except KeyError: pass if default is UNSET: raise KeyError("None of %s found in dict" % ", ".join(keys)) else: return default def update_deep(dct, other): """Merge (recursively) keys and values from dict other into dict dct Works with dict-like objects: dct (and descendants) can be any MutableMapping, 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) ): update_deep(dct[key], value) else: dct[key] = value # (like dict.update(), no return value) def parse_address_list(address_list, field=None): """Returns a list of EmailAddress objects from strings in address_list. Essentially wraps :func:`email.utils.getaddresses` with better error messaging and more-useful output objects Note that the returned list might be longer than the address_list param, if any individual string contains multiple comma-separated addresses. :param list[str]|str|None|list[None] address_list: the address or addresses to parse :param str|None field: optional description of the source of these addresses, for error message :return list[:class:`EmailAddress`]: :raises :exc:`AnymailInvalidAddress`: """ if isinstance(address_list, str) or is_lazy(address_list): address_list = [address_list] if address_list is None or address_list == [None]: return [] # For consistency with Django's SMTP backend behavior, extract all addresses # 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.) # 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 ] # 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( problem=address.addr_spec, source=", ".join(address_list_strings), where=" in `%s`" % field if field else "", ) if len(parsed) > len(address_list): errmsg += " (Maybe missing quotes around a display-name?)" raise AnymailInvalidAddress(errmsg) return parsed 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 :return :class:`EmailAddress`: if address contains a single email :raises :exc:`AnymailInvalidAddress`: if address contains no or multiple emails """ parsed = parse_address_list([address], field=field) 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 "" ) ) else: return parsed[0] class EmailAddress: """A sanitized, complete email address with easy access to display-name, addr-spec (email), etc. Similar to Python 3.6+ email.headerregistry.Address Instance properties, all read-only: :ivar str display_name: the address's display-name portion (unqouted, unescaped), e.g., 'Display Name, Inc.' :ivar str addr_spec: the address's addr-spec portion (unquoted, unescaped), e.g., 'user@example.com' :ivar str username: the local part (before the '@') of the addr-spec, e.g., 'user' :ivar str domain: the domain part (after the '@') of the addr-spec, e.g., 'example.com' :ivar str address: the fully-formatted address, with any necessary quoting and escaping, e.g., '"Display Name, Inc." ' (also available as `str(EmailAddress)`) """ def __init__(self, display_name="", addr_spec=None): self._address = None # lazy formatted address if addr_spec is None: try: display_name, addr_spec = display_name # unpack (name,addr) tuple except ValueError: pass # 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") self.display_name = display_name self.addr_spec = addr_spec try: self.username, self.domain = addr_spec.split("@", 1) # do we need to unquote username? except ValueError: self.username = addr_spec self.domain = "" def __repr__(self): return "EmailAddress({display_name!r}, {addr_spec!r})".format( display_name=self.display_name, addr_spec=self.addr_spec ) @property def address(self): if self._address is None: # (you might be tempted to use `encoding=settings.DEFAULT_CHARSET` here, # but that always forces the display-name to quoted-printable/base64, # even when simple ascii would work fine--and be more readable) self._address = self.formataddr() return self._address def formataddr(self, encoding=None): """Return a fully-formatted email address, using encoding. This is essentially the same as :func:`email.utils.formataddr` on the EmailAddress's name and email properties, but uses Django's :func:`~django.core.mail.message.sanitize_address` for consistent handling of encoding (a.k.a. charset) and proper handling of IDN domain portions. :param str|None encoding: the charset to use for the display-name portion; default None uses ascii if possible, else 'utf-8' (quoted-printable utf-8/base64) """ sanitized = sanitize_address((self.display_name, self.addr_spec), encoding) # sanitize_address() can introduce FWS with a long, non-ASCII display name. # Must unfold it: return re.sub(r"(\r|\n|\r\n)[ \t]", "", sanitized) def __str__(self): return self.address class Attachment: """A normalized EmailMessage.attachments item with additional functionality Normalized to have these properties: name: attachment filename; may be None content: bytestream mimetype: the content type; guessed if not explicit inline: bool, True if attachment has a Content-ID header content_id: for inline, the Content-ID (*with* <>); may be None cid: for inline, the Content-ID *without* <>; may be empty string """ def __init__(self, attachment, encoding): # 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 # or check attachment["Content-Encoding"] ??? self.inline = False self.content_id = None self.cid = "" if isinstance(attachment, MIMEBase): self.name = attachment.get_filename() self.content = attachment.get_payload(decode=True) if self.content is None: self.content = attachment.as_bytes() self.mimetype = attachment.get_content_type() # 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 ): self.inline = True self.content_id = attachment["Content-ID"] # probably including <...> if self.content_id is not None: self.cid = unquote(self.content_id) # without the <, > else: (self.name, self.content, self.mimetype) = attachment self.content_type = self.mimetype self.name = force_non_lazy(self.name) self.content = force_non_lazy(self.content) # Guess missing mimetype from filename, borrowed from # django.core.mail.EmailMessage._create_attachment() if self.mimetype is None and self.name is not None: self.mimetype, _ = mimetypes.guess_type(self.name) if self.mimetype is None: self.mimetype = DEFAULT_ATTACHMENT_MIME_TYPE if self.content_type is None: self.content_type = self.mimetype def __repr__(self): details = [ self.mimetype, "len={length}".format(length=len(self.content)), ] if self.name: 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) ) return "Attachment<{details}>".format(details=", ".join(details)) @property def b64content(self): """Content encoded as a base64 ascii string""" content = self.content if isinstance(content, str): content = content.encode(self.encoding) return b64encode(content).decode("ascii") 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: - kwargs[name] -- e.g., kwargs['api_key'] -- and name key will be popped from kwargs - settings.ANYMAIL['_'] -- e.g., settings.ANYMAIL['MAILGUN_API_KEY'] - settings.ANYMAIL__ -- e.g., settings.ANYMAIL_MAILGUN_API_KEY - settings._ (only if allow_bare) -- e.g., settings.MAILGUN_API_KEY - default if provided; else raises AnymailConfigurationError If allow_bare, allows settings._ without the ANYMAIL_ prefix: ANYMAIL = { "MAILGUN_API_KEY": "xyz", ... } ANYMAIL_MAILGUN_API_KEY = "xyz" MAILGUN_API_KEY = "xyz" """ try: value = kwargs.pop(name) 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 # 'SENDGRID_USERNAME' get unintentionally overridden from kwargs). if value is not None: return value else: return value except (AttributeError, KeyError): pass if esp_name is not None: setting = "{}_{}".format(esp_name.upper().replace(" ", "_"), name.upper()) else: setting = name.upper() anymail_setting = "ANYMAIL_%s" % setting try: return settings.ANYMAIL[setting] except (AttributeError, KeyError): try: return getattr(settings, anymail_setting) except AttributeError: if allow_bare: try: return getattr(settings, setting) except AttributeError: pass if default is UNSET: message = "You must set %s or ANYMAIL = {'%s': ...}" % ( anymail_setting, setting, ) if allow_bare: message += " or %s" % setting message += " in your Django settings" raise AnymailConfigurationError(message) from None else: return default def collect_all_methods(cls, method_name): """Return list of all `method_name` methods for cls and its superclass chain. List is in MRO order, with no duplicates. Methods are unbound. (This is used to simplify mixins and subclasses that contribute to a method set, without requiring superclass chaining, and without requiring cooperating superclasses.) """ methods = [] for ancestor in cls.__mro__: try: validator = getattr(ancestor, method_name) except AttributeError: pass else: if validator not in methods: methods.append(validator) return methods def querydict_getfirst(qdict, field, default=UNSET): """ 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') >>> querydict_getfirst(q, 'a') '1' >>> q.get('a') '3' >>> q['a'] '3' You can bind this to a QueryDict instance using the "descriptor protocol": >>> q.getfirst = querydict_getfirst.__get__(q) >>> 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.) values = qdict.getlist(field) if len(values) > 0: return values[0] elif default is not UNSET: return default else: return qdict[field] # raise appropriate KeyError def rfc2822date(dt): """Turn a datetime into a date string as specified in RFC 2822.""" # This is almost the equivalent of Python's email.utils.format_datetime, # but treats naive datetimes as local rather than "UTC with no information ..." timeval = dt.timestamp() return formatdate(timeval, usegmt=True) def angle_wrap(s): """Return s surrounded by angle brackets, added only if necessary""" # 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 + ">" return s def is_lazy(obj): """Return True if obj is a Django lazy object.""" # See django.utils.functional.lazy. (This appears to be preferred # to checking for `not isinstance(obj, str)`.) return isinstance(obj, Promise) def force_non_lazy(obj): """ 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.) """ if is_lazy(obj): return str(obj) return obj def force_non_lazy_list(obj): """Return a (shallow) copy of sequence obj, with all values forced non-lazy.""" try: return [force_non_lazy(item) for item in obj] except (AttributeError, TypeError): return force_non_lazy(obj) def force_non_lazy_dict(obj): """Return a (deep) copy of dict obj, with all values forced non-lazy.""" try: return {key: force_non_lazy_dict(value) for key, value in obj.items()} except (AttributeError, TypeError): return force_non_lazy(obj) def get_request_basic_auth(request): """Returns HTTP basic auth string sent with request, or None. If request includes basic auth, result is string 'username:password'. """ try: authtype, authdata = request.META["HTTP_AUTHORIZATION"].split() if authtype.lower() == "basic": return base64.b64decode(authdata).decode("utf-8") except (IndexError, KeyError, TypeError, ValueError): pass return None def get_request_uri(request): """Returns the "exact" url used to call request. Like :func:`django.http.request.HTTPRequest.build_absolute_uri`, but also inlines HTTP basic auth, if present. """ url = request.build_absolute_uri() basic_auth = get_request_basic_auth(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, ) ) return url def parse_rfc2822date(s): """Parses an RFC-2822 formatted date string into a datetime.datetime Returns None if string isn't parseable. Returned datetime will be naive if string doesn't include known timezone offset; aware if it does. (Same as Python 3 email.utils.parsedate_to_datetime, with improved handling for unparseable date strings.) """ try: return parsedate_to_datetime(s) except (IndexError, TypeError, ValueError): # despite the docs, parsedate_to_datetime often dies on unparseable input return None class CaseInsensitiveCasePreservingDict(CaseInsensitiveDict): """A dict with case-insensitive keys, which preserves the *first* key set. >>> cicpd = CaseInsensitiveCasePreservingDict() >>> cicpd["Accept"] = "application/text+xml" >>> cicpd["accEPT"] = "application/json" >>> cicpd["accept"] "application/json" >>> cicpd.keys() ["Accept"] Compare to CaseInsensitiveDict, which preserves *last* key set: >>> cid = CaseInsensitiveCasePreservingDict() >>> cid["Accept"] = "application/text+xml" >>> cid["accEPT"] = "application/json" >>> cid.keys() ["accEPT"] """ def __setitem__(self, key, value): _k = key.lower() try: # retrieve earlier matching key, if any key, _ = self._store[_k] except KeyError: pass self._store[_k] = (key, value) def copy(self): return self.__class__(self._store.values()) django-anymail-13.0/anymail/webhooks/000077500000000000000000000000001477357323300176205ustar00rootroot00000000000000django-anymail-13.0/anymail/webhooks/__init__.py000066400000000000000000000000001477357323300217170ustar00rootroot00000000000000django-anymail-13.0/anymail/webhooks/amazon_ses.py000066400000000000000000000454611477357323300223430ustar00rootroot00000000000000from __future__ import annotations import io import json import typing from base64 import b64decode from django.http import HttpResponse from django.utils.dateparse import parse_datetime from ..exceptions import ( AnymailAPIError, AnymailConfigurationError, AnymailImproperlyInstalled, AnymailWebhookValidationFailure, _LazyError, ) from ..inbound import AnymailInboundMessage 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", install_extra="amazon-ses") ) ClientError = object _get_anymail_boto3_params = _LazyError( AnymailImproperlyInstalled(missing_package="boto3", install_extra="amazon-ses") ) class AmazonSESBaseWebhookView(AnymailBaseWebhookView): """Base view class for Amazon SES webhooks (SNS Notifications)""" 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) 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 ) 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"): try: 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 return request._sns_message def validate_request(self, request): # Block random posts that don't even have matching SNS headers sns_message = self._parse_sns_message(request) header_type = request.META.get("HTTP_X_AMZ_SNS_MESSAGE_TYPE", "<>") 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) ) 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) ) # Future: Verify SNS message signature # https://docs.aws.amazon.com/sns/latest/dg/SendMessageToHttp.verify.signature.html 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 response = HttpResponse(status=401) response["WWW-Authenticate"] = 'Basic realm="Anymail WEBHOOK_SECRET"' return response return super().post(request, *args, **kwargs) def parse_events(self, request): # request *has* been validated by now events = [] sns_message = self._parse_sns_message(request) sns_type = sns_message.get("Type") if sns_type == "Notification": message_string = sns_message.get("Message") try: ses_event = json.loads(message_string) except (TypeError, ValueError) as err: 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 else: events = self.esp_to_anymail_events(ses_event, sns_message) elif sns_type == "SubscriptionConfirmation": self.auto_confirm_sns_subscription(sns_message) # else: just ignore other SNS messages (e.g., "UnsubscribeConfirmation") return events def esp_to_anymail_events(self, ses_event, sns_message): raise NotImplementedError() def get_boto_client(self, service_name: str, **kwargs): """ Return a boto3 client for service_name, using session_params and client_params from settings. Any kwargs are treated as additional client_params (overriding settings values). """ if kwargs: client_params = self.client_params.copy() client_params.update(kwargs) else: client_params = self.client_params return boto3.session.Session(**self.session_params).client( service_name, **client_params ) def auto_confirm_sns_subscription(self, sns_message): """ 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 not self.auto_confirm_enabled: return if not self.basic_auth: # 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"), ) ) # 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) try: ( _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) ) sns_client = self.get_boto_client("sns", region_name=region) try: sns_client.confirm_subscription( TopicArn=topic_arn, Token=token, AuthenticateOnUnsubscribe="true" ) finally: sns_client.close() class AmazonSESTrackingWebhookView(AmazonSESBaseWebhookView): """Handler for Amazon SES tracking notifications""" signal = tracking 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 # This code should handle either. 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") ) event_id = sns_message.get("MessageId") # unique to the SNS notification try: timestamp = parse_datetime(sns_message["Timestamp"]) except (KeyError, ValueError): timestamp = None mail_object = ses_event.get("mail", {}) # 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 metadata = {} tags = [] for header in mail_object.get("headers", []): name = header["name"].lower() if name == "x-tag": tags.append(header["value"]) elif name == "x-metadata": try: metadata = json.loads(header["value"]) except (ValueError, TypeError, KeyError): pass # AnymailTrackingEvent props for all recipients: common_props = dict( esp_event=ses_event, event_id=event_id, message_id=message_id, metadata=metadata, tags=tags, timestamp=timestamp, ) # generate individual events for each of these: per_recipient_props = [ dict(recipient=email_address) for email_address in all_recipients ] # 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( event_type=EventType.BOUNCED, 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"] ] elif ses_event_type == "Complaint": common_props.update( event_type=EventType.COMPLAINED, description=event_object.get("complaintFeedbackType"), reject_reason=RejectReason.SPAM, user_agent=event_object.get("userAgent"), ) 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"] ] elif ses_event_type == "Send": common_props.update( event_type=EventType.SENT, ) elif ses_event_type == "Reject": common_props.update( event_type=EventType.REJECTED, description=event_object["reason"], reject_reason=RejectReason.BLOCKED, ) elif ses_event_type == "Open": # SES doesn't report which recipient opened the message (it doesn't # track them separately), so just report it for all_recipients common_props.update( event_type=EventType.OPENED, user_agent=event_object.get("userAgent"), ) elif ses_event_type == "Click": # SES doesn't report which recipient clicked the message (it doesn't # track them separately), so just report it for all_recipients common_props.update( event_type=EventType.CLICKED, user_agent=event_object.get("userAgent"), click_url=event_object.get("link"), ) elif ses_event_type == "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"], ) else: # Umm... new event type? common_props.update( event_type=EventType.UNKNOWN, description="Unknown SES eventType '%s'" % ses_event_type, ) return [ AnymailTrackingEvent(**common_props, **recipient_props) for recipient_props in per_recipient_props ] class AmazonSESInboundWebhookView(AmazonSESBaseWebhookView): """Handler for Amazon SES inbound notifications""" signal = inbound def esp_to_anymail_events(self, ses_event, sns_message): ses_event_type = ses_event.get("notificationType") if ses_event_type != "Received": # 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") ) receipt_object = ses_event.get("receipt", {}) action_object = receipt_object.get("action", {}) mail_object = ses_event.get("mail", {}) action_type = action_object.get("type") if action_type == "SNS": content = ses_event.get("content") if action_object.get("encoding") == "BASE64": content = b64decode(content.encode("ascii")) message = AnymailInboundMessage.parse_raw_mime_bytes(content) else: message = AnymailInboundMessage.parse_raw_mime(content) elif action_type == "S3": # Download message from s3 and parse. (SNS has 15s limit # for an http response; hope download doesn't take that long) fp = self.download_s3_object( bucket_name=action_object["bucketName"], object_key=action_object["objectKey"], ) try: message = AnymailInboundMessage.parse_raw_mime_file(fp) finally: fp.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") ) ) # "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() # spam_detected = False if no spam, True if spam, or None if unsure: message.spam_detected = {"PASS": False, "FAIL": True}.get(spam_status) # "unique ID assigned to the email by Amazon SES": event_id = mail_object.get("messageId") try: # "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, ) ] def download_s3_object(self, bucket_name: str, object_key: str) -> typing.IO: """ Download bucket_name/object_key from S3. Must return a file-like object (bytes or text) opened for reading. Caller is responsible for closing it. """ s3_client = self.get_boto_client("s3") bytesio = io.BytesIO() try: s3_client.download_fileobj(bucket_name, object_key, bytesio) except ClientError as err: bytesio.close() # improve the botocore error message raise AnymailBotoClientAPIError( "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 else: bytesio.seek(0) return bytesio finally: s3_client.close() 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, ) # emulate AnymailError init: self.args = args django-anymail-13.0/anymail/webhooks/base.py000066400000000000000000000144741477357323300211160ustar00rootroot00000000000000import warnings from django.http import HttpResponse from django.utils.crypto import constant_time_compare from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from django.views.generic import View from ..exceptions import AnymailInsecureWebhookWarning, AnymailWebhookValidationFailure from ..utils import collect_all_methods, get_anymail_setting, get_request_basic_auth # Mixin note: Django's View.__init__ doesn't cooperate with chaining, # so all mixins that need __init__ must appear before View in MRO. class AnymailCoreWebhookView(View): """Common view for processing ESP event webhooks ESP-specific implementations will need to implement parse_events. ESP-specific implementations should generally subclass AnymailBaseWebhookView instead, to pick up basic auth. They may also want to implement validate_request if additional security is available. """ def __init__(self, **kwargs): super().__init__(**kwargs) self.validators = collect_all_methods(self.__class__, "validate_request") # Subclass implementation: # Where to send events: either ..signals.inbound or ..signals.tracking signal = None def validate_request(self, request): """Check validity of webhook post, or raise AnymailWebhookValidationFailure. AnymailBaseWebhookView includes basic auth validation. Subclasses can implement (or provide via mixins) if the ESP supports additional validation (such as signature checking). *All* definitions of this method in the class chain (including mixins) will be called. There is no need to chain to the superclass. (See self.run_validators and collect_all_methods.) Security note: use django.utils.crypto.constant_time_compare for string comparisons, to avoid exposing your validation to a timing attack. """ # if not constant_time_compare(request.POST['signature'], expected_signature): # raise AnymailWebhookValidationFailure("...message...") # (else just do nothing) pass def parse_events(self, request): """Return a list of normalized AnymailWebhookEvent extracted from ESP post data. Subclasses must implement. """ raise NotImplementedError() # HTTP handlers (subclasses shouldn't need to override): http_method_names = ["post", "head", "options"] @method_decorator(csrf_exempt) def dispatch(self, request, *args, **kwargs): return super().dispatch(request, *args, **kwargs) def head(self, request, *args, **kwargs): # Some ESPs verify the webhook with a HEAD request at configuration time return HttpResponse() def post(self, request, *args, **kwargs): # Normal Django exception handling will do the right thing: # - AnymailWebhookValidationFailure will turn into an HTTP 400 response # (via Django SuspiciousOperation handling) # - Any other errors (e.g., in signal dispatch) will turn into HTTP 500 # responses (via normal Django error handling). ESPs generally # treat that as "try again later". self.run_validators(request) events = self.parse_events(request) esp_name = self.esp_name for event in events: self.signal.send(sender=self.__class__, event=event, esp_name=esp_name) return HttpResponse() # Request validation (subclasses shouldn't need to override): def run_validators(self, request): for validator in self.validators: validator(self, request) @property def esp_name(self): """ Read-only name of the ESP for this webhook view. Subclasses must override with class attr. E.g.: 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__) ) class AnymailBasicAuthMixin(AnymailCoreWebhookView): """Implements webhook basic auth as mixin to AnymailCoreWebhookView.""" # Whether to warn if basic auth is not configured. # For most ESPs, basic auth is the only webhook security, # so the default is True. Subclasses can set False if # they enforce other security (like signed webhooks). warn_if_no_basic_auth = True # List of allowable HTTP basic-auth 'user:pass' strings. # (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=[], # no esp_name -- auth is shared between ESPs kwargs=kwargs, ) # Allow a single string: if isinstance(self.basic_auth, str): self.basic_auth = [self.basic_auth] if self.warn_if_no_basic_auth and len(self.basic_auth) < 1: warnings.warn( "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, ) 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 ) if not auth_ok: raise AnymailWebhookValidationFailure( "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 django-anymail-13.0/anymail/webhooks/brevo.py000066400000000000000000000216261477357323300213160ustar00rootroot00000000000000import json from datetime import datetime, timezone from email.utils import unquote from urllib.parse import quote, urljoin import requests from ..exceptions import AnymailConfigurationError from ..inbound import AnymailInboundMessage from ..signals import ( AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason, inbound, tracking, ) from ..utils import get_anymail_setting from .base import AnymailBaseWebhookView class BrevoBaseWebhookView(AnymailBaseWebhookView): esp_name = "Brevo" class BrevoTrackingWebhookView(BrevoBaseWebhookView): """Handler for Brevo delivery and engagement tracking webhooks""" # https://developers.brevo.com/docs/transactional-webhooks signal = tracking def parse_events(self, request): esp_event = json.loads(request.body.decode("utf-8")) if "items" in esp_event: # This is an inbound webhook post raise AnymailConfigurationError( f"You seem to have set Brevo's *inbound* webhook URL " f"to Anymail's {self.esp_name} *tracking* webhook URL." ) return [self.esp_to_anymail_event(esp_event)] # Map Brevo event type -> Anymail normalized (event type, reject reason). event_types = { # Treat "request" as QUEUED rather than SENT, because it may be received # even if message won't actually 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), "blocked": (EventType.REJECTED, RejectReason.BLOCKED), "spam": (EventType.COMPLAINED, RejectReason.SPAM), "complaint": (EventType.COMPLAINED, RejectReason.SPAM), "invalid_email": (EventType.BOUNCED, RejectReason.INVALID), "deferred": (EventType.DEFERRED, None), # Brevo has four types of opened events: # - "unique_opened": first time opened # - "opened": subsequent opens # - "unique_proxy_opened": first time opened via proxy (e.g., Apple Mail) # - "proxy_open": subsequent opens via proxy # Treat all of these as OPENED. "unique_opened": (EventType.OPENED, None), "opened": (EventType.OPENED, None), "unique_proxy_open": (EventType.OPENED, None), "proxy_open": (EventType.OPENED, None), "click": (EventType.CLICKED, None), "unsubscribe": (EventType.UNSUBSCRIBED, None), "error": (EventType.FAILED, None), # ("list_addition" shouldn't occur for transactional messages.) "list_addition": (EventType.SUBSCRIBED, None), } 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) ) recipient = esp_event.get("email") try: # Brevo 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"] 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"]] except KeyError: pass try: metadata = json.loads(esp_event["X-Mailin-custom"]) except (KeyError, TypeError): metadata = {} return AnymailTrackingEvent( description=None, esp_event=esp_event, # Brevo doesn't provide a unique event id: event_id=None, event_type=event_type, message_id=esp_event.get("message-id"), metadata=metadata, mta_response=esp_event.get("reason"), recipient=recipient, reject_reason=reject_reason, tags=tags, timestamp=timestamp, user_agent=None, click_url=esp_event.get("link"), ) class BrevoInboundWebhookView(BrevoBaseWebhookView): """Handler for Brevo inbound email webhooks""" # https://developers.brevo.com/docs/inbound-parse-webhooks#parsed-email-payload signal = inbound def __init__(self, **kwargs): super().__init__(**kwargs) # API is required to fetch inbound attachment content: self.api_key = get_anymail_setting( "api_key", esp_name=self.esp_name, kwargs=kwargs, allow_bare=True, ) self.api_url = get_anymail_setting( "api_url", esp_name=self.esp_name, kwargs=kwargs, default="https://api.brevo.com/v3/", ) if not self.api_url.endswith("/"): self.api_url += "/" def parse_events(self, request): payload = json.loads(request.body.decode("utf-8")) try: esp_events = payload["items"] except KeyError: # This is not an inbound webhook post raise AnymailConfigurationError( f"You seem to have set Brevo's *tracking* webhook URL " f"to Anymail's {self.esp_name} *inbound* webhook URL." ) else: return [self.esp_to_anymail_event(esp_event) for esp_event in esp_events] def esp_to_anymail_event(self, esp_event): # Inbound event's "Uuid" is documented as # "A list of recipients UUID (can be used with the Public API)". # In practice, it seems to be a single-item list (even when sending # to multiple inbound recipients at once) that uniquely identifies this # inbound event. (And works as a param for the /inbound/events/{uuid} API # that will "Fetch all events history for one particular received email.") try: event_id = esp_event["Uuid"][0] except (KeyError, IndexError): event_id = None attachments = [ self._fetch_attachment(attachment) for attachment in esp_event.get("Attachments", []) ] headers = [ (name, value) for name, values in esp_event.get("Headers", {}).items() # values is string if single header instance, list of string if multiple for value in ([values] if isinstance(values, str) else values) ] # (esp_event From, To, Cc, ReplyTo, Subject, Date, etc. are also in Headers) message = AnymailInboundMessage.construct( headers=headers, text=esp_event.get("RawTextBody", ""), html=esp_event.get("RawHtmlBody", ""), attachments=attachments, ) if message["Return-Path"]: message.envelope_sender = unquote(message["Return-Path"]) if message["Delivered-To"]: message.envelope_recipient = unquote(message["Delivered-To"]) message.stripped_text = esp_event.get("ExtractedMarkdownMessage") # Documented as "Spam.Score" object, but both example payload # and actual received payload use single "SpamScore" field: message.spam_score = esp_event.get("SpamScore") return AnymailInboundEvent( event_type=EventType.INBOUND, timestamp=None, # Brevo doesn't provide inbound event timestamp event_id=event_id, esp_event=esp_event, message=message, ) def _fetch_attachment(self, attachment): # Download attachment content from Brevo API. # FUTURE: somehow defer download until attachment is accessed? token = attachment["DownloadToken"] url = urljoin(self.api_url, f"inbound/attachments/{quote(token, safe='')}") response = requests.get(url, headers={"api-key": self.api_key}) response.raise_for_status() # or maybe just log and continue? content = response.content # Prefer response Content-Type header to attachment ContentType field, # as the header will include charset but the ContentType field won't. content_type = response.headers.get("Content-Type") or attachment["ContentType"] return AnymailInboundMessage.construct_attachment( content_type=content_type, content=content, filename=attachment.get("Name"), content_id=attachment.get("ContentID"), ) django-anymail-13.0/anymail/webhooks/mailersend.py000066400000000000000000000166331477357323300223260ustar00rootroot00000000000000import hashlib import hmac import json from django.utils.crypto import constant_time_compare from django.utils.dateparse import parse_datetime from ..exceptions import AnymailConfigurationError, AnymailWebhookValidationFailure from ..inbound import AnymailInboundMessage from ..signals import ( AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason, inbound, tracking, ) from ..utils import get_anymail_setting from .base import AnymailBaseWebhookView class MailerSendBaseWebhookView(AnymailBaseWebhookView): """Base view class for MailerSend webhooks""" esp_name = "MailerSend" warn_if_no_basic_auth = False # because we validate against signature def __init__(self, _secret_name, **kwargs): signing_secret = get_anymail_setting( _secret_name, esp_name=self.esp_name, kwargs=kwargs, ) # hmac.new requires bytes key: self.signing_secret = signing_secret.encode("ascii") self._secret_setting_name = f"{self.esp_name}_{_secret_name}".upper() super().__init__(**kwargs) def validate_request(self, request): super().validate_request(request) # first check basic auth if enabled try: signature = request.headers["Signature"] except KeyError: raise AnymailWebhookValidationFailure( "MailerSend webhook called without signature" ) from None expected_signature = hmac.new( key=self.signing_secret, msg=request.body, digestmod=hashlib.sha256, ).hexdigest() if not constant_time_compare(signature, expected_signature): raise AnymailWebhookValidationFailure( f"MailerSend webhook called with incorrect signature" f" (check Anymail {self._secret_setting_name} setting)" ) class MailerSendTrackingWebhookView(MailerSendBaseWebhookView): """Handler for MailerSend delivery and engagement tracking webhooks""" signal = tracking # (Declaring class attr allows override by kwargs in View.as_view.) signing_secret = None def __init__(self, **kwargs): super().__init__(_secret_name="signing_secret", **kwargs) def parse_events(self, request): esp_event = json.loads(request.body.decode("utf-8")) event_type = esp_event.get("type") if event_type == "inbound.message": raise AnymailConfigurationError( "You seem to have set MailerSend's *inbound* route endpoint" " to Anymail's MailerSend *activity tracking* webhook URL. " ) return [self.esp_to_anymail_event(esp_event)] event_types = { # Map MailerSend activity.type: Anymail normalized type "sent": EventType.SENT, "delivered": EventType.DELIVERED, "soft_bounced": EventType.BOUNCED, "hard_bounced": EventType.BOUNCED, "opened": EventType.OPENED, "clicked": EventType.CLICKED, "unsubscribed": EventType.UNSUBSCRIBED, "spam_complaint": EventType.COMPLAINED, } morph_reject_reasons = { # Map MailerSend morph.object (type): Anymail normalized RejectReason "recipient_bounce": RejectReason.BOUNCED, "spam_complaint": RejectReason.SPAM, "recipient_unsubscribe": RejectReason.UNSUBSCRIBED, # any others? } def esp_to_anymail_event(self, esp_event): activity_data = esp_event.get("data", {}) email_data = activity_data.get("email", {}) message_data = email_data.get("message", {}) recipient_data = email_data.get("recipient", {}) event_type = self.event_types.get(activity_data["type"], EventType.UNKNOWN) event_id = activity_data.get("id") recipient = recipient_data.get("email") message_id = message_data.get("id") tags = email_data.get("tags", []) try: timestamp = parse_datetime(activity_data["created_at"]) except KeyError: timestamp = None # Additional, event-specific info is included in a "morph" record. try: morph_data = activity_data["morph"] morph_object = morph_data["object"] # the object type of morph_data except (KeyError, TypeError): reject_reason = None description = None click_url = None else: # It seems like email_data["status"] should map to a reject_reason, but in # reality status is most often just (the undocumented) "rejected" and the # morph_object has more accurate info. reject_reason = self.morph_reject_reasons.get(morph_object) description = morph_data.get("readable_reason") or morph_data.get("reason") click_url = morph_data.get("url") # object="click" # user_ip = morph_data.get("ip") # object="click" or "open" return AnymailTrackingEvent( event_type=event_type, timestamp=timestamp, message_id=message_id, event_id=event_id, recipient=recipient, reject_reason=reject_reason, description=description, tags=tags, click_url=click_url, esp_event=esp_event, ) class MailerSendInboundWebhookView(MailerSendBaseWebhookView): """Handler for MailerSend inbound webhook""" signal = inbound # (Declaring class attr allows override by kwargs in View.as_view.) inbound_secret = None def __init__(self, **kwargs): super().__init__(_secret_name="inbound_secret", **kwargs) def parse_events(self, request): esp_event = json.loads(request.body.decode("utf-8")) event_type = esp_event.get("type") if event_type != "inbound.message": raise AnymailConfigurationError( f"You seem to have set MailerSend's *{event_type}* webhook " "to Anymail's MailerSend *inbound* webhook URL. " ) return [self.esp_to_anymail_event(esp_event)] def esp_to_anymail_event(self, esp_event): message_data = esp_event.get("data") event_id = message_data.get("id") try: timestamp = parse_datetime(message_data["created_at"]) except (KeyError, TypeError): timestamp = None message = AnymailInboundMessage.parse_raw_mime(message_data.get("raw")) try: message.envelope_sender = message_data["sender"]["email"] # (also available as X-Envelope-From header) except KeyError: pass try: # There can be multiple rcptTo if the same message is sent # to multiple inbound recipients. Just use the first. envelope_recipients = [ recipient["email"] for recipient in message_data["recipients"]["rcptTo"] ] message.envelope_recipient = envelope_recipients[0] except (KeyError, IndexError): pass # MailerSend doesn't seem to provide any spam annotations. # SPF seems to be verified, but format is undocumented: # "spf_check": {"code": "+", "value": None} # DKIM doesn't appear to be verified yet: # "dkim_check": False, return AnymailInboundEvent( event_type=EventType.INBOUND, timestamp=timestamp, event_id=event_id, esp_event=esp_event, message=message, ) django-anymail-13.0/anymail/webhooks/mailgun.py000066400000000000000000000521311477357323300216300ustar00rootroot00000000000000import hashlib import hmac import json from datetime import datetime, timezone from django.utils.crypto import constant_time_compare from ..exceptions import ( AnymailConfigurationError, AnymailInvalidAddress, AnymailWebhookValidationFailure, ) from ..inbound import AnymailInboundMessage from ..signals import ( AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason, inbound, tracking, ) from ..utils import ( UNSET, get_anymail_setting, merge_dicts_shallow, parse_single_address, querydict_getfirst, ) from .base import AnymailBaseWebhookView class MailgunBaseWebhookView(AnymailBaseWebhookView): """Base view class for Mailgun webhooks""" esp_name = "Mailgun" warn_if_no_basic_auth = False # because we validate against signature # (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. 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, ) # hmac.new requires bytes key: self.webhook_signing_key = webhook_signing_key.encode("ascii") super().__init__(**kwargs) def validate_request(self, request): super().validate_request(request) # first check basic auth if enabled 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"] except (KeyError, ValueError, UnicodeDecodeError) as err: raise AnymailWebhookValidationFailure( "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"] except KeyError as err: raise AnymailWebhookValidationFailure( "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() if not constant_time_compare(signature, expected_signature): raise AnymailWebhookValidationFailure( "Mailgun webhook called with incorrect signature" ) class MailgunTrackingWebhookView(MailgunBaseWebhookView): """Handler for Mailgun delivery and engagement tracking webhooks""" signal = tracking def parse_events(self, request): if request.content_type == "application/json": 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, } reject_reasons = { # 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" 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 } def esp_to_anymail_event(self, esp_event): event_data = esp_event.get("event-data", {}) event_type = self.event_types.get(event_data["event"], EventType.UNKNOWN) 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") recipient = event_data.get("recipient") try: timestamp = datetime.fromtimestamp( float(event_data["timestamp"]), tz=timezone.utc ) except KeyError: timestamp = None try: message_id = event_data["message"]["headers"]["message-id"] except KeyError: message_id = None if message_id and not message_id.startswith("<"): message_id = "<{}>".format(message_id) metadata = event_data.get("user-variables", {}) tags = event_data.get("tags", []) try: delivery_status = event_data["delivery-status"] # if delivery_status is None, an AttributeError will be raised description = delivery_status.get("description") mta_response = delivery_status.get("message") except (KeyError, AttributeError): description = None mta_response = None if "reason" in event_data: reject_reason = self.reject_reasons.get( event_data["reason"], RejectReason.OTHER ) else: reject_reason = None if event_type == EventType.REJECTED: # This event has a somewhat different structure than the others... description = description or event_data.get("reject", {}).get("reason") reject_reason = reject_reason or RejectReason.OTHER if not recipient: try: to_email = parse_single_address( event_data["message"]["headers"]["to"] ) except (AnymailInvalidAddress, KeyError): pass else: recipient = to_email.addr_spec return AnymailTrackingEvent( event_type=event_type, timestamp=timestamp, message_id=message_id, event_id=event_id, recipient=recipient, reject_reason=reject_reason, description=description, mta_response=mta_response, tags=tags, metadata=metadata, click_url=event_data.get("url"), user_agent=event_data.get("client-info", {}).get("user-agent"), esp_event=esp_event, ) # Legacy event handling # (Prior to 2018-06-29, these were the only Mailgun events.) 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, # Mailgun does not send events corresponding to QUEUED or DEFERRED } legacy_reject_reasons = { # 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 # (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 607: RejectReason.SPAM, # previous spam complaint } def mailgun_legacy_to_anymail_event(self, esp_event): # esp_event is a Django QueryDict (from request.POST), # which has multi-valued fields, but is *not* case-insensitive. # Because of the way Mailgun merges user-variables into the event, # we must generally use the *first* value of any multi-valued field # 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: # 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." ) 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("<"): message_id = "<{}>".format(message_id) 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")) except (KeyError, TypeError): pass except ValueError: # 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] except (TypeError, IndexError): # illegal SMTP status code format pass else: 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, ) 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", []) 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), 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), 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") 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 len(variables) >= 1: # Each X-Mailgun-Variables value is JSON. Parse and merge them all into # single dict: metadata = merge_dicts_shallow( *[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. 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.) 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. 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. 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"}: # values = [user-variable, event-param] metadata[field] = values[0] else: # 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", # Undocumented, but observed in actual events: "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, } class MailgunInboundWebhookView(MailgunBaseWebhookView): """Handler for Mailgun inbound (route forward-to-url) webhook""" signal = inbound 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", "") 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 ) 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). esp_event = request 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"] ) 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." ) if "body-mime" in request.POST: # Raw-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.spam_detected = message.get("X-Mailgun-Sflag", "No").lower() == "yes" try: 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), esp_event=esp_event, message=message, ) def message_from_mailgun_parsed(self, request): """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"]) 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. 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 }, ) attachments = [] 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). # (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 ) ) attachments.append(attachment) return AnymailInboundMessage.construct( # 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, ) django-anymail-13.0/anymail/webhooks/mailjet.py000066400000000000000000000201741477357323300216230ustar00rootroot00000000000000import json from datetime import datetime, timezone from ..inbound import AnymailInboundMessage from ..signals import ( AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason, inbound, tracking, ) from .base import AnymailBaseWebhookView class MailjetTrackingWebhookView(AnymailBaseWebhookView): """Handler for Mailjet delivery and engagement tracking webhooks""" esp_name = "Mailjet" signal = tracking def parse_events(self, request): 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: esp_events[0] # is this really an array of events? except IndexError: pass # yep (and it's empty?!) except KeyError: esp_events = [esp_events] # nope, it's a single, bare event return [self.esp_to_anymail_event(esp_event) for esp_event in esp_events] # 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, } 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, # 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, # error_related_to: mailjet "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 = EventType.DEFERRED try: 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"]) except (KeyError, TypeError): message_id = None 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) tags = [tag] if tag else [] try: metadata = json.loads(esp_event["Payload"]) except (KeyError, ValueError): metadata = {} return AnymailTrackingEvent( event_type=event_type, timestamp=timestamp, message_id=message_id, event_id=None, recipient=esp_event.get("email", None), reject_reason=reject_reason, 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), esp_event=esp_event, ) class MailjetInboundWebhookView(AnymailBaseWebhookView): """Handler for Mailjet inbound (parse API) webhook""" esp_name = "Mailjet" signal = inbound def parse_events(self, request): 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. # 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 ContentRef is Attachment or InlineAttachment: if "Attachment" in part.get("ContentRef", "") ] message = AnymailInboundMessage.construct( headers=headers, text=esp_event.get("Text-part", None), html=esp_event.get("Html-part", None), attachments=attachments, ) message.envelope_sender = esp_event.get("Sender", None) message.envelope_recipient = esp_event.get("Recipient", None) # 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"]) except (KeyError, TypeError, ValueError): pass return AnymailInboundEvent( event_type=EventType.INBOUND, # 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 {'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 for value in values: result.append((name, value)) else: # 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.) # 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) # 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, filename=part_headers.get_filename(None), content_id=part_headers.get("Content-ID", "") or None, ) django-anymail-13.0/anymail/webhooks/mandrill.py000066400000000000000000000167561477357323300220130ustar00rootroot00000000000000import hashlib import hmac import json from base64 import b64encode from datetime import datetime, timezone from django.utils.crypto import constant_time_compare from ..exceptions import AnymailWebhookValidationFailure from ..inbound import AnymailInboundMessage 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): """Validates Mandrill webhook signature""" # These can be set from kwargs in View.as_view, or pulled from settings in init: webhook_key = None # required webhook_url = None # optional; defaults to actual url used 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, ) if webhook_key is not None: # 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) try: signature = request.META["HTTP_X_MANDRILL_SIGNATURE"] except KeyError: 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: 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() ) if not constant_time_compare(signature, expected_signature): raise AnymailWebhookValidationFailure( "Mandrill webhook called with incorrect signature (for url %r)" % url ) class MandrillCombinedWebhookView(MandrillSignatureMixin, AnymailBaseWebhookView): """Unified view class for Mandrill tracking and inbound webhooks""" esp_name = "Mandrill" warn_if_no_basic_auth = False # because we validate against signature signal = None # set in esp_to_anymail_event def parse_events(self, request): 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") 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 # batch must not mix event types self.signal = tracking return self.mandrill_tracking_to_anymail_event(esp_event) # # Tracking events # 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, # Sync events (we don't really normalize these well): "whitelist": EventType.UNKNOWN, "blacklist": EventType.UNKNOWN, # Inbound events: "inbound": EventType.INBOUND, } def mandrill_tracking_to_anymail_event(self, esp_event): 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) except (KeyError, ValueError): timestamp = None try: recipient = esp_event["msg"]["email"] except KeyError: try: recipient = esp_event["reject"]["email"] # sync events except KeyError: recipient = None try: mta_response = esp_event["msg"]["diag"] except KeyError: mta_response = None try: description = getfirst(esp_event["reject"], ["detail", "reason"]) except KeyError: description = None try: metadata = esp_event["msg"]["metadata"] except KeyError: metadata = {} try: tags = esp_event["msg"]["tags"] except KeyError: tags = [] return AnymailTrackingEvent( click_url=esp_event.get("url", None), description=description, esp_event=esp_event, event_type=event_type, message_id=esp_event.get("_id", None), metadata=metadata, mta_response=mta_response, recipient=recipient, # 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), ) # # Inbound events # 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"]) # (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) except (KeyError, ValueError): timestamp = None return AnymailInboundEvent( event_type=EventType.INBOUND, timestamp=timestamp, # 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: MandrillTrackingWebhookView = MandrillCombinedWebhookView django-anymail-13.0/anymail/webhooks/postal.py000066400000000000000000000146761477357323300215120ustar00rootroot00000000000000import binascii import json from base64 import b64decode from datetime import datetime, timezone from ..exceptions import ( AnymailConfigurationError, AnymailImproperlyInstalled, AnymailInvalidAddress, AnymailWebhookValidationFailure, _LazyError, ) from ..inbound import AnymailInboundMessage from ..signals import ( AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason, inbound, tracking, ) from ..utils import get_anymail_setting, parse_single_address from .base import AnymailBaseWebhookView try: 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", install_extra="postal" ) ) serialization = error hashes = error default_backend = error padding = error InvalidSignature = object class PostalBaseWebhookView(AnymailBaseWebhookView): """Base view class for Postal webhooks""" esp_name = "Postal" warn_if_no_basic_auth = False # These can be set from kwargs in View.as_view, or pulled from settings in init: 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 ) super().__init__(**kwargs) def validate_request(self, request): try: signature = request.META["HTTP_X_POSTAL_SIGNATURE"] except KeyError: 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(), ) try: public_key.verify( b64decode(signature), request.body, padding.PKCS1v15(), hashes.SHA1() ) except (InvalidSignature, binascii.Error): raise AnymailWebhookValidationFailure( "Postal webhook called with incorrect signature" ) class PostalTrackingWebhookView(PostalBaseWebhookView): """Handler for Postal message, engagement, and generation event webhooks""" signal = tracking def parse_events(self, request): esp_event = json.loads(request.body.decode("utf-8")) if "rcpt_to" in esp_event: raise AnymailConfigurationError( "You seem to have set Postal's *inbound* webhook " "to Anymail's Postal *tracking* webhook URL." ) raw_timestamp = esp_event.get("timestamp") timestamp = ( datetime.fromtimestamp(int(raw_timestamp), tz=timezone.utc) if raw_timestamp else None ) payload = esp_event.get("payload", {}) status_types = { "Sent": EventType.DELIVERED, "SoftFail": EventType.DEFERRED, "HardFail": EventType.FAILED, "Held": EventType.QUEUED, } if "status" in payload: event_type = status_types.get(payload["status"], EventType.UNKNOWN) elif "bounce" in payload: event_type = EventType.BOUNCED elif "url" in payload: event_type = EventType.CLICKED else: event_type = EventType.UNKNOWN description = payload.get("details") mta_response = payload.get("output") # extract message-related fields message = payload.get("message") or payload.get("original_message", {}) message_id = message.get("id") tag = message.get("tag") recipient = None message_to = message.get("to") if message_to is not None: try: recipient = parse_single_address(message_to).addr_spec except AnymailInvalidAddress: pass 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. return [] # only for MessageLinkClicked click_url = payload.get("url") user_agent = payload.get("user_agent") event = AnymailTrackingEvent( event_type=event_type, timestamp=timestamp, event_id=esp_event.get("uuid"), esp_event=esp_event, click_url=click_url, description=description, message_id=message_id, metadata=None, mta_response=mta_response, recipient=recipient, reject_reason=( RejectReason.BOUNCED if event_type == EventType.BOUNCED else None ), tags=[tag], user_agent=user_agent, ) return [event] class PostalInboundWebhookView(PostalBaseWebhookView): """Handler for Postal inbound relay webhook""" signal = inbound def parse_events(self, request): esp_event = json.loads(request.body.decode("utf-8")) if "status" in esp_event: raise AnymailConfigurationError( "You seem to have set Postal's *tracking* webhook " "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) event = AnymailInboundEvent( event_type=EventType.INBOUND, timestamp=None, event_id=esp_event.get("id"), esp_event=esp_event, message=message, ) return [event] django-anymail-13.0/anymail/webhooks/postmark.py000066400000000000000000000243261477357323300220410ustar00rootroot00000000000000import json from email.utils import unquote from django.utils.dateparse import parse_datetime from ..exceptions import AnymailConfigurationError from ..inbound import AnymailInboundMessage from ..signals import ( AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason, inbound, tracking, ) from ..utils import EmailAddress, getfirst from .base import AnymailBaseWebhookView class PostmarkBaseWebhookView(AnymailBaseWebhookView): """Base view class for Postmark webhooks""" esp_name = "Postmark" def parse_events(self, request): 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): raise NotImplementedError() class PostmarkTrackingWebhookView(PostmarkBaseWebhookView): """Handler for Postmark delivery and engagement tracking webhooks""" signal = tracking 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 } 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), # 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): reject_reason = None try: esp_record_type = esp_event["RecordType"] except KeyError: if "FromFull" in esp_event: # This is an inbound event event_type = EventType.INBOUND else: event_type = EventType.UNKNOWN else: event_type = self.event_record_types.get(esp_record_type, EventType.UNKNOWN) if event_type == EventType.INBOUND: raise AnymailConfigurationError( "You seem to have set Postmark's *inbound* webhook " "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"]] except KeyError: pass if event_type == EventType.UNSUBSCRIBED: 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"] ] except KeyError: pass else: event_type, reject_reason = self.event_types["Subscribe"] # Email for bounce; Recipient for open: recipient = getfirst(esp_event, ["Email", "Recipient"], None) try: 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 except KeyError: event_id = None metadata = esp_event.get("Metadata", {}) try: tags = [esp_event["Tag"]] except KeyError: tags = [] return AnymailTrackingEvent( 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), metadata=metadata, 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), ) class PostmarkInboundWebhookView(PostmarkBaseWebhookView): """Handler for Postmark inbound webhook""" signal = inbound def esp_to_anymail_event(self, esp_event): # Check correct webhook (inbound events don't have RecordType): esp_record_type = esp_event.get("RecordType", "Inbound") if esp_record_type != "Inbound": raise AnymailConfigurationError( f"You seem to have set Postmark's *{esp_record_type}* webhook" f" to Anymail's Postmark *inbound* webhook URL." ) headers = esp_event.get("Headers", []) # Postmark inbound prepends "Return-Path" to Headers list # (but it doesn't appear in original message or RawEmail). # (A Return-Path anywhere else in the headers or RawEmail # can't be considered legitimate.) envelope_sender = None if len(headers) > 0 and headers[0]["Name"].lower() == "return-path": envelope_sender = unquote(headers[0]["Value"]) # remove <> headers = headers[1:] # don't include in message construction if "RawEmail" in esp_event: message = AnymailInboundMessage.parse_raw_mime(esp_event["RawEmail"]) # Postmark provides Bcc when delivered-to is not in To header, # but doesn't add it to the RawEmail. if esp_event.get("BccFull") and "Bcc" not in message: message["Bcc"] = self._addresses(esp_event["BccFull"]) else: # RawEmail not included in payload; construct from parsed data. attachments = [ AnymailInboundMessage.construct_attachment( content_type=attachment["ContentType"], # Real payloads have "Content", test payloads have "Data" (?!): content=attachment.get("Content") or attachment["Data"], base64=True, filename=attachment.get("Name"), content_id=attachment.get("ContentID"), ) for attachment in esp_event.get("Attachments", []) ] message = AnymailInboundMessage.construct( from_email=self._address(esp_event.get("FromFull")), to=self._addresses(esp_event.get("ToFull")), cc=self._addresses(esp_event.get("CcFull")), bcc=self._addresses(esp_event.get("BccFull")), subject=esp_event.get("Subject", ""), headers=((header["Name"], header["Value"]) for header in headers), text=esp_event.get("TextBody", ""), html=esp_event.get("HtmlBody", ""), attachments=attachments, ) # Postmark strips these headers and provides them as separate event fields: if esp_event.get("Date") and "Date" not in message: message["Date"] = esp_event["Date"] if esp_event.get("ReplyTo") and "Reply-To" not in message: message["Reply-To"] = esp_event["ReplyTo"] message.envelope_sender = envelope_sender message.envelope_recipient = esp_event.get("OriginalRecipient") message.stripped_text = esp_event.get("StrippedTextReply") message.spam_detected = message.get("X-Spam-Status", "No").lower() == "yes" try: message.spam_score = float(message["X-Spam-Score"]) except (TypeError, ValueError): pass return AnymailInboundEvent( event_type=EventType.INBOUND, # 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, ) @classmethod def _address(cls, full): """ Return a formatted email address from a Postmark inbound {From,To,Cc,Bcc}Full dict """ if full is None: return "" return str( EmailAddress( display_name=full.get("Name", ""), addr_spec=full.get("Email", ""), ) ) @classmethod def _addresses(cls, full_list): """ Return a formatted email address list string from a Postmark inbound {To,Cc,Bcc}Full[] list of dicts """ if full_list is None: return None return ", ".join(cls._address(addr) for addr in full_list) django-anymail-13.0/anymail/webhooks/resend.py000066400000000000000000000167711477357323300214660ustar00rootroot00000000000000import json from datetime import datetime from ..exceptions import ( AnymailImproperlyInstalled, AnymailInvalidAddress, AnymailWebhookValidationFailure, _LazyError, ) from ..signals import AnymailTrackingEvent, EventType, RejectReason, tracking from ..utils import get_anymail_setting, parse_single_address from .base import AnymailBaseWebhookView, AnymailCoreWebhookView try: # Valid webhook signatures with svix library if available from svix.webhooks import Webhook as SvixWebhook, WebhookVerificationError except ImportError: # Otherwise, validating with basic auth is sufficient # (unless settings specify signature validation, which will then raise this error) SvixWebhook = _LazyError( AnymailImproperlyInstalled(missing_package="svix", install_extra="resend") ) WebhookVerificationError = object() class SvixWebhookValidationMixin(AnymailCoreWebhookView): """Mixin to validate Svix webhook signatures""" # Consuming classes can override (e.g., to use different secrets # for inbound and tracking webhooks). _secret_setting_name = "signing_secret" @classmethod def as_view(cls, **initkwargs): if not hasattr(cls, cls._secret_setting_name): # The attribute must exist on the class before View.as_view # will allow overrides via kwarg setattr(cls, cls._secret_setting_name, None) return super().as_view(**initkwargs) def __init__(self, **kwargs): self.signing_secret = get_anymail_setting( self._secret_setting_name, esp_name=self.esp_name, default=None, kwargs=kwargs, ) if self.signing_secret is None: self._svix_webhook = None self.warn_if_no_basic_auth = True else: # This will raise an import error if svix isn't installed self._svix_webhook = SvixWebhook(self.signing_secret) # Basic auth is not required if validating signature self.warn_if_no_basic_auth = False super().__init__(**kwargs) def validate_request(self, request): if self._svix_webhook: # https://docs.svix.com/receiving/verifying-payloads/how try: # Note: if signature is valid, Svix also tries to parse # the json body, so this could raise other errors... self._svix_webhook.verify(request.body, request.headers) except WebhookVerificationError as error: setting_name = f"{self.esp_name}_{self._secret_setting_name}".upper() raise AnymailWebhookValidationFailure( f"{self.esp_name} webhook called with incorrect signature" f" (check Anymail {setting_name} setting)" ) from error class ResendTrackingWebhookView(SvixWebhookValidationMixin, AnymailBaseWebhookView): """Handler for Resend.com status tracking webhooks""" esp_name = "Resend" signal = tracking def parse_events(self, request): esp_event = json.loads(request.body.decode("utf-8")) return [self.esp_to_anymail_event(esp_event, request)] # https://resend.com/docs/dashboard/webhooks/event-types event_types = { # Map Resend type: Anymail normalized type "email.sent": EventType.SENT, "email.delivered": EventType.DELIVERED, "email.delivery_delayed": EventType.DEFERRED, "email.complained": EventType.COMPLAINED, "email.bounced": EventType.BOUNCED, "email.opened": EventType.OPENED, "email.clicked": EventType.CLICKED, } def esp_to_anymail_event(self, esp_event, request): event_type = self.event_types.get(esp_event["type"], EventType.UNKNOWN) # event_id: HTTP header `svix-id` is unique for a particular event # (including across reposts due to errors) try: event_id = request.headers["svix-id"] except KeyError: event_id = None # timestamp: Payload created_at is unique for a particular event. # (Payload data.created_at is when the message was created, not the event. # HTTP header `svix-timestamp` changes for each repost of the same event.) try: timestamp = datetime.fromisoformat( # Must convert "Z" to timezone offset for Python 3.10 and earlier. esp_event["created_at"].replace("Z", "+00:00") ) except (KeyError, ValueError): timestamp = None try: message_id = esp_event["data"]["email_id"] except (KeyError, TypeError): message_id = None # Resend doesn't provide bounce reasons or SMTP responses, # but it's possible to distinguish some cases by examining # the human-readable message text: try: bounce_message = esp_event["data"]["bounce"]["message"] except (KeyError, ValueError): bounce_message = None reject_reason = None else: if "suppressed sending" in bounce_message: # "Resend has suppressed sending to this address ..." reject_reason = RejectReason.BLOCKED elif "bounce message" in bounce_message: # "The recipient's email provider sent a hard bounce message, ..." # "The recipient's email provider sent a general bounce message. ..." # "The recipient's email provider sent a bounce message because # the recipient's inbox was full. ..." reject_reason = RejectReason.BOUNCED else: reject_reason = RejectReason.OTHER # unknown # Recover tags and metadata from custom headers metadata = {} tags = [] try: headers = esp_event["data"]["headers"] except KeyError: pass else: for header in headers: name = header["name"].lower() if name == "x-tags": try: tags = json.loads(header["value"]) except (ValueError, TypeError): pass elif name == "x-metadata": try: metadata = json.loads(header["value"]) except (ValueError, TypeError): pass # For multi-recipient emails (including cc and bcc), Resend generates events # for each recipient, but no indication of which recipient an event applies to. # Just report the first `to` recipient. try: first_to = esp_event["data"]["to"][0] recipient = parse_single_address(first_to).addr_spec except (KeyError, IndexError, TypeError, AnymailInvalidAddress): recipient = None try: click_data = esp_event["data"]["click"] except (KeyError, TypeError): click_url = None user_agent = None else: click_url = click_data.get("link") user_agent = click_data.get("userAgent") return AnymailTrackingEvent( event_type=event_type, timestamp=timestamp, message_id=message_id, event_id=event_id, recipient=recipient, reject_reason=reject_reason, description=bounce_message, mta_response=None, tags=tags, metadata=metadata, click_url=click_url, user_agent=user_agent, esp_event=esp_event, ) django-anymail-13.0/anymail/webhooks/sendgrid.py000066400000000000000000000244141477357323300217760ustar00rootroot00000000000000import json from datetime import datetime, timezone from email.parser import BytesParser from email.policy import default as default_policy from ..inbound import AnymailInboundMessage from ..signals import ( AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason, inbound, tracking, ) from .base import AnymailBaseWebhookView class SendGridTrackingWebhookView(AnymailBaseWebhookView): """Handler for SendGrid delivery and engagement tracking webhooks""" esp_name = "SendGrid" signal = tracking def parse_events(self, request): 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, } reject_reasons = { # Map SendGrid reason/type strings (lowercased) # to Anymail normalized reject_reason "invalid": RejectReason.INVALID, "unsubscribed address": RejectReason.UNSUBSCRIBED, "bounce": RejectReason.BOUNCED, "bounced address": 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) try: timestamp = datetime.fromtimestamp(esp_event["timestamp"], tz=timezone.utc) except (KeyError, ValueError): timestamp = None 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)) reject_reason = None # SendGrid merges metadata ('unique_args') with the event. # We can (sort of) split metadata back out by filtering known # SendGrid event params, though this can miss metadata keys # that duplicate SendGrid params, and can accidentally include # non-metadata keys if SendGrid modifies their event records. metadata_keys = set(esp_event.keys()) - self.sendgrid_event_keys if len(metadata_keys) > 0: metadata = {key: esp_event[key] for key in metadata_keys} else: metadata = {} return AnymailTrackingEvent( event_type=event_type, timestamp=timestamp, # (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", []), metadata=metadata, 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 } class SendGridInboundWebhookView(AnymailBaseWebhookView): """Handler for SendGrid inbound webhook""" esp_name = "SendGrid" signal = inbound def parse_events(self, request): 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). 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: # Default (not "Send Raw") inbound fields message = self.message_from_sendgrid_parsed(esp_event) elif "email" in request.POST: # "Send Raw" full MIME message = AnymailInboundMessage.parse_raw_mime(request.POST["email"]) else: raise KeyError( "Invalid SendGrid inbound event data" " (missing both 'headers' and 'email' fields)" ) try: envelope = json.loads(request.POST["envelope"]) except (KeyError, TypeError, ValueError): pass else: message.envelope_sender = envelope["from"] message.envelope_recipient = envelope["to"][0] # no simple boolean spam; would need to parse the spam_report message.spam_detected = None try: message.spam_score = float(request.POST["spam_score"]) except (KeyError, TypeError, ValueError): pass return AnymailInboundEvent( event_type=EventType.INBOUND, # 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, ) def message_from_sendgrid_parsed(self, request): """Construct a Message from SendGrid's "default" (non-raw) fields""" try: charsets = json.loads(request.POST["charsets"]) except (KeyError, ValueError): charsets = {} try: attachment_info = json.loads(request.POST["attachment-info"]) except (KeyError, ValueError): attachments = None else: # Load attachments from posted files attachments = [] for attachment_id in sorted(attachment_info.keys()): 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). (To avoid this problem, enable SendGrid's "raw, full MIME" # inbound option.) pass else: # (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 ) ) 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 ): # 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() ) for part in parsed_parts: name = part.get_param("name", header="content-disposition") if name == "text": text = part.get_payload(decode=True).decode(text_charset) 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( # POST["headers"] includes From, To, Cc, Subject, etc. raw_headers=request.POST.get("headers", ""), text=text, html=html, attachments=attachments, ) django-anymail-13.0/anymail/webhooks/sendinblue.py000066400000000000000000000022301477357323300223170ustar00rootroot00000000000000import warnings from ..exceptions import AnymailDeprecationWarning from .brevo import BrevoInboundWebhookView, BrevoTrackingWebhookView class SendinBlueTrackingWebhookView(BrevoTrackingWebhookView): """ Deprecated compatibility tracking webhook for old Brevo name "SendinBlue". """ esp_name = "SendinBlue" def __init__(self, **kwargs): warnings.warn( "Anymail's SendinBlue webhook URLs are deprecated." " Update your Brevo transactional email webhook URL to change" " 'anymail/sendinblue' to 'anymail/brevo'.", AnymailDeprecationWarning, ) super().__init__(**kwargs) class SendinBlueInboundWebhookView(BrevoInboundWebhookView): """ Deprecated compatibility inbound webhook for old Brevo name "SendinBlue". """ esp_name = "SendinBlue" def __init__(self, **kwargs): warnings.warn( "Anymail's SendinBlue webhook URLs are deprecated." " Update your Brevo inbound webhook URL to change" " 'anymail/sendinblue' to 'anymail/brevo'.", AnymailDeprecationWarning, ) super().__init__(**kwargs) django-anymail-13.0/anymail/webhooks/sparkpost.py000066400000000000000000000216571477357323300222330ustar00rootroot00000000000000import json from base64 import b64decode from datetime import datetime, timezone from ..exceptions import AnymailConfigurationError from ..inbound import AnymailInboundMessage from ..signals import ( AnymailInboundEvent, AnymailTrackingEvent, EventType, RejectReason, inbound, tracking, ) from ..utils import get_anymail_setting from .base import AnymailBaseWebhookView class SparkPostBaseWebhookView(AnymailBaseWebhookView): """Base view class for SparkPost webhooks""" esp_name = "SparkPost" def parse_events(self, request): 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) for (event_class, event, raw_event) in unwrapped_events if event is not None # filter out empty "ping" events ] def unwrap_event(self, raw_event): """Unwraps SparkPost event structure, and returns event_class, event, raw_event raw_event is of form {'msys': {event_class: {...event...}}} Can return None, None, raw_event for SparkPost "ping" raw_event={'msys': {}} """ event_classes = raw_event["msys"].keys() try: (event_class,) = event_classes 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 return event_class, event, raw_event def esp_to_anymail_event(self, event_class, event, raw_event): raise NotImplementedError() class SparkPostTrackingWebhookView(SparkPostBaseWebhookView): """Handler for SparkPost message, engagement, and generation event webhooks""" signal = tracking 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, } # 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, } 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 } def __init__(self, **kwargs): # Set Anymail setting SPARKPOST_TRACK_INITIAL_OPEN_AS_OPENED True # to report *both* "open" and "initial_open" as Anymail "opened" events. # (Otherwise only "open" maps to "opened", matching the behavior of most # 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, ) 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": # 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." ) event_type = self.event_types.get(event["type"], EventType.UNKNOWN) try: 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 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, event_type = reject_reason except ValueError: pass except KeyError: reject_reason = None # no bounce_class return AnymailTrackingEvent( event_type=event_type, timestamp=timestamp, # 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), # description=???, tags=tags, # 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, ) class SparkPostInboundWebhookView(SparkPostBaseWebhookView): """Handler for SparkPost inbound relay webhook""" signal = inbound def esp_to_anymail_event(self, event_class, event, raw_event): 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." ) 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"] ) ) 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) return AnymailInboundEvent( event_type=EventType.INBOUND, # 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, ) django-anymail-13.0/anymail/webhooks/unisender_go.py000066400000000000000000000172741477357323300226660ustar00rootroot00000000000000from __future__ import annotations import json import typing from datetime import datetime, timezone from hashlib import md5 from django.http import HttpRequest, HttpResponse from django.utils.crypto import constant_time_compare from ..exceptions import AnymailWebhookValidationFailure from ..signals import AnymailTrackingEvent, EventType, RejectReason, tracking from ..utils import get_anymail_setting from .base import AnymailBaseWebhookView class UnisenderGoTrackingWebhookView(AnymailBaseWebhookView): """Handler for Unisender Go delivery and engagement tracking webhooks""" # See https://godocs.unisender.ru/web-api-ref#callback-format for webhook payload esp_name = "Unisender Go" signal = tracking warn_if_no_basic_auth = False # because we validate against signature api_key: str | None = None # allows kwargs override event_types = { "sent": EventType.SENT, "delivered": EventType.DELIVERED, "opened": EventType.OPENED, "clicked": EventType.CLICKED, "unsubscribed": EventType.UNSUBSCRIBED, "subscribed": EventType.SUBSCRIBED, "spam": EventType.COMPLAINED, "soft_bounced": EventType.DEFERRED, "hard_bounced": EventType.BOUNCED, } reject_reasons = { "err_user_unknown": RejectReason.BOUNCED, "err_user_inactive": RejectReason.BOUNCED, "err_will_retry": None, # not rejected "err_mailbox_discarded": RejectReason.BOUNCED, "err_mailbox_full": RejectReason.BOUNCED, "err_spam_rejected": RejectReason.SPAM, "err_blacklisted": RejectReason.BLOCKED, "err_too_large": RejectReason.BOUNCED, "err_unsubscribed": RejectReason.UNSUBSCRIBED, "err_unreachable": RejectReason.BOUNCED, "err_skip_letter": RejectReason.BOUNCED, "err_domain_inactive": RejectReason.BOUNCED, "err_destination_misconfigured": RejectReason.BOUNCED, "err_delivery_failed": RejectReason.OTHER, "err_spam_skipped": RejectReason.SPAM, "err_lost": RejectReason.OTHER, } http_method_names = ["post", "head", "options", "get"] def __init__(self, **kwargs): api_key = get_anymail_setting( "api_key", esp_name=self.esp_name, allow_bare=True, kwargs=kwargs ) self.api_key_bytes = api_key.encode("ascii") super().__init__(**kwargs) def get( self, request: HttpRequest, *args: typing.Any, **kwargs: typing.Any ) -> HttpResponse: # Unisender Go verifies the webhook with a GET request at configuration time return HttpResponse() def parse_json_body(self, request: HttpRequest) -> dict | list | None: # Cache parsed JSON request.body on the request. if hasattr(request, "_parsed_json"): parsed = getattr(request, "_parsed_json") else: parsed = json.loads(request.body.decode()) setattr(request, "_parsed_json", parsed) return parsed def validate_request(self, request: HttpRequest) -> None: """ Validate Unisender Go webhook signature: "MD5 hash of the string body of the message, with the auth value replaced by the api_key of the user/project whose handler is being called." https://godocs.unisender.ru/web-api-ref#callback-format """ # This must avoid any assumptions about how Unisender Go serializes JSON # (key order, spaces, Unicode encoding vs. \u escapes, etc.). But we do # assume the "auth" field MD5 hash is unique within the serialized JSON, # so that we can use string replacement to calculate the expected hash. body = request.body try: parsed = self.parse_json_body(request) actual_auth = parsed["auth"] actual_auth_bytes = actual_auth.encode() except (AttributeError, KeyError, ValueError): raise AnymailWebhookValidationFailure( "Unisender Go webhook called with invalid payload" ) body_to_sign = body.replace(actual_auth_bytes, self.api_key_bytes) expected_auth = md5(body_to_sign).hexdigest() if not constant_time_compare(actual_auth, expected_auth): # If webhook has a selected project, include the project_id in the error. try: project_id = parsed["events_by_user"][0]["project_id"] except (KeyError, IndexError): project_id = parsed.get("project_id") # try "single event" payload is_for_project = f" is for Project ID {project_id}" if project_id else "" raise AnymailWebhookValidationFailure( "Unisender Go webhook called with incorrect signature" f" (check Anymail UNISENDER_GO_API_KEY setting{is_for_project})" ) def parse_events(self, request: HttpRequest) -> list[AnymailTrackingEvent]: parsed = self.parse_json_body(request) # Unisender Go has two options for webhook payloads. We support both. try: events_by_user = parsed["events_by_user"] except KeyError: # "Use single event": one flat dict, combining "event_data" fields # with "event_name", "user_id", "project_id", etc. if parsed["event_name"] == "transactional_email_status": esp_events = [parsed] else: esp_events = [] else: # Not "use single event": we want the "event_data" from all events # with event_name "transactional_email_status". assert len(events_by_user) == 1 # "A single element array" per API docs esp_events = [ event["event_data"] for event in events_by_user[0]["events"] if event["event_name"] == "transactional_email_status" ] return [self.esp_to_anymail_event(esp_event) for esp_event in esp_events] def esp_to_anymail_event(self, event_data: dict) -> AnymailTrackingEvent: event_type = self.event_types.get(event_data["status"], EventType.UNKNOWN) # Unisender Go does not provide any way to deduplicate webhook calls. # (There is an "ID" HTTP header, but it has a unique value for every # webhook call--including retransmissions of earlier failed calls.) event_id = None # event_time is ISO-like, without a stated time zone. (But it's UTC per docs.) try: timestamp = datetime.fromisoformat(event_data["event_time"]).replace( tzinfo=timezone.utc ) except KeyError: timestamp = None # Extract our message_id (see backend UNISENDER_GO_GENERATE_MESSAGE_ID). metadata = event_data.get("metadata", {}).copy() message_id = metadata.pop("anymail_id", event_data.get("job_id")) delivery_info = event_data.get("delivery_info", {}) delivery_status = delivery_info.get("delivery_status", "") if delivery_status.startswith("err"): reject_reason = self.reject_reasons.get(delivery_status, RejectReason.OTHER) else: reject_reason = None description = delivery_info.get("delivery_status") or event_data.get("comment") mta_response = delivery_info.get("destination_response") return AnymailTrackingEvent( event_type=event_type, timestamp=timestamp, message_id=message_id, event_id=event_id, recipient=event_data["email"], reject_reason=reject_reason, description=description, mta_response=mta_response, metadata=metadata, click_url=event_data.get("url"), user_agent=delivery_info.get("user_agent"), esp_event=event_data, ) django-anymail-13.0/docs/000077500000000000000000000000001477357323300152755ustar00rootroot00000000000000django-anymail-13.0/docs/Makefile000066400000000000000000000126741477357323300167470ustar00rootroot00000000000000# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = _build # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: -rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Djrill.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Djrill.qhc" devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/Djrill" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Djrill" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." django-anymail-13.0/docs/_readme/000077500000000000000000000000001477357323300166715ustar00rootroot00000000000000django-anymail-13.0/docs/_readme/render.py000066400000000000000000000067441477357323300205350ustar00rootroot00000000000000#!/usr/bin/env python # Render a README file (roughly) as it would appear on PyPI import argparse import sys from importlib.metadata import PackageNotFoundError, metadata from pathlib import Path from typing import Dict, Optional import readme_renderer.rst from docutils.core import publish_string from docutils.utils import SystemMessage # Docutils template.txt in our directory: DEFAULT_TEMPLATE_FILE = Path(__file__).with_name("template.txt").absolute() def get_package_readme(package: str) -> str: # Note: "description" was added to metadata in Python 3.10 return metadata(package)["description"] class ReadMeHTMLWriter(readme_renderer.rst.Writer): translator_class = readme_renderer.rst.ReadMeHTMLTranslator def interpolation_dict(self) -> Dict[str, str]: result = super().interpolation_dict() # clean the same parts as readme_renderer.rst.render: clean = readme_renderer.rst.clean result["docinfo"] = clean(result["docinfo"]) result["body"] = result["fragment"] = clean(result["fragment"]) return result def render(source_text: str, warning_stream=sys.stderr) -> Optional[str]: # Adapted from readme_renderer.rst.render settings = readme_renderer.rst.SETTINGS.copy() settings.update( { "warning_stream": warning_stream, "template": DEFAULT_TEMPLATE_FILE, # Input and output are text str (we handle decoding/encoding): "input_encoding": "unicode", "output_encoding": "unicode", # Exit with error on docutils warning or above. # (There's discussion of having readme_renderer ignore warnings; # this ensures they'll be treated as errors here.) "halt_level": 2, # (docutils.utils.Reporter.WARNING_LEVEL) # Report all docutils warnings or above. # (The readme_renderer default suppresses this output.) "report_level": 2, # (docutils.utils.Reporter.WARNING_LEVEL) } ) writer = ReadMeHTMLWriter() try: return publish_string( source_text, writer=writer, settings_overrides=settings, ) except SystemMessage: warning_stream.write("Error rendering readme source.\n") return None def main(argv=None): parser = argparse.ArgumentParser( description="Render readme file as it would appear on PyPI" ) input_group = parser.add_mutually_exclusive_group(required=True) input_group.add_argument( "-p", "--package", help="Source readme from package's metadata" ) input_group.add_argument( "-i", "--input", help="Source readme.rst file ('-' for stdin)", type=argparse.FileType("r"), ) parser.add_argument( "-o", "--output", help="Output file (default: stdout)", type=argparse.FileType("w"), default="-", ) args = parser.parse_args(argv) if args.package: try: source_text = get_package_readme(args.package) except PackageNotFoundError: print(f"Package not installed: {args.package!r}", file=sys.stderr) sys.exit(2) if source_text is None: print(f"No metadata readme for {args.package!r}", file=sys.stderr) sys.exit(2) else: source_text = args.input.read() rendered = render(source_text) if rendered is None: sys.exit(2) args.output.write(rendered) if __name__ == "__main__": main() django-anymail-13.0/docs/_readme/template.txt000066400000000000000000000032401477357323300212440ustar00rootroot00000000000000%(head_prefix)s %(head)s %(body_prefix)s

Project description

%(body)s
%(body_suffix)s django-anymail-13.0/docs/_static/000077500000000000000000000000001477357323300167235ustar00rootroot00000000000000django-anymail-13.0/docs/_static/anymail-config.js000066400000000000000000000001441477357323300221550ustar00rootroot00000000000000window.RATETHEDOCS_OPTIONS = { contactLink: "/help/#contact", privacyLink: "/docs_privacy/", }; django-anymail-13.0/docs/_static/anymail-theme.css000066400000000000000000000057451477357323300222020ustar00rootroot00000000000000/* 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 { position: sticky; left: 0; background-color: #fcfcfc; /* override transparent from .wy-table td */ } table.sticky-left td:first-of-type[colspan] > p, table.sticky-left th:first-of-type[colspan] > p { /* Hack: the full-width section headers can't stick left; since those always wrap a rubric

(in the specific table that uses this), just make the

sticky within the . */ display: inline-block; position: sticky; left: 17px; /* (.wy-table $table-padding-size) + (docutils border="1" in html) */ } /* Fix footnote stacking in sticky table */ .rst-content .citation-reference, .rst-content .footnote-reference { /* Original (but `position: relative` creates a new stacking context): vertical-align: baseline; position: relative; top: -.4em; */ vertical-align: 0.4em; position: static; top: initial; /* (not relevant with `position: static`) */ } /* 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 a more specific rule setting `.rst-content a code.xref` to $text-codexref-color, bold [2]. And $text-codexref-color is $text-color (black). This makes code.xref's inside an use standard link coloring instead. [1]: https://github.com/readthedocs/sphinx_rtd_theme/blob/2.0.0/src/sass/_theme_rst.sass#L484 [2]: https://github.com/readthedocs/sphinx_rtd_theme/blob/2.0.0/src/sass/_theme_rst.sass#L477 Related: https://github.com/readthedocs/sphinx_rtd_theme/issues/153 https://github.com/readthedocs/sphinx_rtd_theme/issues/92 */ .rst-content a code.xref { color: inherit; /*font-weight: inherit;*/ } .rst-content a:hover code.xref { color: inherit; } .rst-content a:visited code.xref { color: inherit; } /* Inline search forms (Anymail addition) */ .anymail-inline-search-form { margin-top: -1em; margin-bottom: 1em; } .anymail-inline-search-form input[type="search"] { width: 280px; max-width: 100%; border-radius: 50px; padding: 6px 12px; } /* Improve list item spacing in "open" lists. https://github.com/readthedocs/sphinx_rtd_theme/issues/1555 Undoes this rule in non-.simple lists: https://github.com/readthedocs/sphinx_rtd_theme/blob/2.0.0/src/sass/_theme_rst.sass#L174-L175 */ .rst-content .section ol:not(.simple) > li > p:only-child, .rst-content .section ol:not(.simple) > li > p:only-child:last-child, .rst-content .section ul:not(.simple) > li > p:only-child, .rst-content .section ul:not(.simple) > li > p:only-child:last-child, .rst-content section ol:not(.simple) > li > p:only-child, .rst-content section ol:not(.simple) > li > p:only-child:last-child, .rst-content section ul:not(.simple) > li > p:only-child, .rst-content section ul:not(.simple) > li > p:only-child:last-child { margin-bottom: 12px; } django-anymail-13.0/docs/_static/table-formatting.js000066400000000000000000000020101477357323300225110ustar00rootroot00000000000000/** * Return the first sibling of el that matches CSS selector, or null if no matches. * @param {HTMLElement} el * @param {string} selector * @returns {HTMLElement|null} */ function nextSiblingMatching(el, selector) { while (el && el.nextElementSibling) { el = el.nextElementSibling; if (el.matches(selector)) { return el; } } return null; } /** * Convert runs of empty elements to a colspan on the first . */ function collapseEmptyTableCells() { document.querySelectorAll(".rst-content tr:has(td:empty)").forEach((tr) => { for ( let spanStart = tr.querySelector("td"); spanStart; spanStart = nextSiblingMatching(spanStart, "td") ) { let emptyCell; while ((emptyCell = nextSiblingMatching(spanStart, "td:empty"))) { emptyCell.remove(); spanStart.colSpan++; } } }); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", collapseEmptyTableCells); } else { collapseEmptyTableCells(); } django-anymail-13.0/docs/changelog.rst000066400000000000000000000001011477357323300177460ustar00rootroot00000000000000.. _changelog: .. _release_notes: .. include:: ../CHANGELOG.rst django-anymail-13.0/docs/conf.py000066400000000000000000000251311477357323300165760ustar00rootroot00000000000000# Anymail documentation build configuration file, created by # sphinx-quickstart # # This file is execfile()d with the current directory set to its containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import os import sys from pathlib import Path from anymail import VERSION as PACKAGE_VERSION ON_READTHEDOCS = os.environ.get("READTHEDOCS") == "True" DOCS_PATH = Path(__file__).parent PROJECT_ROOT_PATH = DOCS_PATH.parent # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.insert(0, PROJECT_ROOT_PATH.resolve()) # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. 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", "sphinx_rtd_theme", "sphinxcontrib.googleanalytics", ] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # The suffix of source filenames. source_suffix = ".rst" # The encoding of source files. # source_encoding = 'utf-8-sig' # The master toctree document. master_doc = "index" # General information about the project. project = "Anymail" # noinspection PyShadowingBuiltins 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 # built documents. # # The full version, including alpha/beta/rc tags. release = ".".join(PACKAGE_VERSION) # The short X.Y version. version = ".".join(PACKAGE_VERSION[:2]) # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: # today = '' # Else, today_fmt is used as the format for a strftime call. # today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ["_build"] # The reST default role (used for this markup: `text`) to use for all documents. default_role = "py:obj" # If true, '()' will be appended to :func: etc. cross-reference text. # add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). # add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. # show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] # -- Options for HTML output --------------------------------------------------- # Set canonical URL from the Read the Docs Domain. html_baseurl = os.environ.get("READTHEDOCS_CANONICAL_URL", "") # Let templates know whether the build is running on Read the Docs. html_context = {"READTHEDOCS": ON_READTHEDOCS} # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = "sphinx_rtd_theme" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". # html_title = None # A shorter title for the navigation bar. Default is the same as html_title. # html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. # html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. # html_favicon = None # 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"] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. # html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. # html_use_smartypants = True # If true, a list all whose items consist of a single paragraph and/or a # sub-list all whose items etc… (recursive definition) will not use the

# element for any of its items. This is standard docutils behavior. # html_compact_lists = True # Custom sidebar templates, maps document names to template names. # html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. # html_additional_pages = {} # If false, no module index is generated. # html_domain_indices = True # If false, no index is generated. # html_use_index = True # If true, the index is split into individual pages for each letter. # html_split_index = False # If true, links to the reST sources are added to the pages. # html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. # html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. # html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. # html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). # html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = "Anymaildoc" # -- Options for LaTeX output -------------------------------------------------- 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': '', } # 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"), ] # The name of an image file (relative to this directory) to place at the top of # the title page. # latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. # latex_use_parts = False # If true, show page references after internal links. # latex_show_pagerefs = False # If true, show URL addresses after external links. # latex_show_urls = False # Documents to append as an appendix to all manuals. # latex_appendices = [] # If false, no module index is generated. # latex_domain_indices = True # -- Options for manual page output -------------------------------------------- # 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)] # If true, show URL addresses after external links. # man_show_urls = False # -- Options for Texinfo output ------------------------------------------------ # Grouping the document tree into Texinfo files. List of tuples # (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", ), ] # Documents to append as an appendix to all manuals. # texinfo_appendices = [] # If false, no module index is generated. # texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. # texinfo_show_urls = 'footnote' # -- Options for extlinks --------------------------------------------------- extlinks = { "pypi": ("https://pypi.org/project/%s/", "%s"), } # -- Options for Intersphinx ------------------------------------------------ intersphinx_mapping = { "python": ("https://docs.python.org/3", 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), } # -- Options for Google Analytics ------------------------------------------- googleanalytics_id = os.environ.get("GOOGLE_ANALYTICS_ID", "") googleanalytics_enabled = bool(googleanalytics_id) if not googleanalytics_enabled: # Work around https://github.com/sphinx-contrib/googleanalytics/issues/14. googleanalytics_id = "IGNORED" # -- App setup -------------------------------------------------------------- def setup(app): app.add_css_file("anymail-theme.css") # Inline