├── .editorconfig ├── .flake8 ├── .git-blame-ignore-revs ├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md └── workflows │ ├── integration-test.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yml ├── ADDING_ESPS.md ├── CHANGELOG.rst ├── LICENSE ├── README.rst ├── anymail ├── __init__.py ├── _version.py ├── apps.py ├── backends │ ├── __init__.py │ ├── amazon_ses.py │ ├── base.py │ ├── base_requests.py │ ├── brevo.py │ ├── console.py │ ├── mailersend.py │ ├── mailgun.py │ ├── mailjet.py │ ├── mandrill.py │ ├── postal.py │ ├── postmark.py │ ├── resend.py │ ├── sendgrid.py │ ├── sendinblue.py │ ├── sparkpost.py │ ├── test.py │ └── unisender_go.py ├── checks.py ├── exceptions.py ├── inbound.py ├── message.py ├── signals.py ├── urls.py ├── utils.py └── webhooks │ ├── __init__.py │ ├── amazon_ses.py │ ├── base.py │ ├── brevo.py │ ├── mailersend.py │ ├── mailgun.py │ ├── mailjet.py │ ├── mandrill.py │ ├── postal.py │ ├── postmark.py │ ├── resend.py │ ├── sendgrid.py │ ├── sendinblue.py │ ├── sparkpost.py │ └── unisender_go.py ├── docs ├── Makefile ├── _readme │ ├── render.py │ └── template.txt ├── _static │ ├── anymail-config.js │ ├── anymail-theme.css │ └── table-formatting.js ├── changelog.rst ├── conf.py ├── contributing.rst ├── docs_privacy.rst ├── docutils.conf ├── esps │ ├── amazon_ses.rst │ ├── brevo.rst │ ├── esp-feature-matrix.csv │ ├── index.rst │ ├── mailersend.rst │ ├── mailgun.rst │ ├── mailjet.rst │ ├── mandrill.rst │ ├── postal.rst │ ├── postmark.rst │ ├── resend.rst │ ├── sendgrid.rst │ ├── sparkpost.rst │ └── unisender_go.rst ├── help.rst ├── inbound.rst ├── index.rst ├── installation.rst ├── make.bat ├── quickstart.rst ├── requirements.txt ├── sending │ ├── anymail_additions.rst │ ├── django_email.rst │ ├── exceptions.rst │ ├── index.rst │ ├── signals.rst │ ├── templates.rst │ └── tracking.rst └── tips │ ├── django_templates.rst │ ├── index.rst │ ├── multiple_backends.rst │ ├── performance.rst │ ├── securing_webhooks.rst │ ├── testing.rst │ └── transient_errors.rst ├── hatch_build.py ├── pyproject.toml ├── requirements-dev.txt ├── runtests.py ├── tests ├── __init__.py ├── mock_requests_backend.py ├── requirements.txt ├── test_amazon_ses_backend.py ├── test_amazon_ses_inbound.py ├── test_amazon_ses_integration.py ├── test_amazon_ses_webhooks.py ├── test_base_backends.py ├── test_brevo_backend.py ├── test_brevo_inbound.py ├── test_brevo_integration.py ├── test_brevo_webhooks.py ├── test_checks.py ├── test_files │ ├── postmark-inbound-test-payload-with-raw.json │ ├── postmark-inbound-test-payload.json │ ├── sample_email.txt │ ├── sample_image.png │ └── unisender-go-tracking-test-payload.json.raw ├── test_general_backend.py ├── test_inbound.py ├── test_mailersend_backend.py ├── test_mailersend_inbound.py ├── test_mailersend_integration.py ├── test_mailersend_webhooks.py ├── test_mailgun_backend.py ├── test_mailgun_inbound.py ├── test_mailgun_integration.py ├── test_mailgun_webhooks.py ├── test_mailjet_backend.py ├── test_mailjet_inbound.py ├── test_mailjet_integration.py ├── test_mailjet_webhooks.py ├── test_mandrill_backend.py ├── test_mandrill_inbound.py ├── test_mandrill_integration.py ├── test_mandrill_webhooks.py ├── test_message.py ├── test_postal_backend.py ├── test_postal_inbound.py ├── test_postal_integration.py ├── test_postal_webhooks.py ├── test_postmark_backend.py ├── test_postmark_inbound.py ├── test_postmark_integration.py ├── test_postmark_webhooks.py ├── test_resend_backend.py ├── test_resend_integration.py ├── test_resend_webhooks.py ├── test_send_signals.py ├── test_sendgrid_backend.py ├── test_sendgrid_inbound.py ├── test_sendgrid_integration.py ├── test_sendgrid_webhooks.py ├── test_sendinblue_deprecations.py ├── test_settings │ ├── __init__.py │ ├── settings_4_0.py │ ├── settings_5_0.py │ └── urls.py ├── test_sparkpost_backend.py ├── test_sparkpost_inbound.py ├── test_sparkpost_integration.py ├── test_sparkpost_webhooks.py ├── test_unisender_go_backend.py ├── test_unisender_go_integration.py ├── test_unisender_go_payload.py ├── test_unisender_go_webhooks.py ├── test_utils.py ├── utils.py ├── utils_postal.py └── webhook_cases.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org/ 2 | # This is adapted from Django's .editorconfig: 3 | # https://github.com/django/django/blob/main/.editorconfig 4 | 5 | root = true 6 | 7 | [*] 8 | indent_style = space 9 | indent_size = 4 10 | insert_final_newline = true 11 | max_line_length = 88 12 | trim_trailing_whitespace = true 13 | end_of_line = lf 14 | charset = utf-8 15 | 16 | # Match pyproject.toml [tool.black] config: 17 | [*.py] 18 | max_line_length = 88 19 | 20 | # Match pyproject.toml [tool.doc8] config: 21 | [*.rst] 22 | max_line_length = 120 23 | 24 | [*.md] 25 | indent_size = 2 26 | 27 | [*.html] 28 | indent_size = 2 29 | 30 | # Anymail uses smaller indents than Django in css and js sources 31 | [*.css] 32 | indent_size = 2 33 | 34 | [*.js] 35 | indent_size = 2 36 | 37 | [*.json] 38 | indent_size = 2 39 | 40 | # Minified files shouldn't be changed 41 | [**.min.{css,js}] 42 | indent_style = ignore 43 | insert_final_newline = ignore 44 | 45 | # Makefiles always use tabs for indentation 46 | [Makefile] 47 | indent_style = tab 48 | 49 | # Batch files use tabs for indentation 50 | [*.bat] 51 | end_of_line = crlf 52 | indent_style = tab 53 | 54 | [*.{yml,yaml}] 55 | indent_size = 2 56 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | extend-exclude = 3 | build, 4 | tests/test_settings/settings_*.py 5 | 6 | # Black compatibility: 7 | # - E203 (spaces around slice operators) is not PEP-8 compliant (and Black _is_) 8 | # - Black sometimes deliberately overruns max-line-length by a small amount 9 | # (97 is Black's max-line-length of 88 + 10%) 10 | extend-ignore = E203 11 | max-line-length = 97 12 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Applied black, doc8, isort, prettier 2 | b4e22c63b38452386746fed19d5defe0797d76a0 3 | # Upgraded black to 24.8 4 | 66c677e4ab2633b1f52198597251c47739ba7b93 5 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Anymail is supported and maintained by the people who use it—like you! 2 | We welcome all contributions: issue reports, bug fixes, documentation improvements, 3 | new features, ideas and suggestions, and anything else to help improve the package. 4 | 5 | Before posting **questions** or **issues** in GitHub, please check out 6 | [_Getting support_][support] and [_Troubleshooting_][troubleshooting] 7 | in the Anymail docs. Also… 8 | 9 | > …when you're reporting a problem or bug, it's _really helpful_ to include: 10 | > 11 | > - which **ESP** you're using (Mailgun, SendGrid, etc.) 12 | > - what **versions** of Anymail, Django, and Python you're running 13 | > - any error messages and exception stack traces 14 | > - the relevant portions of your code and settings 15 | > - any [troubleshooting] you've tried 16 | 17 | For more info on **pull requests** and the **development** environment, 18 | please see [_Contributing_][contributing] in the docs. For significant 19 | new features or breaking changes, it's always a good idea to 20 | propose the idea in the [discussions] forum before writing 21 | a lot of code. 22 | 23 | [contributing]: https://anymail.dev/en/stable/contributing/ 24 | [discussions]: https://github.com/anymail/django-anymail/discussions 25 | [support]: https://anymail.dev/en/stable/help/#support 26 | [troubleshooting]: https://anymail.dev/en/stable/help/#troubleshooting 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Reporting an error? It's helpful to know: 2 | 3 | - Anymail version 4 | - ESP (Mailgun, SendGrid, etc.) 5 | - Your ANYMAIL settings (change secrets to "redacted") 6 | - Versions of Django, requests, python 7 | - Exact error message and/or stack trace 8 | - Any other relevant code and settings (e.g., for problems 9 | sending, your code that sends the message) 10 | -------------------------------------------------------------------------------- /.github/workflows/integration-test.yml: -------------------------------------------------------------------------------- 1 | name: integration-test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: ["main", "v[0-9]*"] 7 | tags: ["v[0-9]*"] 8 | workflow_dispatch: 9 | schedule: 10 | # Weekly test (on branch main) every Thursday at 12:15 UTC. 11 | # (Used to monitor compatibility with ESP API changes.) 12 | - cron: "15 12 * * 4" 13 | 14 | jobs: 15 | skip_duplicate_runs: 16 | # Avoid running the live integration tests twice on the same code 17 | # (to conserve limited sending quotas in the live ESP test accounts) 18 | runs-on: ubuntu-22.04 19 | continue-on-error: true 20 | outputs: 21 | should_skip: ${{ steps.skip_check.outputs.should_skip }} 22 | steps: 23 | - id: skip_check 24 | # uses: fkirc/skip-duplicate-actions@v5.3.1 25 | uses: fkirc/skip-duplicate-actions@f75f66ce1886f00957d99748a42c724f4330bdcf 26 | with: 27 | concurrent_skipping: "same_content_newer" 28 | cancel_others: "true" 29 | 30 | test: 31 | name: ${{ matrix.config.tox }} ${{ matrix.config.options }} 32 | runs-on: ubuntu-22.04 33 | needs: skip_duplicate_runs 34 | if: needs.skip_duplicate_runs.outputs.should_skip != 'true' 35 | timeout-minutes: 15 36 | strategy: 37 | fail-fast: false 38 | matrix: 39 | # Live API integration tests are run on only one representative Python/Django version 40 | # combination, to avoid rapidly consuming the testing accounts' entire send allotments. 41 | config: 42 | - { tox: django52-py313-amazon_ses, python: "3.13" } 43 | - { tox: django52-py313-brevo, python: "3.13" } 44 | - { tox: django52-py313-mailersend, python: "3.13" } 45 | - { tox: django52-py313-mailgun, python: "3.13" } 46 | - { tox: django52-py313-mailjet, python: "3.13" } 47 | - { tox: django52-py313-mandrill, python: "3.13" } 48 | - { tox: django52-py313-postal, python: "3.13" } 49 | - { tox: django52-py313-postmark, python: "3.13" } 50 | - { tox: django52-py313-resend, python: "3.13" } 51 | - { tox: django52-py313-sendgrid, python: "3.13" } 52 | - { tox: django52-py313-sparkpost, python: "3.13" } 53 | - { tox: django52-py313-unisender_go, python: "3.13" } 54 | 55 | steps: 56 | - name: Get code 57 | uses: actions/checkout@v4 58 | - name: Setup Python ${{ matrix.config.python }} 59 | uses: actions/setup-python@v5 60 | with: 61 | python-version: ${{ matrix.config.python }} 62 | cache: "pip" 63 | - name: Install tox 64 | run: | 65 | set -x 66 | python --version 67 | pip install 'tox<4' 68 | tox --version 69 | - name: Test ${{ matrix.config.tox }} 70 | run: | 71 | tox -e ${{ matrix.config.tox }} 72 | continue-on-error: ${{ contains( matrix.config.options, 'allow-failures' ) }} 73 | env: 74 | CONTINUOUS_INTEGRATION: true 75 | TOX_FORCE_IGNORE_OUTCOME: false 76 | ANYMAIL_RUN_LIVE_TESTS: true 77 | ANYMAIL_TEST_AMAZON_SES_ACCESS_KEY_ID: ${{ secrets.ANYMAIL_TEST_AMAZON_SES_ACCESS_KEY_ID }} 78 | ANYMAIL_TEST_AMAZON_SES_DOMAIN: ${{ secrets.ANYMAIL_TEST_AMAZON_SES_DOMAIN }} 79 | ANYMAIL_TEST_AMAZON_SES_REGION_NAME: ${{ secrets.ANYMAIL_TEST_AMAZON_SES_REGION_NAME }} 80 | ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY: ${{ secrets.ANYMAIL_TEST_AMAZON_SES_SECRET_ACCESS_KEY }} 81 | ANYMAIL_TEST_BREVO_API_KEY: ${{ secrets.ANYMAIL_TEST_BREVO_API_KEY }} 82 | ANYMAIL_TEST_BREVO_DOMAIN: ${{ vars.ANYMAIL_TEST_BREVO_DOMAIN }} 83 | ANYMAIL_TEST_MAILERSEND_API_TOKEN: ${{ secrets.ANYMAIL_TEST_MAILERSEND_API_TOKEN }} 84 | ANYMAIL_TEST_MAILERSEND_DOMAIN: ${{ secrets.ANYMAIL_TEST_MAILERSEND_DOMAIN }} 85 | ANYMAIL_TEST_MAILGUN_API_KEY: ${{ secrets.ANYMAIL_TEST_MAILGUN_API_KEY }} 86 | ANYMAIL_TEST_MAILGUN_DOMAIN: ${{ secrets.ANYMAIL_TEST_MAILGUN_DOMAIN }} 87 | ANYMAIL_TEST_MAILJET_API_KEY: ${{ secrets.ANYMAIL_TEST_MAILJET_API_KEY }} 88 | ANYMAIL_TEST_MAILJET_DOMAIN: ${{ vars.ANYMAIL_TEST_MAILJET_DOMAIN }} 89 | ANYMAIL_TEST_MAILJET_SECRET_KEY: ${{ secrets.ANYMAIL_TEST_MAILJET_SECRET_KEY }} 90 | ANYMAIL_TEST_MAILJET_TEMPLATE_ID: ${{ vars.ANYMAIL_TEST_MAILJET_TEMPLATE_ID }} 91 | ANYMAIL_TEST_MANDRILL_API_KEY: ${{ secrets.ANYMAIL_TEST_MANDRILL_API_KEY }} 92 | ANYMAIL_TEST_MANDRILL_DOMAIN: ${{ secrets.ANYMAIL_TEST_MANDRILL_DOMAIN }} 93 | ANYMAIL_TEST_POSTMARK_DOMAIN: ${{ secrets.ANYMAIL_TEST_POSTMARK_DOMAIN }} 94 | ANYMAIL_TEST_POSTMARK_SERVER_TOKEN: ${{ secrets.ANYMAIL_TEST_POSTMARK_SERVER_TOKEN }} 95 | ANYMAIL_TEST_POSTMARK_TEMPLATE_ID: ${{ secrets.ANYMAIL_TEST_POSTMARK_TEMPLATE_ID }} 96 | ANYMAIL_TEST_RESEND_API_KEY: ${{ secrets.ANYMAIL_TEST_RESEND_API_KEY }} 97 | ANYMAIL_TEST_RESEND_DOMAIN: ${{ secrets.ANYMAIL_TEST_RESEND_DOMAIN }} 98 | ANYMAIL_TEST_SENDGRID_API_KEY: ${{ secrets.ANYMAIL_TEST_SENDGRID_API_KEY }} 99 | ANYMAIL_TEST_SENDGRID_DOMAIN: ${{ secrets.ANYMAIL_TEST_SENDGRID_DOMAIN }} 100 | ANYMAIL_TEST_SENDGRID_TEMPLATE_ID: ${{ secrets.ANYMAIL_TEST_SENDGRID_TEMPLATE_ID }} 101 | ANYMAIL_TEST_SPARKPOST_API_KEY: ${{ secrets.ANYMAIL_TEST_SPARKPOST_API_KEY }} 102 | ANYMAIL_TEST_SPARKPOST_DOMAIN: ${{ secrets.ANYMAIL_TEST_SPARKPOST_DOMAIN }} 103 | ANYMAIL_TEST_UNISENDER_GO_API_KEY: ${{ secrets.ANYMAIL_TEST_UNISENDER_GO_API_KEY }} 104 | ANYMAIL_TEST_UNISENDER_GO_API_URL: ${{ vars.ANYMAIL_TEST_UNISENDER_GO_API_URL }} 105 | ANYMAIL_TEST_UNISENDER_GO_DOMAIN: ${{ vars.ANYMAIL_TEST_UNISENDER_GO_DOMAIN }} 106 | ANYMAIL_TEST_UNISENDER_GO_TEMPLATE_ID: ${{ vars.ANYMAIL_TEST_UNISENDER_GO_TEMPLATE_ID }} 107 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | # To release this package: 4 | # 1. Update the version number and changelog in the source. 5 | # Commit and push (to branch main or a vX.Y patch branch), 6 | # and wait for tests to complete. 7 | # 2. Tag with "vX.Y" or "vX.Y.Z": either create and push tag 8 | # directly via git, or create and publish a GitHub release. 9 | # 10 | # This workflow will run in response to the new tag, and will: 11 | # - Verify the source code and git tag version numbers match 12 | # - Publish the package to PyPI 13 | # - Create or update the release on GitHub 14 | 15 | on: 16 | push: 17 | tags: ["v[0-9]*"] 18 | workflow_dispatch: 19 | 20 | jobs: 21 | build: 22 | runs-on: ubuntu-22.04 23 | outputs: 24 | anchor: ${{ steps.version.outputs.anchor }} 25 | tag: ${{ steps.version.outputs.tag }} 26 | version: ${{ steps.version.outputs.version }} 27 | steps: 28 | - name: Get code 29 | uses: actions/checkout@v4 30 | 31 | - name: Setup Python 32 | uses: actions/setup-python@v5 33 | with: 34 | python-version: "3.13" 35 | 36 | - name: Install build requirements 37 | run: | 38 | python -m pip install --upgrade build hatch twine 39 | 40 | - name: Get version 41 | # (This will end the workflow if git and source versions don't match.) 42 | id: version 43 | run: | 44 | VERSION="$(python -m hatch version)" 45 | TAG="v$VERSION" 46 | GIT_TAG="$(git tag -l --points-at "$GITHUB_REF" 'v*')" 47 | if [ "x$GIT_TAG" != "x$TAG" ]; then 48 | echo "::error ::package version '$TAG' does not match git tag '$GIT_TAG'" 49 | exit 1 50 | fi 51 | echo "version=$VERSION" >> $GITHUB_OUTPUT 52 | echo "tag=$TAG" >> $GITHUB_OUTPUT 53 | echo "anchor=${TAG//[^[:alnum:]]/-}" >> $GITHUB_OUTPUT 54 | 55 | - name: Build distribution 56 | run: | 57 | rm -rf build dist django_anymail.egg-info 58 | python -m build 59 | 60 | - name: Check metadata 61 | run: | 62 | python -m twine check dist/* 63 | 64 | - name: Upload build artifacts 65 | uses: actions/upload-artifact@v4 66 | with: 67 | name: dist 68 | path: dist/ 69 | retention-days: 7 70 | 71 | publish: 72 | needs: [build] 73 | runs-on: ubuntu-22.04 74 | environment: 75 | name: pypi 76 | url: https://pypi.org/p/django-anymail 77 | permissions: 78 | # Required for PyPI trusted publishing 79 | id-token: write 80 | steps: 81 | - name: Download build artifacts 82 | uses: actions/download-artifact@v4 83 | with: 84 | name: dist 85 | path: dist/ 86 | - name: Publish to PyPI 87 | uses: pypa/gh-action-pypi-publish@release/v1 88 | 89 | release: 90 | needs: [build, publish] 91 | runs-on: ubuntu-22.04 92 | permissions: 93 | # `gh release` requires write permission on repo contents 94 | contents: write 95 | steps: 96 | - name: Download build artifacts 97 | uses: actions/download-artifact@v4 98 | with: 99 | name: dist 100 | path: dist/ 101 | - name: Release to GitHub 102 | env: 103 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 104 | TAG: ${{ needs.build.outputs.tag }} 105 | TITLE: ${{ needs.build.outputs.tag }} 106 | NOTES: | 107 | [Changelog](https://anymail.dev/en/stable/changelog/#${{ needs.build.outputs.anchor }}) 108 | run: | 109 | if ! gh release edit "$TAG" \ 110 | --repo "$GITHUB_REPOSITORY" \ 111 | --verify-tag \ 112 | --target "$GITHUB_SHA" \ 113 | --title "$TITLE" \ 114 | --notes "$NOTES"\ 115 | then 116 | gh release create "$TAG" \ 117 | --repo "$GITHUB_REPOSITORY" \ 118 | --verify-tag \ 119 | --target "$GITHUB_SHA" \ 120 | --title "$TITLE" \ 121 | --notes "$NOTES" 122 | fi 123 | gh release upload "$TAG" \ 124 | --repo "$GITHUB_REPOSITORY" \ 125 | ./dist/* 126 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: ["main", "v[0-9]*"] 7 | tags: ["v[0-9]*"] 8 | workflow_dispatch: 9 | schedule: 10 | # Weekly test (on branch main) every Thursday at 12:00 UTC. 11 | # (Used to monitor compatibility with Django patches/dev and other dependencies.) 12 | - cron: "0 12 * * 4" 13 | 14 | jobs: 15 | get-envlist: 16 | runs-on: ubuntu-22.04 17 | outputs: 18 | envlist: ${{ steps.generate-envlist.outputs.envlist }} 19 | steps: 20 | - name: Get code 21 | uses: actions/checkout@v4 22 | - name: Setup default Python 23 | # Change default Python version to something consistent 24 | # for installing/running tox 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: "3.13" 28 | - name: Install tox-gh-matrix 29 | run: | 30 | python -m pip install 'tox<4' 'tox-gh-matrix<0.3' 31 | python -m tox --version 32 | - name: Generate tox envlist 33 | id: generate-envlist 34 | run: | 35 | python -m tox --gh-matrix 36 | python -m tox --gh-matrix-dump # for debugging 37 | 38 | test: 39 | runs-on: ubuntu-22.04 40 | needs: get-envlist 41 | strategy: 42 | matrix: 43 | tox: ${{ fromJSON(needs.get-envlist.outputs.envlist) }} 44 | fail-fast: false 45 | 46 | name: ${{ matrix.tox.name }} ${{ matrix.tox.ignore_outcome && 'allow-failures' || '' }} 47 | timeout-minutes: 15 48 | steps: 49 | - name: Get code 50 | uses: actions/checkout@v4 51 | - name: Setup Python ${{ matrix.tox.python.version }} 52 | # Ensure matrix Python version is installed and available for tox 53 | uses: actions/setup-python@v5 54 | with: 55 | python-version: ${{ matrix.tox.python.spec }} 56 | cache: "pip" 57 | - name: Setup default Python 58 | # Change default Python version back to something consistent 59 | # for installing/running tox 60 | uses: actions/setup-python@v5 61 | with: 62 | python-version: "3.13" 63 | - name: Install tox 64 | run: | 65 | set -x 66 | python -VV 67 | python -m pip install 'tox<4' 68 | python -m tox --version 69 | - name: Test ${{ matrix.tox.name }} 70 | run: | 71 | python -m tox -e ${{ matrix.tox.name }} 72 | continue-on-error: ${{ matrix.tox.ignore_outcome == true }} 73 | env: 74 | CONTINUOUS_INTEGRATION: true 75 | TOX_OVERRIDE_IGNORE_OUTCOME: false 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | ._* 3 | *.pyc 4 | *.egg 5 | *.egg-info 6 | .eggs/ 7 | .tox/ 8 | build/ 9 | dist/ 10 | docs/_build/ 11 | local.py 12 | 13 | # Because pipenv was only used to manage a local development 14 | # environment, it was not helpful to track its lock file 15 | Pipfile.lock 16 | 17 | # Use pyenv-virtualenv to manage a venv for local development 18 | .python-version 19 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 24.8.0 4 | hooks: 5 | - id: black 6 | - repo: https://github.com/pycqa/isort 7 | rev: 5.13.2 8 | hooks: 9 | - id: isort 10 | - repo: https://github.com/pycqa/flake8 11 | rev: 7.1.1 12 | hooks: 13 | - id: flake8 14 | - repo: https://github.com/pycqa/doc8 15 | rev: v1.1.1 16 | hooks: 17 | - id: doc8 18 | - repo: https://github.com/pre-commit/mirrors-prettier 19 | # rev: see 20 | # https://github.com/pre-commit/mirrors-prettier/issues/29#issuecomment-1332667344 21 | rev: v2.7.1 22 | hooks: 23 | - id: prettier 24 | files: '\.(css|html|jsx?|md|tsx?|ya?ml)$' 25 | additional_dependencies: 26 | - prettier@2.8.3 27 | - repo: https://github.com/pre-commit/pygrep-hooks 28 | rev: v1.10.0 29 | hooks: 30 | - id: python-check-blanket-noqa 31 | - id: python-check-blanket-type-ignore 32 | - id: python-no-eval 33 | - id: python-no-log-warn 34 | - id: python-use-type-annotations 35 | # - id: rst-backticks 36 | # (no: some docs source uses single backticks expecting Sphinx default_role) 37 | - id: rst-directive-colons 38 | - id: rst-inline-touching-normal 39 | - repo: https://github.com/pre-commit/pre-commit-hooks 40 | rev: v4.6.0 41 | hooks: 42 | - id: check-json 43 | - id: check-toml 44 | - id: check-yaml 45 | - id: end-of-file-fixer 46 | exclude: "\\.(bin|raw)$" 47 | - id: fix-byte-order-marker 48 | - id: fix-encoding-pragma 49 | args: [--remove] 50 | - id: forbid-submodules 51 | - id: mixed-line-ending 52 | - id: requirements-txt-fixer 53 | - id: trailing-whitespace 54 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | version: 2 5 | 6 | build: 7 | os: ubuntu-22.04 8 | tools: 9 | # "last stable CPython version": 10 | python: "3" 11 | 12 | sphinx: 13 | configuration: docs/conf.py 14 | builder: dirhtml 15 | 16 | # Additional formats to build: 17 | formats: all 18 | 19 | python: 20 | install: 21 | - path: . 22 | method: pip 23 | - requirements: docs/requirements.txt 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2016-2024, Anymail Contributors 4 | 5 | Redistribution and use in source and binary forms, with or without modification, 6 | are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, 9 | this list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright 12 | notice, this list of conditions and the following disclaimer in the 13 | documentation and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its contributors 16 | may be used to endorse or promote products derived from this software 17 | without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 20 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 21 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 23 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 24 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 25 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 26 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 28 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /anymail/__init__.py: -------------------------------------------------------------------------------- 1 | from ._version import VERSION, __version__ 2 | 3 | __all__ = [ 4 | "VERSION", 5 | "__version__", 6 | ] 7 | -------------------------------------------------------------------------------- /anymail/_version.py: -------------------------------------------------------------------------------- 1 | # Don't import this file directly (unless you are a build system). 2 | # Instead, load version info from the package root. 3 | 4 | #: major.minor or major.minor.patch (optionally with .devN suffix) 5 | __version__ = "13.0" 6 | 7 | VERSION = __version__.split(",") 8 | -------------------------------------------------------------------------------- /anymail/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.core import checks 3 | 4 | from .checks import check_deprecated_settings, check_insecure_settings 5 | 6 | 7 | class AnymailBaseConfig(AppConfig): 8 | name = "anymail" 9 | verbose_name = "Anymail" 10 | 11 | def ready(self): 12 | checks.register(check_deprecated_settings) 13 | checks.register(check_insecure_settings) 14 | -------------------------------------------------------------------------------- /anymail/backends/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anymail/django-anymail/29c446ff645169ce832a0263aa9baca938a46809/anymail/backends/__init__.py -------------------------------------------------------------------------------- /anymail/backends/console.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.core.mail.backends.console import EmailBackend as DjangoConsoleBackend 4 | 5 | from ..exceptions import AnymailError 6 | from .test import EmailBackend as AnymailTestBackend 7 | 8 | 9 | class EmailBackend(AnymailTestBackend, DjangoConsoleBackend): 10 | """ 11 | Anymail backend that prints messages to the console, while retaining 12 | anymail statuses and signals. 13 | """ 14 | 15 | esp_name = "Console" 16 | 17 | def get_esp_message_id(self, message): 18 | # Generate a guaranteed-unique ID for the message 19 | return str(uuid.uuid4()) 20 | 21 | def send_messages(self, email_messages): 22 | if not email_messages: 23 | return 24 | msg_count = 0 25 | with self._lock: 26 | try: 27 | stream_created = self.open() 28 | for message in email_messages: 29 | try: 30 | sent = self._send(message) 31 | except AnymailError: 32 | if self.fail_silently: 33 | sent = False 34 | else: 35 | raise 36 | if sent: 37 | self.write_message(message) 38 | self.stream.flush() # flush after each message 39 | msg_count += 1 40 | finally: 41 | if stream_created: 42 | self.close() 43 | 44 | return msg_count 45 | -------------------------------------------------------------------------------- /anymail/backends/postal.py: -------------------------------------------------------------------------------- 1 | from ..exceptions import AnymailRequestsAPIError 2 | from ..message import AnymailRecipientStatus 3 | from ..utils import get_anymail_setting 4 | from .base_requests import AnymailRequestsBackend, RequestsPayload 5 | 6 | 7 | class EmailBackend(AnymailRequestsBackend): 8 | """ 9 | Postal v1 API Email Backend 10 | """ 11 | 12 | esp_name = "Postal" 13 | 14 | def __init__(self, **kwargs): 15 | """Init options from Django settings""" 16 | esp_name = self.esp_name 17 | 18 | self.api_key = get_anymail_setting( 19 | "api_key", esp_name=esp_name, kwargs=kwargs, allow_bare=True 20 | ) 21 | 22 | # Required, as there is no hosted instance of Postal 23 | api_url = get_anymail_setting("api_url", esp_name=esp_name, kwargs=kwargs) 24 | if not api_url.endswith("/"): 25 | api_url += "/" 26 | super().__init__(api_url, **kwargs) 27 | 28 | def build_message_payload(self, message, defaults): 29 | return PostalPayload(message, defaults, self) 30 | 31 | def parse_recipient_status(self, response, payload, message): 32 | parsed_response = self.deserialize_json_response(response, payload, message) 33 | 34 | if parsed_response["status"] != "success": 35 | raise AnymailRequestsAPIError( 36 | email_message=message, payload=payload, response=response, backend=self 37 | ) 38 | 39 | # If we get here, the send call was successful. 40 | messages = parsed_response["data"]["messages"] 41 | 42 | return { 43 | email: AnymailRecipientStatus(message_id=details["id"], status="queued") 44 | for email, details in messages.items() 45 | } 46 | 47 | 48 | class PostalPayload(RequestsPayload): 49 | def __init__(self, message, defaults, backend, *args, **kwargs): 50 | http_headers = kwargs.pop("headers", {}) 51 | http_headers["X-Server-API-Key"] = backend.api_key 52 | http_headers["Content-Type"] = "application/json" 53 | http_headers["Accept"] = "application/json" 54 | super().__init__( 55 | message, defaults, backend, headers=http_headers, *args, **kwargs 56 | ) 57 | 58 | def get_api_endpoint(self): 59 | return "api/v1/send/message" 60 | 61 | def init_payload(self): 62 | self.data = {} 63 | 64 | def serialize_data(self): 65 | return self.serialize_json(self.data) 66 | 67 | def set_from_email(self, email): 68 | self.data["from"] = str(email) 69 | 70 | def set_subject(self, subject): 71 | self.data["subject"] = subject 72 | 73 | def set_to(self, emails): 74 | self.data["to"] = [str(email) for email in emails] 75 | 76 | def set_cc(self, emails): 77 | self.data["cc"] = [str(email) for email in emails] 78 | 79 | def set_bcc(self, emails): 80 | self.data["bcc"] = [str(email) for email in emails] 81 | 82 | def set_reply_to(self, emails): 83 | if len(emails) > 1: 84 | self.unsupported_feature("multiple reply_to addresses") 85 | if len(emails) > 0: 86 | self.data["reply_to"] = str(emails[0]) 87 | 88 | def set_extra_headers(self, headers): 89 | self.data["headers"] = headers 90 | 91 | def set_text_body(self, body): 92 | self.data["plain_body"] = body 93 | 94 | def set_html_body(self, body): 95 | if "html_body" in self.data: 96 | self.unsupported_feature("multiple html parts") 97 | self.data["html_body"] = body 98 | 99 | def make_attachment(self, attachment): 100 | """Returns Postal attachment dict for attachment""" 101 | att = { 102 | "name": attachment.name or "", 103 | "data": attachment.b64content, 104 | "content_type": attachment.mimetype, 105 | } 106 | if attachment.inline: 107 | # see https://github.com/postalhq/postal/issues/731 108 | # but it might be possible with the send/raw endpoint 109 | self.unsupported_feature("inline attachments") 110 | return att 111 | 112 | def set_attachments(self, attachments): 113 | if attachments: 114 | self.data["attachments"] = [ 115 | self.make_attachment(attachment) for attachment in attachments 116 | ] 117 | 118 | def set_envelope_sender(self, email): 119 | self.data["sender"] = str(email) 120 | 121 | def set_tags(self, tags): 122 | if len(tags) > 1: 123 | self.unsupported_feature("multiple tags") 124 | if len(tags) > 0: 125 | self.data["tag"] = tags[0] 126 | 127 | def set_esp_extra(self, extra): 128 | self.data.update(extra) 129 | -------------------------------------------------------------------------------- /anymail/backends/sendinblue.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from ..exceptions import AnymailDeprecationWarning 4 | from .brevo import EmailBackend as BrevoEmailBackend 5 | 6 | 7 | class EmailBackend(BrevoEmailBackend): 8 | """ 9 | Deprecated compatibility backend for old Brevo name "SendinBlue". 10 | """ 11 | 12 | esp_name = "SendinBlue" 13 | 14 | def __init__(self, **kwargs): 15 | warnings.warn( 16 | "`anymail.backends.sendinblue.EmailBackend` has been renamed" 17 | " `anymail.backends.brevo.EmailBackend`.", 18 | AnymailDeprecationWarning, 19 | ) 20 | super().__init__(**kwargs) 21 | -------------------------------------------------------------------------------- /anymail/backends/test.py: -------------------------------------------------------------------------------- 1 | from django.core import mail 2 | 3 | from ..exceptions import AnymailAPIError 4 | from ..message import AnymailRecipientStatus 5 | from .base import AnymailBaseBackend, BasePayload 6 | 7 | 8 | class EmailBackend(AnymailBaseBackend): 9 | """ 10 | Anymail backend that simulates sending messages, useful for testing. 11 | 12 | Sent messages are collected in django.core.mail.outbox 13 | (as with Django's locmem backend). 14 | 15 | In addition: 16 | * Anymail send params parsed from the message will be attached 17 | to the outbox message as a dict in the attr `anymail_test_params` 18 | * If the caller supplies an `anymail_test_response` attr on the message, 19 | that will be used instead of the default "sent" response. It can be either 20 | an AnymailRecipientStatus or an instance of AnymailAPIError (or a subclass) 21 | to raise an exception. 22 | """ 23 | 24 | esp_name = "Test" 25 | 26 | def __init__(self, *args, **kwargs): 27 | # Allow replacing the payload, for testing. 28 | # (Real backends would generally not implement this option.) 29 | self._payload_class = kwargs.pop("payload_class", TestPayload) 30 | super().__init__(*args, **kwargs) 31 | if not hasattr(mail, "outbox"): 32 | mail.outbox = [] # see django.core.mail.backends.locmem 33 | 34 | def get_esp_message_id(self, message): 35 | # Get a unique ID for the message. The message must have been added to 36 | # the outbox first. 37 | return mail.outbox.index(message) 38 | 39 | def build_message_payload(self, message, defaults): 40 | return self._payload_class(backend=self, message=message, defaults=defaults) 41 | 42 | def post_to_esp(self, payload, message): 43 | # Keep track of the sent messages and params (for test cases) 44 | message.anymail_test_params = payload.get_params() 45 | mail.outbox.append(message) 46 | try: 47 | # Tests can supply their own message.test_response: 48 | response = message.anymail_test_response 49 | if isinstance(response, AnymailAPIError): 50 | raise response 51 | except AttributeError: 52 | # Default is to return 'sent' for each recipient 53 | status = AnymailRecipientStatus( 54 | message_id=self.get_esp_message_id(message), status="sent" 55 | ) 56 | response = { 57 | "recipient_status": { 58 | email: status for email in payload.recipient_emails 59 | } 60 | } 61 | return response 62 | 63 | def parse_recipient_status(self, response, payload, message): 64 | try: 65 | return response["recipient_status"] 66 | except KeyError as err: 67 | raise AnymailAPIError("Unparsable test response") from err 68 | 69 | 70 | class TestPayload(BasePayload): 71 | # For test purposes, just keep a dict of the params we've received. 72 | # (This approach is also useful for native API backends -- think of 73 | # payload.params as collecting kwargs for esp_native_api.send().) 74 | 75 | def init_payload(self): 76 | self.params = {} 77 | self.recipient_emails = [] 78 | 79 | def get_params(self): 80 | # Test backend callers can check message.anymail_test_params['is_batch_send'] 81 | # to verify whether Anymail thought the message should use batch send logic. 82 | self.params["is_batch_send"] = self.is_batch() 83 | return self.params 84 | 85 | def set_from_email(self, email): 86 | self.params["from"] = email 87 | 88 | def set_envelope_sender(self, email): 89 | self.params["envelope_sender"] = email.addr_spec 90 | 91 | def set_to(self, emails): 92 | self.params["to"] = emails 93 | self.recipient_emails += [email.addr_spec for email in emails] 94 | 95 | def set_cc(self, emails): 96 | self.params["cc"] = emails 97 | self.recipient_emails += [email.addr_spec for email in emails] 98 | 99 | def set_bcc(self, emails): 100 | self.params["bcc"] = emails 101 | self.recipient_emails += [email.addr_spec for email in emails] 102 | 103 | def set_subject(self, subject): 104 | self.params["subject"] = subject 105 | 106 | def set_reply_to(self, emails): 107 | self.params["reply_to"] = emails 108 | 109 | def set_extra_headers(self, headers): 110 | self.params["extra_headers"] = headers 111 | 112 | def set_text_body(self, body): 113 | self.params["text_body"] = body 114 | 115 | def set_html_body(self, body): 116 | self.params["html_body"] = body 117 | 118 | def add_alternative(self, content, mimetype): 119 | # For testing purposes, we allow all "text/*" alternatives, 120 | # but not any other mimetypes. 121 | if mimetype.startswith("text"): 122 | self.params.setdefault("alternatives", []).append((content, mimetype)) 123 | else: 124 | self.unsupported_feature("alternative part with type '%s'" % mimetype) 125 | 126 | def add_attachment(self, attachment): 127 | self.params.setdefault("attachments", []).append(attachment) 128 | 129 | def set_metadata(self, metadata): 130 | self.params["metadata"] = metadata 131 | 132 | def set_send_at(self, send_at): 133 | self.params["send_at"] = send_at 134 | 135 | def set_tags(self, tags): 136 | self.params["tags"] = tags 137 | 138 | def set_track_clicks(self, track_clicks): 139 | self.params["track_clicks"] = track_clicks 140 | 141 | def set_track_opens(self, track_opens): 142 | self.params["track_opens"] = track_opens 143 | 144 | def set_template_id(self, template_id): 145 | self.params["template_id"] = template_id 146 | 147 | def set_merge_data(self, merge_data): 148 | self.params["merge_data"] = merge_data 149 | 150 | def set_merge_headers(self, merge_headers): 151 | self.params["merge_headers"] = merge_headers 152 | 153 | def set_merge_metadata(self, merge_metadata): 154 | self.params["merge_metadata"] = merge_metadata 155 | 156 | def set_merge_global_data(self, merge_global_data): 157 | self.params["merge_global_data"] = merge_global_data 158 | 159 | def set_esp_extra(self, extra): 160 | # Merge extra into params 161 | self.params.update(extra) 162 | -------------------------------------------------------------------------------- /anymail/checks.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core import checks 3 | 4 | from anymail.utils import get_anymail_setting 5 | 6 | 7 | def check_deprecated_settings(app_configs, **kwargs): 8 | errors = [] 9 | 10 | anymail_settings = getattr(settings, "ANYMAIL", {}) 11 | 12 | # anymail.W001: reserved [was deprecation warning that became anymail.E001] 13 | 14 | # anymail.E001: rename WEBHOOK_AUTHORIZATION to WEBHOOK_SECRET 15 | if "WEBHOOK_AUTHORIZATION" in anymail_settings: 16 | errors.append( 17 | checks.Error( 18 | "The ANYMAIL setting 'WEBHOOK_AUTHORIZATION' has been renamed" 19 | " 'WEBHOOK_SECRET' to improve security.", 20 | hint="You must update your settings.py.", 21 | id="anymail.E001", 22 | ) 23 | ) 24 | if hasattr(settings, "ANYMAIL_WEBHOOK_AUTHORIZATION"): 25 | errors.append( 26 | checks.Error( 27 | "The ANYMAIL_WEBHOOK_AUTHORIZATION setting has been renamed" 28 | " ANYMAIL_WEBHOOK_SECRET to improve security.", 29 | hint="You must update your settings.py.", 30 | id="anymail.E001", 31 | ) 32 | ) 33 | 34 | return errors 35 | 36 | 37 | def check_insecure_settings(app_configs, **kwargs): 38 | errors = [] 39 | 40 | # anymail.W002: DEBUG_API_REQUESTS can leak private information 41 | if get_anymail_setting("debug_api_requests", default=False) and not settings.DEBUG: 42 | errors.append( 43 | checks.Warning( 44 | "You have enabled the ANYMAIL setting DEBUG_API_REQUESTS, which can " 45 | "leak API keys and other sensitive data into logs or the console.", 46 | hint="You should not use DEBUG_API_REQUESTS in production deployment.", 47 | id="anymail.W002", 48 | ) 49 | ) 50 | 51 | return errors 52 | -------------------------------------------------------------------------------- /anymail/message.py: -------------------------------------------------------------------------------- 1 | from email.mime.image import MIMEImage 2 | from email.utils import make_msgid, unquote 3 | from pathlib import Path 4 | 5 | from django.core.mail import EmailMessage, EmailMultiAlternatives 6 | 7 | from .utils import UNSET 8 | 9 | 10 | class AnymailMessageMixin(EmailMessage): 11 | """Mixin for EmailMessage that exposes Anymail features. 12 | 13 | Use of this mixin is optional. You can always just set Anymail 14 | attributes on any EmailMessage. 15 | 16 | (The mixin can be helpful with type checkers and other development 17 | tools that complain about accessing Anymail's added attributes 18 | on a regular EmailMessage.) 19 | """ 20 | 21 | def __init__(self, *args, **kwargs): 22 | self.esp_extra = kwargs.pop("esp_extra", UNSET) 23 | self.envelope_sender = kwargs.pop("envelope_sender", UNSET) 24 | self.metadata = kwargs.pop("metadata", UNSET) 25 | self.send_at = kwargs.pop("send_at", UNSET) 26 | self.tags = kwargs.pop("tags", UNSET) 27 | self.track_clicks = kwargs.pop("track_clicks", UNSET) 28 | self.track_opens = kwargs.pop("track_opens", UNSET) 29 | self.template_id = kwargs.pop("template_id", UNSET) 30 | self.merge_data = kwargs.pop("merge_data", UNSET) 31 | self.merge_global_data = kwargs.pop("merge_global_data", UNSET) 32 | self.merge_headers = kwargs.pop("merge_headers", UNSET) 33 | self.merge_metadata = kwargs.pop("merge_metadata", UNSET) 34 | self.anymail_status = AnymailStatus() 35 | 36 | super().__init__(*args, **kwargs) 37 | 38 | def attach_inline_image_file(self, path, subtype=None, idstring="img", domain=None): 39 | """ 40 | Add inline image from file path to an EmailMessage, and return its content id 41 | """ 42 | assert isinstance(self, EmailMessage) 43 | return attach_inline_image_file(self, path, subtype, idstring, domain) 44 | 45 | def attach_inline_image( 46 | self, content, filename=None, subtype=None, idstring="img", domain=None 47 | ): 48 | """Add inline image and return its content id""" 49 | assert isinstance(self, EmailMessage) 50 | return attach_inline_image(self, content, filename, subtype, idstring, domain) 51 | 52 | 53 | class AnymailMessage(AnymailMessageMixin, EmailMultiAlternatives): 54 | pass 55 | 56 | 57 | def attach_inline_image_file(message, path, subtype=None, idstring="img", domain=None): 58 | """Add inline image from file path to an EmailMessage, and return its content id""" 59 | pathobj = Path(path) 60 | filename = pathobj.name 61 | content = pathobj.read_bytes() 62 | return attach_inline_image(message, content, filename, subtype, idstring, domain) 63 | 64 | 65 | def attach_inline_image( 66 | message, content, filename=None, subtype=None, idstring="img", domain=None 67 | ): 68 | """Add inline image to an EmailMessage, and return its content id""" 69 | if domain is None: 70 | # Avoid defaulting to hostname that might end in '.com', because some ESPs 71 | # use Content-ID as filename, and Gmail blocks filenames ending in '.com'. 72 | domain = "inline" # valid domain for a msgid; will never be a real TLD 73 | 74 | # Content ID per RFC 2045 section 7 (with <...>): 75 | content_id = make_msgid(idstring, domain) 76 | 77 | image = MIMEImage(content, subtype) 78 | image.add_header("Content-Disposition", "inline", filename=filename) 79 | image.add_header("Content-ID", content_id) 80 | message.attach(image) 81 | return unquote(content_id) # Without <...>, for use as the tag src 82 | 83 | 84 | ANYMAIL_STATUSES = [ 85 | "sent", # the ESP has sent the message (though it may or may not get delivered) 86 | "queued", # the ESP will try to send the message later 87 | "invalid", # the recipient email was not valid 88 | "rejected", # the recipient is blacklisted 89 | "failed", # the attempt to send failed for some other reason 90 | "unknown", # anything else 91 | ] 92 | 93 | 94 | class AnymailRecipientStatus: 95 | """Information about an EmailMessage's send status for a single recipient""" 96 | 97 | def __init__(self, message_id, status): 98 | try: 99 | # message_id must be something that can be put in a set 100 | # (see AnymailStatus.set_recipient_status) 101 | set([message_id]) 102 | except TypeError: 103 | raise TypeError("Invalid message_id %r is not scalar type" % message_id) 104 | if status is not None and status not in ANYMAIL_STATUSES: 105 | raise ValueError("Invalid status %r" % status) 106 | self.message_id = message_id # ESP message id 107 | self.status = status # one of ANYMAIL_STATUSES, or None for not yet sent to ESP 108 | 109 | def __repr__(self): 110 | return "AnymailRecipientStatus({message_id!r}, {status!r})".format( 111 | message_id=self.message_id, status=self.status 112 | ) 113 | 114 | 115 | class AnymailStatus: 116 | """Information about an EmailMessage's send status for all recipients""" 117 | 118 | def __init__(self): 119 | #: set of ESP message ids across all recipients, or bare id if only one, or None 120 | self.message_id = None 121 | #: set of ANYMAIL_STATUSES across all recipients, or None if not yet sent to ESP 122 | self.status = None 123 | #: per-recipient: { email: AnymailRecipientStatus, ... } 124 | self.recipients = {} 125 | self.esp_response = None 126 | 127 | def __repr__(self): 128 | def _repr(o): 129 | if isinstance(o, set): 130 | # force sorted order, for reproducible testing 131 | item_reprs = [repr(item) for item in sorted(o)] 132 | return "{%s}" % ", ".join(item_reprs) 133 | else: 134 | return repr(o) 135 | 136 | details = ["status={status}".format(status=_repr(self.status))] 137 | if self.message_id: 138 | details.append( 139 | "message_id={message_id}".format(message_id=_repr(self.message_id)) 140 | ) 141 | if self.recipients: 142 | details.append( 143 | "{num_recipients} recipients".format( 144 | num_recipients=len(self.recipients) 145 | ) 146 | ) 147 | return "AnymailStatus<{details}>".format(details=", ".join(details)) 148 | 149 | def set_recipient_status(self, recipients): 150 | self.recipients.update(recipients) 151 | recipient_statuses = self.recipients.values() 152 | self.message_id = set( 153 | [recipient.message_id for recipient in recipient_statuses] 154 | ) 155 | if len(self.message_id) == 1: 156 | self.message_id = self.message_id.pop() # de-set-ify if single message_id 157 | self.status = set([recipient.status for recipient in recipient_statuses]) 158 | -------------------------------------------------------------------------------- /anymail/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | #: Outbound message, before sending 4 | #: provides args: message, esp_name 5 | pre_send = Signal() 6 | 7 | #: Outbound message, after sending 8 | #: provides args: message, status, esp_name 9 | post_send = Signal() 10 | 11 | #: Delivery and tracking events for sent messages 12 | #: provides args: event, esp_name 13 | tracking = Signal() 14 | 15 | #: Event for receiving inbound messages 16 | #: provides args: event, esp_name 17 | inbound = Signal() 18 | 19 | 20 | class AnymailEvent: 21 | """Base class for normalized Anymail webhook events""" 22 | 23 | def __init__( 24 | self, event_type, timestamp=None, event_id=None, esp_event=None, **kwargs 25 | ): 26 | #: normalized to an EventType str 27 | self.event_type = event_type 28 | #: normalized to an aware datetime 29 | self.timestamp = timestamp 30 | #: opaque str 31 | self.event_id = event_id 32 | #: raw event fields (e.g., parsed JSON dict or POST data QueryDict) 33 | self.esp_event = esp_event 34 | 35 | 36 | class AnymailTrackingEvent(AnymailEvent): 37 | """Normalized delivery and tracking event for sent messages""" 38 | 39 | def __init__(self, **kwargs): 40 | super().__init__(**kwargs) 41 | self.click_url = kwargs.pop("click_url", None) #: str 42 | #: str, usually human-readable, not normalized 43 | self.description = kwargs.pop("description", None) 44 | self.message_id = kwargs.pop("message_id", None) #: str, format may vary 45 | self.metadata = kwargs.pop("metadata", {}) #: dict 46 | #: str, may include SMTP codes, not normalized 47 | self.mta_response = kwargs.pop("mta_response", None) 48 | #: str email address (just the email portion; no name) 49 | self.recipient = kwargs.pop("recipient", None) 50 | #: normalized to a RejectReason str 51 | self.reject_reason = kwargs.pop("reject_reason", None) 52 | self.tags = kwargs.pop("tags", []) #: list of str 53 | self.user_agent = kwargs.pop("user_agent", None) #: str 54 | 55 | 56 | class AnymailInboundEvent(AnymailEvent): 57 | """Normalized inbound message event""" 58 | 59 | def __init__(self, **kwargs): 60 | super().__init__(**kwargs) 61 | #: anymail.inbound.AnymailInboundMessage 62 | self.message = kwargs.pop("message", None) 63 | 64 | 65 | class EventType: 66 | """Constants for normalized Anymail event types""" 67 | 68 | # Delivery (and non-delivery) event types 69 | # (these match message.ANYMAIL_STATUSES where appropriate) 70 | 71 | #: the ESP has accepted the message and will try to send it (possibly later) 72 | QUEUED = "queued" 73 | 74 | #: the ESP has sent the message (though it may or may not get delivered) 75 | SENT = "sent" 76 | 77 | #: the ESP refused to send the message 78 | #: (e.g., suppression list, policy, invalid email) 79 | REJECTED = "rejected" 80 | 81 | #: the ESP was unable to send the message (e.g., template rendering error) 82 | FAILED = "failed" 83 | 84 | #: rejected or blocked by receiving MTA 85 | BOUNCED = "bounced" 86 | 87 | #: delayed by receiving MTA; should be followed by a later BOUNCED or DELIVERED 88 | DEFERRED = "deferred" 89 | 90 | #: accepted by receiving MTA 91 | DELIVERED = "delivered" 92 | 93 | #: a bot replied 94 | AUTORESPONDED = "autoresponded" 95 | 96 | # Tracking event types 97 | 98 | #: open tracking 99 | OPENED = "opened" 100 | 101 | #: click tracking 102 | CLICKED = "clicked" 103 | 104 | #: recipient reported as spam (e.g., through feedback loop) 105 | COMPLAINED = "complained" 106 | 107 | #: recipient attempted to unsubscribe 108 | UNSUBSCRIBED = "unsubscribed" 109 | 110 | #: signed up for mailing list through ESP-hosted form 111 | SUBSCRIBED = "subscribed" 112 | 113 | # Inbound event types 114 | 115 | #: received message 116 | INBOUND = "inbound" 117 | 118 | #: (ESP notification of) error receiving message 119 | INBOUND_FAILED = "inbound_failed" 120 | 121 | # Other event types 122 | 123 | #: all other ESP events 124 | UNKNOWN = "unknown" 125 | 126 | 127 | class RejectReason: 128 | """Constants for normalized Anymail reject/drop reasons""" 129 | 130 | #: bad address format 131 | INVALID = "invalid" 132 | 133 | #: (previous) bounce from recipient 134 | BOUNCED = "bounced" 135 | 136 | #: (previous) repeated failed delivery attempts 137 | TIMED_OUT = "timed_out" 138 | 139 | #: ESP policy suppression 140 | BLOCKED = "blocked" 141 | 142 | #: (previous) spam complaint from recipient 143 | SPAM = "spam" 144 | 145 | #: (previous) unsubscribe request from recipient 146 | UNSUBSCRIBED = "unsubscribed" 147 | 148 | #: all other ESP reject reasons 149 | OTHER = "other" 150 | -------------------------------------------------------------------------------- /anymail/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, re_path 2 | 3 | from .webhooks.amazon_ses import ( 4 | AmazonSESInboundWebhookView, 5 | AmazonSESTrackingWebhookView, 6 | ) 7 | from .webhooks.brevo import BrevoInboundWebhookView, BrevoTrackingWebhookView 8 | from .webhooks.mailersend import ( 9 | MailerSendInboundWebhookView, 10 | MailerSendTrackingWebhookView, 11 | ) 12 | from .webhooks.mailgun import MailgunInboundWebhookView, MailgunTrackingWebhookView 13 | from .webhooks.mailjet import MailjetInboundWebhookView, MailjetTrackingWebhookView 14 | from .webhooks.mandrill import MandrillCombinedWebhookView 15 | from .webhooks.postal import PostalInboundWebhookView, PostalTrackingWebhookView 16 | from .webhooks.postmark import PostmarkInboundWebhookView, PostmarkTrackingWebhookView 17 | from .webhooks.resend import ResendTrackingWebhookView 18 | from .webhooks.sendgrid import SendGridInboundWebhookView, SendGridTrackingWebhookView 19 | from .webhooks.sendinblue import ( 20 | SendinBlueInboundWebhookView, 21 | SendinBlueTrackingWebhookView, 22 | ) 23 | from .webhooks.sparkpost import ( 24 | SparkPostInboundWebhookView, 25 | SparkPostTrackingWebhookView, 26 | ) 27 | from .webhooks.unisender_go import UnisenderGoTrackingWebhookView 28 | 29 | app_name = "anymail" 30 | urlpatterns = [ 31 | path( 32 | "amazon_ses/inbound/", 33 | AmazonSESInboundWebhookView.as_view(), 34 | name="amazon_ses_inbound_webhook", 35 | ), 36 | path( 37 | "brevo/inbound/", 38 | BrevoInboundWebhookView.as_view(), 39 | name="brevo_inbound_webhook", 40 | ), 41 | path( 42 | "mailersend/inbound/", 43 | MailerSendInboundWebhookView.as_view(), 44 | name="mailersend_inbound_webhook", 45 | ), 46 | re_path( 47 | # Mailgun delivers inbound messages differently based on whether 48 | # the webhook url contains "mime" (anywhere). You can use either 49 | # ".../mailgun/inbound/" or ".../mailgun/inbound_mime/" depending 50 | # on the behavior you want. 51 | r"^mailgun/inbound(_mime)?/$", 52 | MailgunInboundWebhookView.as_view(), 53 | name="mailgun_inbound_webhook", 54 | ), 55 | path( 56 | "mailjet/inbound/", 57 | MailjetInboundWebhookView.as_view(), 58 | name="mailjet_inbound_webhook", 59 | ), 60 | path( 61 | "postal/inbound/", 62 | PostalInboundWebhookView.as_view(), 63 | name="postal_inbound_webhook", 64 | ), 65 | path( 66 | "postmark/inbound/", 67 | PostmarkInboundWebhookView.as_view(), 68 | name="postmark_inbound_webhook", 69 | ), 70 | path( 71 | "sendgrid/inbound/", 72 | SendGridInboundWebhookView.as_view(), 73 | name="sendgrid_inbound_webhook", 74 | ), 75 | path( 76 | # Compatibility for old SendinBlue esp_name; use Brevo in new code 77 | "sendinblue/inbound/", 78 | SendinBlueInboundWebhookView.as_view(), 79 | name="sendinblue_inbound_webhook", 80 | ), 81 | path( 82 | "sparkpost/inbound/", 83 | SparkPostInboundWebhookView.as_view(), 84 | name="sparkpost_inbound_webhook", 85 | ), 86 | path( 87 | "amazon_ses/tracking/", 88 | AmazonSESTrackingWebhookView.as_view(), 89 | name="amazon_ses_tracking_webhook", 90 | ), 91 | path( 92 | "brevo/tracking/", 93 | BrevoTrackingWebhookView.as_view(), 94 | name="brevo_tracking_webhook", 95 | ), 96 | path( 97 | "mailersend/tracking/", 98 | MailerSendTrackingWebhookView.as_view(), 99 | name="mailersend_tracking_webhook", 100 | ), 101 | path( 102 | "mailgun/tracking/", 103 | MailgunTrackingWebhookView.as_view(), 104 | name="mailgun_tracking_webhook", 105 | ), 106 | path( 107 | "mailjet/tracking/", 108 | MailjetTrackingWebhookView.as_view(), 109 | name="mailjet_tracking_webhook", 110 | ), 111 | path( 112 | "postal/tracking/", 113 | PostalTrackingWebhookView.as_view(), 114 | name="postal_tracking_webhook", 115 | ), 116 | path( 117 | "postmark/tracking/", 118 | PostmarkTrackingWebhookView.as_view(), 119 | name="postmark_tracking_webhook", 120 | ), 121 | path( 122 | "resend/tracking/", 123 | ResendTrackingWebhookView.as_view(), 124 | name="resend_tracking_webhook", 125 | ), 126 | path( 127 | "sendgrid/tracking/", 128 | SendGridTrackingWebhookView.as_view(), 129 | name="sendgrid_tracking_webhook", 130 | ), 131 | path( 132 | # Compatibility for old SendinBlue esp_name; use Brevo in new code 133 | "sendinblue/tracking/", 134 | SendinBlueTrackingWebhookView.as_view(), 135 | name="sendinblue_tracking_webhook", 136 | ), 137 | path( 138 | "sparkpost/tracking/", 139 | SparkPostTrackingWebhookView.as_view(), 140 | name="sparkpost_tracking_webhook", 141 | ), 142 | path( 143 | "unisender_go/tracking/", 144 | UnisenderGoTrackingWebhookView.as_view(), 145 | name="unisender_go_tracking_webhook", 146 | ), 147 | # Anymail uses a combined Mandrill webhook endpoint, 148 | # to simplify Mandrill's key-validation scheme: 149 | path("mandrill/", MandrillCombinedWebhookView.as_view(), name="mandrill_webhook"), 150 | # This url is maintained for backwards compatibility with earlier Anymail releases: 151 | path( 152 | "mandrill/tracking/", 153 | MandrillCombinedWebhookView.as_view(), 154 | name="mandrill_tracking_webhook", 155 | ), 156 | ] 157 | -------------------------------------------------------------------------------- /anymail/webhooks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anymail/django-anymail/29c446ff645169ce832a0263aa9baca938a46809/anymail/webhooks/__init__.py -------------------------------------------------------------------------------- /anymail/webhooks/base.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from django.http import HttpResponse 4 | from django.utils.crypto import constant_time_compare 5 | from django.utils.decorators import method_decorator 6 | from django.views.decorators.csrf import csrf_exempt 7 | from django.views.generic import View 8 | 9 | from ..exceptions import AnymailInsecureWebhookWarning, AnymailWebhookValidationFailure 10 | from ..utils import collect_all_methods, get_anymail_setting, get_request_basic_auth 11 | 12 | 13 | # Mixin note: Django's View.__init__ doesn't cooperate with chaining, 14 | # so all mixins that need __init__ must appear before View in MRO. 15 | class AnymailCoreWebhookView(View): 16 | """Common view for processing ESP event webhooks 17 | 18 | ESP-specific implementations will need to implement parse_events. 19 | 20 | ESP-specific implementations should generally subclass 21 | AnymailBaseWebhookView instead, to pick up basic auth. 22 | They may also want to implement validate_request 23 | if additional security is available. 24 | """ 25 | 26 | def __init__(self, **kwargs): 27 | super().__init__(**kwargs) 28 | self.validators = collect_all_methods(self.__class__, "validate_request") 29 | 30 | # Subclass implementation: 31 | 32 | # Where to send events: either ..signals.inbound or ..signals.tracking 33 | signal = None 34 | 35 | def validate_request(self, request): 36 | """Check validity of webhook post, or raise AnymailWebhookValidationFailure. 37 | 38 | AnymailBaseWebhookView includes basic auth validation. 39 | Subclasses can implement (or provide via mixins) if the ESP supports 40 | additional validation (such as signature checking). 41 | 42 | *All* definitions of this method in the class chain (including mixins) 43 | will be called. There is no need to chain to the superclass. 44 | (See self.run_validators and collect_all_methods.) 45 | 46 | Security note: use django.utils.crypto.constant_time_compare for string 47 | comparisons, to avoid exposing your validation to a timing attack. 48 | """ 49 | # if not constant_time_compare(request.POST['signature'], expected_signature): 50 | # raise AnymailWebhookValidationFailure("...message...") 51 | # (else just do nothing) 52 | pass 53 | 54 | def parse_events(self, request): 55 | """Return a list of normalized AnymailWebhookEvent extracted from ESP post data. 56 | 57 | Subclasses must implement. 58 | """ 59 | raise NotImplementedError() 60 | 61 | # HTTP handlers (subclasses shouldn't need to override): 62 | 63 | http_method_names = ["post", "head", "options"] 64 | 65 | @method_decorator(csrf_exempt) 66 | def dispatch(self, request, *args, **kwargs): 67 | return super().dispatch(request, *args, **kwargs) 68 | 69 | def head(self, request, *args, **kwargs): 70 | # Some ESPs verify the webhook with a HEAD request at configuration time 71 | return HttpResponse() 72 | 73 | def post(self, request, *args, **kwargs): 74 | # Normal Django exception handling will do the right thing: 75 | # - AnymailWebhookValidationFailure will turn into an HTTP 400 response 76 | # (via Django SuspiciousOperation handling) 77 | # - Any other errors (e.g., in signal dispatch) will turn into HTTP 500 78 | # responses (via normal Django error handling). ESPs generally 79 | # treat that as "try again later". 80 | self.run_validators(request) 81 | events = self.parse_events(request) 82 | esp_name = self.esp_name 83 | for event in events: 84 | self.signal.send(sender=self.__class__, event=event, esp_name=esp_name) 85 | return HttpResponse() 86 | 87 | # Request validation (subclasses shouldn't need to override): 88 | 89 | def run_validators(self, request): 90 | for validator in self.validators: 91 | validator(self, request) 92 | 93 | @property 94 | def esp_name(self): 95 | """ 96 | Read-only name of the ESP for this webhook view. 97 | 98 | Subclasses must override with class attr. E.g.: 99 | esp_name = "Postmark" 100 | esp_name = "SendGrid" # (use ESP's preferred capitalization) 101 | """ 102 | raise NotImplementedError( 103 | "%s.%s must declare esp_name class attr" 104 | % (self.__class__.__module__, self.__class__.__name__) 105 | ) 106 | 107 | 108 | class AnymailBasicAuthMixin(AnymailCoreWebhookView): 109 | """Implements webhook basic auth as mixin to AnymailCoreWebhookView.""" 110 | 111 | # Whether to warn if basic auth is not configured. 112 | # For most ESPs, basic auth is the only webhook security, 113 | # so the default is True. Subclasses can set False if 114 | # they enforce other security (like signed webhooks). 115 | warn_if_no_basic_auth = True 116 | 117 | # List of allowable HTTP basic-auth 'user:pass' strings. 118 | # (Declaring class attr allows override by kwargs in View.as_view.): 119 | basic_auth = None 120 | 121 | def __init__(self, **kwargs): 122 | self.basic_auth = get_anymail_setting( 123 | "webhook_secret", 124 | default=[], 125 | # no esp_name -- auth is shared between ESPs 126 | kwargs=kwargs, 127 | ) 128 | 129 | # Allow a single string: 130 | if isinstance(self.basic_auth, str): 131 | self.basic_auth = [self.basic_auth] 132 | if self.warn_if_no_basic_auth and len(self.basic_auth) < 1: 133 | warnings.warn( 134 | "Your Anymail webhooks are insecure and open to anyone on the web. " 135 | "You should set WEBHOOK_SECRET in your ANYMAIL settings. " 136 | "See 'Securing webhooks' in the Anymail docs.", 137 | AnymailInsecureWebhookWarning, 138 | ) 139 | super().__init__(**kwargs) 140 | 141 | def validate_request(self, request): 142 | """If configured for webhook basic auth, validate request has correct auth.""" 143 | if self.basic_auth: 144 | request_auth = get_request_basic_auth(request) 145 | # Use constant_time_compare to avoid timing attack on basic auth. (It's OK 146 | # that any() can terminate early: we're not trying to protect how many auth 147 | # strings are allowed, just the contents of each individual auth string.) 148 | auth_ok = any( 149 | constant_time_compare(request_auth, allowed_auth) 150 | for allowed_auth in self.basic_auth 151 | ) 152 | if not auth_ok: 153 | raise AnymailWebhookValidationFailure( 154 | "Missing or invalid basic auth in Anymail %s webhook" 155 | % self.esp_name 156 | ) 157 | 158 | 159 | class AnymailBaseWebhookView(AnymailBasicAuthMixin, AnymailCoreWebhookView): 160 | """ 161 | Abstract base class for most webhook views, enforcing HTTP basic auth security 162 | """ 163 | 164 | pass 165 | -------------------------------------------------------------------------------- /anymail/webhooks/sendinblue.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from ..exceptions import AnymailDeprecationWarning 4 | from .brevo import BrevoInboundWebhookView, BrevoTrackingWebhookView 5 | 6 | 7 | class SendinBlueTrackingWebhookView(BrevoTrackingWebhookView): 8 | """ 9 | Deprecated compatibility tracking webhook for old Brevo name "SendinBlue". 10 | """ 11 | 12 | esp_name = "SendinBlue" 13 | 14 | def __init__(self, **kwargs): 15 | warnings.warn( 16 | "Anymail's SendinBlue webhook URLs are deprecated." 17 | " Update your Brevo transactional email webhook URL to change" 18 | " 'anymail/sendinblue' to 'anymail/brevo'.", 19 | AnymailDeprecationWarning, 20 | ) 21 | super().__init__(**kwargs) 22 | 23 | 24 | class SendinBlueInboundWebhookView(BrevoInboundWebhookView): 25 | """ 26 | Deprecated compatibility inbound webhook for old Brevo name "SendinBlue". 27 | """ 28 | 29 | esp_name = "SendinBlue" 30 | 31 | def __init__(self, **kwargs): 32 | warnings.warn( 33 | "Anymail's SendinBlue webhook URLs are deprecated." 34 | " Update your Brevo inbound webhook URL to change" 35 | " 'anymail/sendinblue' to 'anymail/brevo'.", 36 | AnymailDeprecationWarning, 37 | ) 38 | super().__init__(**kwargs) 39 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Djrill.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Djrill.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Djrill" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Djrill" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /docs/_readme/render.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Render a README file (roughly) as it would appear on PyPI 3 | 4 | import argparse 5 | import sys 6 | from importlib.metadata import PackageNotFoundError, metadata 7 | from pathlib import Path 8 | from typing import Dict, Optional 9 | 10 | import readme_renderer.rst 11 | from docutils.core import publish_string 12 | from docutils.utils import SystemMessage 13 | 14 | # Docutils template.txt in our directory: 15 | DEFAULT_TEMPLATE_FILE = Path(__file__).with_name("template.txt").absolute() 16 | 17 | 18 | def get_package_readme(package: str) -> str: 19 | # Note: "description" was added to metadata in Python 3.10 20 | return metadata(package)["description"] 21 | 22 | 23 | class ReadMeHTMLWriter(readme_renderer.rst.Writer): 24 | translator_class = readme_renderer.rst.ReadMeHTMLTranslator 25 | 26 | def interpolation_dict(self) -> Dict[str, str]: 27 | result = super().interpolation_dict() 28 | # clean the same parts as readme_renderer.rst.render: 29 | clean = readme_renderer.rst.clean 30 | result["docinfo"] = clean(result["docinfo"]) 31 | result["body"] = result["fragment"] = clean(result["fragment"]) 32 | return result 33 | 34 | 35 | def render(source_text: str, warning_stream=sys.stderr) -> Optional[str]: 36 | # Adapted from readme_renderer.rst.render 37 | settings = readme_renderer.rst.SETTINGS.copy() 38 | settings.update( 39 | { 40 | "warning_stream": warning_stream, 41 | "template": DEFAULT_TEMPLATE_FILE, 42 | # Input and output are text str (we handle decoding/encoding): 43 | "input_encoding": "unicode", 44 | "output_encoding": "unicode", 45 | # Exit with error on docutils warning or above. 46 | # (There's discussion of having readme_renderer ignore warnings; 47 | # this ensures they'll be treated as errors here.) 48 | "halt_level": 2, # (docutils.utils.Reporter.WARNING_LEVEL) 49 | # Report all docutils warnings or above. 50 | # (The readme_renderer default suppresses this output.) 51 | "report_level": 2, # (docutils.utils.Reporter.WARNING_LEVEL) 52 | } 53 | ) 54 | 55 | writer = ReadMeHTMLWriter() 56 | 57 | try: 58 | return publish_string( 59 | source_text, 60 | writer=writer, 61 | settings_overrides=settings, 62 | ) 63 | except SystemMessage: 64 | warning_stream.write("Error rendering readme source.\n") 65 | return None 66 | 67 | 68 | def main(argv=None): 69 | parser = argparse.ArgumentParser( 70 | description="Render readme file as it would appear on PyPI" 71 | ) 72 | input_group = parser.add_mutually_exclusive_group(required=True) 73 | input_group.add_argument( 74 | "-p", "--package", help="Source readme from package's metadata" 75 | ) 76 | input_group.add_argument( 77 | "-i", 78 | "--input", 79 | help="Source readme.rst file ('-' for stdin)", 80 | type=argparse.FileType("r"), 81 | ) 82 | parser.add_argument( 83 | "-o", 84 | "--output", 85 | help="Output file (default: stdout)", 86 | type=argparse.FileType("w"), 87 | default="-", 88 | ) 89 | 90 | args = parser.parse_args(argv) 91 | if args.package: 92 | try: 93 | source_text = get_package_readme(args.package) 94 | except PackageNotFoundError: 95 | print(f"Package not installed: {args.package!r}", file=sys.stderr) 96 | sys.exit(2) 97 | if source_text is None: 98 | print(f"No metadata readme for {args.package!r}", file=sys.stderr) 99 | sys.exit(2) 100 | else: 101 | source_text = args.input.read() 102 | rendered = render(source_text) 103 | if rendered is None: 104 | sys.exit(2) 105 | args.output.write(rendered) 106 | 107 | 108 | if __name__ == "__main__": 109 | main() 110 | -------------------------------------------------------------------------------- /docs/_readme/template.txt: -------------------------------------------------------------------------------- 1 | %(head_prefix)s 2 | 15 | 16 | 17 | %(head)s 18 | 19 | 20 | 21 | 22 | 23 | 26 | 27 | 28 | %(body_prefix)s 29 | 30 |
31 | 32 | 40 | 41 |
42 |
43 |
44 |
45 |
46 |

Project description

47 | 48 |
49 | %(body)s 50 |
51 |
52 |
53 |
54 |
55 |
56 | 57 |
58 | %(body_suffix)s 59 | -------------------------------------------------------------------------------- /docs/_static/anymail-config.js: -------------------------------------------------------------------------------- 1 | window.RATETHEDOCS_OPTIONS = { 2 | contactLink: "/help/#contact", 3 | privacyLink: "/docs_privacy/", 4 | }; 5 | -------------------------------------------------------------------------------- /docs/_static/anymail-theme.css: -------------------------------------------------------------------------------- 1 | /* Anymail modifications to sphinx-rtd-theme styles */ 2 | 3 | /* Sticky table first column (used for ESP feature matrix) */ 4 | table.sticky-left td:first-of-type, 5 | table.sticky-left th:first-of-type { 6 | position: sticky; 7 | left: 0; 8 | background-color: #fcfcfc; /* override transparent from .wy-table td */ 9 | } 10 | table.sticky-left td:first-of-type[colspan] > p, 11 | table.sticky-left th:first-of-type[colspan] > p { 12 | /* Hack: the full-width section headers can't stick left; 13 | since those always wrap a rubric

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

sticky within the . */ 15 | display: inline-block; 16 | position: sticky; 17 | left: 17px; /* (.wy-table $table-padding-size) + (docutils border="1" in html) */ 18 | } 19 | 20 | /* Fix footnote stacking in sticky table */ 21 | .rst-content .citation-reference, 22 | .rst-content .footnote-reference { 23 | /* Original (but `position: relative` creates a new stacking context): 24 | vertical-align: baseline; 25 | position: relative; 26 | top: -.4em; 27 | */ 28 | vertical-align: 0.4em; 29 | position: static; 30 | top: initial; /* (not relevant with `position: static`) */ 31 | } 32 | 33 | /* Show code cross-reference links as clickable $link-color (blue). 34 | 35 | Sphinx-rtd-theme usually wants `.rst-content a code` to be $link-color [1], but has 36 | a more specific rule setting `.rst-content a code.xref` to $text-codexref-color, 37 | bold [2]. And $text-codexref-color is $text-color (black). 38 | 39 | This makes code.xref's inside an use standard link coloring instead. 40 | 41 | [1]: https://github.com/readthedocs/sphinx_rtd_theme/blob/2.0.0/src/sass/_theme_rst.sass#L484 42 | [2]: https://github.com/readthedocs/sphinx_rtd_theme/blob/2.0.0/src/sass/_theme_rst.sass#L477 43 | 44 | Related: https://github.com/readthedocs/sphinx_rtd_theme/issues/153 45 | https://github.com/readthedocs/sphinx_rtd_theme/issues/92 46 | */ 47 | .rst-content a code.xref { 48 | color: inherit; 49 | /*font-weight: inherit;*/ 50 | } 51 | .rst-content a:hover code.xref { 52 | color: inherit; 53 | } 54 | .rst-content a:visited code.xref { 55 | color: inherit; 56 | } 57 | 58 | /* Inline search forms (Anymail addition) */ 59 | .anymail-inline-search-form { 60 | margin-top: -1em; 61 | margin-bottom: 1em; 62 | } 63 | .anymail-inline-search-form input[type="search"] { 64 | width: 280px; 65 | max-width: 100%; 66 | border-radius: 50px; 67 | padding: 6px 12px; 68 | } 69 | 70 | /* Improve list item spacing in "open" lists. 71 | https://github.com/readthedocs/sphinx_rtd_theme/issues/1555 72 | 73 | Undoes this rule in non-.simple lists: 74 | https://github.com/readthedocs/sphinx_rtd_theme/blob/2.0.0/src/sass/_theme_rst.sass#L174-L175 75 | */ 76 | .rst-content .section ol:not(.simple) > li > p:only-child, 77 | .rst-content .section ol:not(.simple) > li > p:only-child:last-child, 78 | .rst-content .section ul:not(.simple) > li > p:only-child, 79 | .rst-content .section ul:not(.simple) > li > p:only-child:last-child, 80 | .rst-content section ol:not(.simple) > li > p:only-child, 81 | .rst-content section ol:not(.simple) > li > p:only-child:last-child, 82 | .rst-content section ul:not(.simple) > li > p:only-child, 83 | .rst-content section ul:not(.simple) > li > p:only-child:last-child { 84 | margin-bottom: 12px; 85 | } 86 | -------------------------------------------------------------------------------- /docs/_static/table-formatting.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Return the first sibling of el that matches CSS selector, or null if no matches. 3 | * @param {HTMLElement} el 4 | * @param {string} selector 5 | * @returns {HTMLElement|null} 6 | */ 7 | function nextSiblingMatching(el, selector) { 8 | while (el && el.nextElementSibling) { 9 | el = el.nextElementSibling; 10 | if (el.matches(selector)) { 11 | return el; 12 | } 13 | } 14 | return null; 15 | } 16 | 17 | /** 18 | * Convert runs of empty elements to a colspan on the first . 19 | */ 20 | function collapseEmptyTableCells() { 21 | document.querySelectorAll(".rst-content tr:has(td:empty)").forEach((tr) => { 22 | for ( 23 | let spanStart = tr.querySelector("td"); 24 | spanStart; 25 | spanStart = nextSiblingMatching(spanStart, "td") 26 | ) { 27 | let emptyCell; 28 | while ((emptyCell = nextSiblingMatching(spanStart, "td:empty"))) { 29 | emptyCell.remove(); 30 | spanStart.colSpan++; 31 | } 32 | } 33 | }); 34 | } 35 | 36 | if (document.readyState === "loading") { 37 | document.addEventListener("DOMContentLoaded", collapseEmptyTableCells); 38 | } else { 39 | collapseEmptyTableCells(); 40 | } 41 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. _changelog: 2 | .. _release_notes: 3 | 4 | .. include:: ../CHANGELOG.rst 5 | -------------------------------------------------------------------------------- /docs/docs_privacy.rst: -------------------------------------------------------------------------------- 1 | Anymail documentation privacy 2 | ============================= 3 | 4 | Anymail's documentation site at `anymail.dev`_ is hosted by 5 | **Read the Docs**. Please see the `Read the Docs Privacy Policy`_ for more 6 | about what information Read the Docs collects and how they use it. 7 | 8 | Separately, Anymail's maintainers have configured **Google Analytics** 9 | third-party tracking on this documentation site. We (Anymail's maintainers) 10 | use this analytics data to better understand how these docs are used, for 11 | the purpose of improving the content. Google Analytics helps us answer 12 | questions like: 13 | 14 | * what docs pages are most and least viewed 15 | * what terms people search for in the documentation 16 | * what paths readers (in general) tend to take through the docs 17 | 18 | But we're *not* able to identify any particular person or track individual 19 | behavior. Anymail's maintainers *do not* collect or have access to any 20 | personally identifiable (or even *potentially* personally identifiable) 21 | information about visitors to this documentation site. 22 | 23 | We also use Google Analytics to collect feedback from the "Is this page helpful?" 24 | box at the bottom of the page. Please do not include any personally-identifiable 25 | information in suggestions you submit through this form. 26 | (If you would like to contact Anymail's maintainers, see :ref:`contact`.) 27 | 28 | Anymail's maintainers have *not* connected our Google Analytics implementation 29 | to any Google Advertising Services. (Incidentally, we're not involved with the 30 | ads you may see here. Those come from---and support---Read the Docs under 31 | their `ethical ads`_ model.) 32 | 33 | The developer audience for Anymail's docs is likely already familiar 34 | with site analytics, tracking cookies, and related concepts. To learn more 35 | about how Google Analytics uses **cookies** and how to **opt out** of 36 | analytics tracking, see the "Information for Visitors of Sites and Apps Using 37 | Google Analytics" section of Google's `Safeguarding your data`_ document. 38 | 39 | Questions about privacy and information practices related to this Anymail 40 | documentation site can be emailed to *privacy\anymail\dev*. 41 | (This is not an appropriate contact for questions about *using* Anymail; 42 | see :ref:`help` if you need assistance with your code.) 43 | 44 | 45 | .. _anymail.dev: 46 | https://anymail.dev/ 47 | .. _Read the Docs Privacy Policy: 48 | https://docs.readthedocs.io/en/latest/privacy-policy.html 49 | .. _Safeguarding your data: 50 | https://support.google.com/analytics/answer/6004245 51 | .. _ethical ads: 52 | https://docs.readthedocs.io/en/latest/ethical-advertising.html 53 | -------------------------------------------------------------------------------- /docs/docutils.conf: -------------------------------------------------------------------------------- 1 | [general] 2 | footnote_backlinks: false 3 | trim_footnote_reference_space: true 4 | 5 | [html writers] 6 | footnote_references: superscript 7 | -------------------------------------------------------------------------------- /docs/esps/esp-feature-matrix.csv: -------------------------------------------------------------------------------- 1 | Email Service Provider,:ref:`amazon-ses-backend`,:ref:`brevo-backend`,:ref:`mailersend-backend`,:ref:`mailgun-backend`,:ref:`mailjet-backend`,:ref:`mandrill-backend`,:ref:`postal-backend`,:ref:`postmark-backend`,:ref:`resend-backend`,:ref:`sendgrid-backend`,:ref:`sparkpost-backend`,:ref:`unisender-go-backend` 2 | .. rubric:: :ref:`Anymail send options `,,,,,,,,,,,, 3 | :attr:`~AnymailMessage.envelope_sender`,Yes,No,No,Domain only,Yes,Domain only,Yes,No,No,No,Yes,No 4 | :attr:`~AnymailMessage.merge_headers`,Yes [#caveats]_,Yes,No,Yes,Yes,No,No,Yes,Yes,Yes,Yes [#caveats]_,Yes [#caveats]_ 5 | :attr:`~AnymailMessage.metadata`,Yes,Yes,No,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes,Yes 6 | :attr:`~AnymailMessage.merge_metadata`,Yes [#caveats]_,Yes,No,Yes,Yes,Yes,No,Yes,Yes,Yes,Yes,Yes 7 | :attr:`~AnymailMessage.send_at`,No,Yes,Yes,Yes,No,Yes,No,No,Yes,Yes,Yes,Yes 8 | :attr:`~AnymailMessage.tags`,Yes,Yes,Yes,Yes,Max 1 tag,Yes,Max 1 tag,Max 1 tag,Yes,Yes,Max 1 tag,Yes 9 | :attr:`~AnymailMessage.track_clicks`,No [#nocontrol]_,No [#nocontrol]_,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes 10 | :attr:`~AnymailMessage.track_opens`,No [#nocontrol]_,No [#nocontrol]_,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes 11 | :ref:`amp-email`,Yes,No,No,Yes,No,No,No,No,No,Yes,Yes,Yes 12 | .. rubric:: :ref:`templates-and-merge`,,,,,,,,,,,, 13 | :attr:`~AnymailMessage.template_id`,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes 14 | :attr:`~AnymailMessage.merge_data`,Yes [#caveats]_,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes 15 | :attr:`~AnymailMessage.merge_global_data`,Yes [#caveats]_,Yes,Yes,Yes,Yes,Yes,No,Yes,No,Yes,Yes,Yes 16 | .. rubric:: :ref:`Status ` and :ref:`event tracking `,,,,,,,,,,,, 17 | :attr:`~AnymailMessage.anymail_status`,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes 18 | :class:`~anymail.signals.AnymailTrackingEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes 19 | .. rubric:: :ref:`Inbound handling `,,,,,,,,,,,, 20 | :class:`~anymail.signals.AnymailInboundEvent` from webhooks,Yes,Yes,Yes,Yes,Yes,Yes,Yes,Yes,No,Yes,Yes,No 21 | -------------------------------------------------------------------------------- /docs/esps/index.rst: -------------------------------------------------------------------------------- 1 | .. _supported-esps: 2 | 3 | Supported ESPs 4 | ============== 5 | 6 | Anymail currently supports these Email Service Providers. 7 | Click an ESP's name for specific Anymail settings required, 8 | and notes about any quirks or limitations: 9 | 10 | .. these are listed in alphabetical order 11 | 12 | .. toctree:: 13 | :maxdepth: 1 14 | 15 | amazon_ses 16 | brevo 17 | mailersend 18 | mailgun 19 | mailjet 20 | mandrill 21 | postal 22 | postmark 23 | resend 24 | sendgrid 25 | sparkpost 26 | unisender_go 27 | 28 | 29 | Anymail feature support 30 | ----------------------- 31 | 32 | The table below summarizes the Anymail features supported for each ESP. 33 | (Scroll it to the left and right to see all ESPs.) 34 | 35 | .. currentmodule:: anymail.message 36 | 37 | .. It's much easier to edit esp-feature-matrix.csv with a CSV-aware editor, such as: 38 | .. PyCharm (Pro has native CSV support; use a CSV editor plugin with Community) 39 | .. VSCode with a CSV editor extension 40 | .. Excel (watch out for charset issues), Apple Numbers, or Google Sheets 41 | .. Every row must have the same number of columns. If you add a column, you must 42 | .. also add a comma to each sub-heading row. (A CSV editor should handle this for you.) 43 | .. Please keep columns sorted alphabetically by ESP name. 44 | 45 | .. csv-table:: 46 | :file: esp-feature-matrix.csv 47 | :header-rows: 1 48 | :widths: auto 49 | :class: sticky-left 50 | 51 | .. [#caveats] 52 | Some restrictions apply---see the ESP detail page 53 | (usually under "Limitations and Quirks"). 54 | 55 | .. [#nocontrol] 56 | The ESP supports tracking, but Anymail can't enable/disable it 57 | for individual messages. See the ESP detail page for more information. 58 | 59 | Trying to choose an ESP? Please **don't** start with this table. It's far more 60 | important to consider things like an ESP's deliverability stats, latency, uptime, 61 | and support for developers. The *number* of extra features an ESP offers is almost 62 | meaningless. (And even specific features don't matter if you don't plan to use them.) 63 | 64 | 65 | Other ESPs 66 | ---------- 67 | 68 | Don't see your favorite ESP here? Anymail is designed to be extensible. 69 | You can suggest that Anymail add an ESP, or even contribute 70 | your own implementation to Anymail. See :ref:`contributing`. 71 | -------------------------------------------------------------------------------- /docs/esps/postal.rst: -------------------------------------------------------------------------------- 1 | .. _postal-backend: 2 | 3 | Postal 4 | ======== 5 | 6 | Anymail integrates with the `Postal`_ self-hosted transactional email platform, 7 | using their `HTTP email API`_. 8 | 9 | .. _Postal: https://docs.postalserver.io/ 10 | .. _HTTP email API: https://docs.postalserver.io/developer/api 11 | 12 | 13 | Settings 14 | -------- 15 | 16 | .. rubric:: EMAIL_BACKEND 17 | 18 | To use Anymail's Postal backend, set: 19 | 20 | .. code-block:: python 21 | 22 | EMAIL_BACKEND = "anymail.backends.postal.EmailBackend" 23 | 24 | in your settings.py. 25 | 26 | 27 | .. setting:: ANYMAIL_POSTAL_API_KEY 28 | 29 | .. rubric:: POSTAL_API_KEY 30 | 31 | Required. A Postal API key. 32 | 33 | .. code-block:: python 34 | 35 | ANYMAIL = { 36 | ... 37 | "POSTAL_API_KEY": "", 38 | } 39 | 40 | Anymail will also look for ``POSTAL_API_KEY`` at the 41 | root of the settings file if neither ``ANYMAIL["POSTAL_API_KEY"]`` 42 | nor ``ANYMAIL_POSTAL_API_KEY`` is set. 43 | 44 | 45 | .. setting:: ANYMAIL_POSTAL_API_URL 46 | 47 | .. rubric:: POSTAL_API_URL 48 | 49 | Required. The base URL of your Postal server (without /api/v1 or any API paths). 50 | Anymail will automatically append the required API paths. 51 | 52 | .. code-block:: python 53 | 54 | ANYMAIL = { 55 | ... 56 | "POSTAL_API_URL": "https://yourpostal.example.com", 57 | } 58 | 59 | 60 | .. setting:: ANYMAIL_POSTAL_WEBHOOK_KEY 61 | 62 | .. rubric:: POSTAL_WEBHOOK_KEY 63 | 64 | Required when using status tracking or inbound webhooks. 65 | 66 | This should be set to the public key of the Postal instance. 67 | You can find it by running `postal default-dkim-record` on your 68 | Postal instance. 69 | Use the part that comes after `p=`, until the semicolon at the end. 70 | 71 | 72 | .. _postal-esp-extra: 73 | 74 | esp_extra support 75 | ----------------- 76 | 77 | To use Postal features not directly supported by Anymail, you can 78 | set a message's :attr:`~anymail.message.AnymailMessage.esp_extra` to 79 | a `dict` that will be merged into the json sent to Postal's 80 | `email API`_. 81 | 82 | Example: 83 | 84 | .. code-block:: python 85 | 86 | message.esp_extra = { 87 | 'HypotheticalFuturePostalParam': '2022', # merged into send params 88 | } 89 | 90 | 91 | (You can also set `"esp_extra"` in Anymail's 92 | :ref:`global send defaults ` to apply it to all 93 | messages.) 94 | 95 | 96 | .. _email API: https://apiv1.postalserver.io/controllers/send/message 97 | 98 | 99 | Limitations and quirks 100 | ---------------------- 101 | 102 | Postal does not support a few tracking and reporting additions offered by other ESPs. 103 | 104 | Anymail normally raises an :exc:`~anymail.exceptions.AnymailUnsupportedFeature` 105 | error when you try to send a message using features that Postal doesn't support 106 | You can tell Anymail to suppress these errors and send the messages anyway -- 107 | see :ref:`unsupported-features`. 108 | 109 | **Single tag** 110 | Postal allows a maximum of one tag per message. If your message has two or more 111 | :attr:`~anymail.message.AnymailMessage.tags`, you'll get an 112 | :exc:`~anymail.exceptions.AnymailUnsupportedFeature` error---or 113 | if you've enabled :setting:`ANYMAIL_IGNORE_UNSUPPORTED_FEATURES`, 114 | Anymail will use only the first tag. 115 | 116 | **No delayed sending** 117 | Postal does not support :attr:`~anymail.message.AnymailMessage.send_at`. 118 | 119 | **Toggle click-tracking and open-tracking** 120 | By default, Postal does not enable click-tracking and open-tracking. 121 | To enable it, `see their docs on click- & open-tracking`_. 122 | Anymail's :attr:`~anymail.message.AnymailMessage.track_clicks` and 123 | :attr:`~anymail.message.AnymailMessage.track_opens` settings are unsupported. 124 | 125 | .. _see their docs on click- & open-tracking: https://docs.postalserver.io/features/click-and-open-tracking 126 | 127 | **Attachments must be named** 128 | Postal issues an `AttachmentMissingName` error when trying to send an attachment without name. 129 | 130 | **No merge features** 131 | Because Postal does not support batch sending, Anymail's 132 | :attr:`~anymail.message.AnymailMessage.merge_headers`, 133 | :attr:`~anymail.message.AnymailMessage.merge_metadata`, 134 | and :attr:`~anymail.message.AnymailMessage.merge_data` 135 | are not supported. 136 | 137 | 138 | .. _postal-templates: 139 | 140 | Batch sending/merge and ESP templates 141 | ------------------------------------- 142 | 143 | Postal does not support batch sending or ESP templates. 144 | 145 | 146 | .. _postal-webhooks: 147 | 148 | Status tracking webhooks 149 | ------------------------ 150 | 151 | If you are using Anymail's normalized :ref:`status tracking `, set up 152 | a webhook in your Postal mail server settings, under Webhooks. The webhook URL is: 153 | 154 | :samp:`https://{yoursite.example.com}/anymail/postal/tracking/` 155 | 156 | * *yoursite.example.com* is your Django site 157 | 158 | Choose all the event types you want to receive. 159 | 160 | Postal signs its webhook payloads. You need to set :setting:`ANYMAIL_POSTAL_WEBHOOK_KEY`. 161 | 162 | If you use multiple Postal mail servers, you'll need to repeat entering the webhook 163 | settings for each of them. 164 | 165 | Postal will report these Anymail :attr:`~anymail.signals.AnymailTrackingEvent.event_type`\s: 166 | failed, bounced, deferred, queued, delivered, clicked. 167 | 168 | The event's :attr:`~anymail.signals.AnymailTrackingEvent.esp_event` field will be 169 | a `dict` of Postal's `webhook `_ data. 170 | 171 | .. _postal-inbound: 172 | 173 | Inbound webhook 174 | --------------- 175 | 176 | If you want to receive email from Postal through Anymail's normalized :ref:`inbound ` 177 | handling, follow Postal's guide to for receiving emails (Help > Receiving Emails) to create an 178 | incoming route. Then set up an `HTTP Endpoint`, pointing to Anymail's inbound webhook. 179 | 180 | The url will be: 181 | 182 | :samp:`https://{yoursite.example.com}/anymail/postal/inbound/` 183 | 184 | * *yoursite.example.com* is your Django site 185 | 186 | Set `Format` to `Delivered as the raw message`. 187 | 188 | You also need to set :setting:`ANYMAIL_POSTAL_WEBHOOK_KEY` to enable signature validation. 189 | -------------------------------------------------------------------------------- /docs/help.rst: -------------------------------------------------------------------------------- 1 | .. _help: 2 | 3 | Help 4 | ==== 5 | 6 | .. _contact: 7 | .. _support: 8 | 9 | Getting support 10 | --------------- 11 | 12 | Anymail is supported and maintained by the people who use it---like you! 13 | Our contributors volunteer their time (and most are not employees of any ESP). 14 | 15 | Here's how to contact the Anymail community: 16 | 17 | **"How do I...?"** 18 | 19 | .. raw:: html 20 | 21 |

28 | 29 | If searching the docs doesn't find an answer, 30 | ask a question in the GitHub `Anymail discussions`_ forum. 31 | 32 | **"I'm getting an error or unexpected behavior..."** 33 | 34 | First, try the :ref:`troubleshooting tips ` in the next section. 35 | If those don't help, ask a question in the GitHub `Anymail discussions`_ forum. 36 | Be sure to include: 37 | 38 | * which ESP you're using (Mailgun, SendGrid, etc.) 39 | * what versions of Anymail, Django, and Python you're running 40 | * the relevant portions of your code and settings 41 | * the text of any error messages 42 | * any exception stack traces 43 | * the results of your :ref:`troubleshooting ` (e.g., any relevant 44 | info from your ESP's activity log) 45 | * if it's something that was working before, when it last worked, 46 | and what (if anything) changed since then 47 | 48 | ... plus anything else you think might help someone understand what you're seeing. 49 | 50 | **"I found a bug..."** 51 | 52 | Open a `GitHub issue`_. Be sure to include the versions and other information listed above. 53 | (And if you know what the problem is, we always welcome 54 | :ref:`contributions ` with a fix!) 55 | 56 | **"I found a security issue!"** 57 | 58 | Contact the Anymail maintainers by emailing *security\anymail\dev.* 59 | (Please don't open a GitHub issue or post publicly about potential security problems.) 60 | 61 | **"Could Anymail support this ESP or feature...?"** 62 | 63 | If the idea has already been suggested in the GitHub `Anymail discussions`_ forum, 64 | express your support using GitHub's `thumbs up reaction`_. If not, add the idea 65 | as a new discussion topic. And either way, if you'd be able to help with development 66 | or testing, please add a comment saying so. 67 | 68 | 69 | .. _Anymail discussions: https://github.com/anymail/django-anymail/discussions 70 | .. _GitHub issue: https://github.com/anymail/django-anymail/issues 71 | .. _thumbs up reaction: 72 | https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/ 73 | 74 | 75 | .. _troubleshooting: 76 | 77 | Troubleshooting 78 | --------------- 79 | 80 | If Anymail's not behaving like you expect, these troubleshooting tips can 81 | often help you pinpoint the problem... 82 | 83 | **Check the error message** 84 | 85 | Look for an Anymail error message in your console (running Django in dev mode) 86 | or in your server error logs. If you see something like "invalid API key" 87 | or "invalid email address", that's often a big first step toward being able 88 | to solve the problem. 89 | 90 | **Check your ESPs API logs** 91 | 92 | Most ESPs offer some sort of API activity log in their dashboards. 93 | Check their logs to see if the 94 | data you thought you were sending actually made it to your ESP, and 95 | if they recorded any errors there. 96 | 97 | **Double-check common issues** 98 | 99 | * Did you add any required settings for your ESP to the `ANYMAIL` dict in your 100 | settings.py? (E.g., ``"SENDGRID_API_KEY"`` for SendGrid.) Check the instructions 101 | for the ESP you're using under :ref:`supported-esps`. 102 | * Did you add ``'anymail'`` to the list of :setting:`INSTALLED_APPS` in settings.py? 103 | * Are you using a valid *from* address? Django's default is "webmaster@localhost", 104 | which most ESPs reject. Either specify the ``from_email`` explicitly on every message 105 | you send, or add :setting:`DEFAULT_FROM_EMAIL` to your settings.py. 106 | 107 | **Try it without Anymail** 108 | 109 | If you think Anymail might be causing the problem, try switching your 110 | :setting:`EMAIL_BACKEND` setting to 111 | Django's :ref:`File backend ` and then running your 112 | email-sending code again. If that causes errors, you'll know the issue is somewhere 113 | other than Anymail. And you can look through the :setting:`EMAIL_FILE_PATH` 114 | file contents afterward to see if you're generating the email you want. 115 | 116 | **Examine the raw API communication** 117 | 118 | Sometimes you just want to see exactly what Anymail is telling your ESP to do 119 | and how your ESP is responding. In a dev environment, enable the Anymail setting 120 | :setting:`DEBUG_API_REQUESTS ` 121 | to show the raw HTTP requests and responses from (most) ESP APIs. (This is not 122 | recommended in production, as it can leak sensitive data into your logs.) 123 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Anymail: Django email integration for transactional ESPs 2 | ======================================================== 3 | 4 | Version |release| 5 | 6 | .. Incorporate the shared-intro section from the root README: 7 | 8 | .. include:: ../README.rst 9 | :start-after: _shared-intro: 10 | :end-before: END shared-intro 11 | 12 | 13 | .. _main-toc: 14 | 15 | Documentation 16 | ------------- 17 | 18 | .. toctree:: 19 | :maxdepth: 2 20 | :caption: Using Anymail 21 | 22 | quickstart 23 | installation 24 | sending/index 25 | inbound 26 | esps/index 27 | tips/index 28 | help 29 | 30 | .. toctree:: 31 | :maxdepth: 2 32 | :caption: About Anymail 33 | 34 | contributing 35 | changelog 36 | Docs privacy 37 | Source code (on GitHub) 38 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. linkcheck to check all external links for integrity 37 | echo. doctest to run all doctests embedded in the documentation if enabled 38 | goto end 39 | ) 40 | 41 | if "%1" == "clean" ( 42 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 43 | del /q /s %BUILDDIR%\* 44 | goto end 45 | ) 46 | 47 | if "%1" == "html" ( 48 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 49 | if errorlevel 1 exit /b 1 50 | echo. 51 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 52 | goto end 53 | ) 54 | 55 | if "%1" == "dirhtml" ( 56 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 57 | if errorlevel 1 exit /b 1 58 | echo. 59 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 60 | goto end 61 | ) 62 | 63 | if "%1" == "singlehtml" ( 64 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 68 | goto end 69 | ) 70 | 71 | if "%1" == "pickle" ( 72 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished; now you can process the pickle files. 76 | goto end 77 | ) 78 | 79 | if "%1" == "json" ( 80 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished; now you can process the JSON files. 84 | goto end 85 | ) 86 | 87 | if "%1" == "htmlhelp" ( 88 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can run HTML Help Workshop with the ^ 92 | .hhp project file in %BUILDDIR%/htmlhelp. 93 | goto end 94 | ) 95 | 96 | if "%1" == "qthelp" ( 97 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 98 | if errorlevel 1 exit /b 1 99 | echo. 100 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 101 | .qhcp project file in %BUILDDIR%/qthelp, like this: 102 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Djrill.qhcp 103 | echo.To view the help file: 104 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Djrill.ghc 105 | goto end 106 | ) 107 | 108 | if "%1" == "devhelp" ( 109 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 110 | if errorlevel 1 exit /b 1 111 | echo. 112 | echo.Build finished. 113 | goto end 114 | ) 115 | 116 | if "%1" == "epub" ( 117 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 118 | if errorlevel 1 exit /b 1 119 | echo. 120 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 121 | goto end 122 | ) 123 | 124 | if "%1" == "latex" ( 125 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 129 | goto end 130 | ) 131 | 132 | if "%1" == "text" ( 133 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The text files are in %BUILDDIR%/text. 137 | goto end 138 | ) 139 | 140 | if "%1" == "man" ( 141 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 145 | goto end 146 | ) 147 | 148 | if "%1" == "texinfo" ( 149 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 150 | if errorlevel 1 exit /b 1 151 | echo. 152 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 153 | goto end 154 | ) 155 | 156 | if "%1" == "gettext" ( 157 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 158 | if errorlevel 1 exit /b 1 159 | echo. 160 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 161 | goto end 162 | ) 163 | 164 | if "%1" == "changes" ( 165 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 166 | if errorlevel 1 exit /b 1 167 | echo. 168 | echo.The overview file is in %BUILDDIR%/changes. 169 | goto end 170 | ) 171 | 172 | if "%1" == "linkcheck" ( 173 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 174 | if errorlevel 1 exit /b 1 175 | echo. 176 | echo.Link check complete; look for any errors in the above output ^ 177 | or in %BUILDDIR%/linkcheck/output.txt. 178 | goto end 179 | ) 180 | 181 | if "%1" == "doctest" ( 182 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 183 | if errorlevel 1 exit /b 1 184 | echo. 185 | echo.Testing of doctests in the sources finished, look at the ^ 186 | results in %BUILDDIR%/doctest/output.txt. 187 | goto end 188 | ) 189 | 190 | :end 191 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | Anymail 1-2-3 2 | ============= 3 | 4 | .. Quickstart is maintained in README.rst at the source root. 5 | (Docs can include from the readme; the readme can't include anything.) 6 | 7 | .. include:: ../README.rst 8 | :start-after: _quickstart: 9 | :end-before: END quickstart 10 | 11 | 12 | Problems? We have some :ref:`troubleshooting` info that may help. 13 | 14 | 15 | .. rubric:: Now what? 16 | 17 | Now that you've got Anymail working, you might be interested in: 18 | 19 | * :ref:`Sending email with Anymail ` 20 | * :ref:`Receiving inbound email ` 21 | * :ref:`ESP-specific information ` 22 | * :ref:`All the docs ` 23 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # Packages required only for building docs 2 | 3 | Pygments~=2.18.0 4 | readme-renderer~=41.0 5 | sphinx~=7.4 6 | sphinx-rtd-theme~=2.0.0 7 | sphinxcontrib-googleanalytics~=0.4 8 | -------------------------------------------------------------------------------- /docs/sending/exceptions.rst: -------------------------------------------------------------------------------- 1 | .. _anymail-exceptions: 2 | 3 | Exceptions 4 | ---------- 5 | 6 | .. module:: anymail.exceptions 7 | 8 | .. exception:: AnymailUnsupportedFeature 9 | 10 | If the email tries to use features that aren't supported by the ESP, the send 11 | call will raise an :exc:`!AnymailUnsupportedFeature` error, and the message 12 | won't be sent. See :ref:`unsupported-features`. 13 | 14 | You can disable this exception (ignoring the unsupported features and 15 | sending the message anyway, without them) by setting 16 | :setting:`ANYMAIL_IGNORE_UNSUPPORTED_FEATURES` to `True`. 17 | 18 | 19 | .. exception:: AnymailRecipientsRefused 20 | 21 | Raised when *all* recipients (to, cc, bcc) of a message are invalid or rejected by 22 | your ESP *at send time.* See :ref:`recipients-refused`. 23 | 24 | You can disable this exception by setting :setting:`ANYMAIL_IGNORE_RECIPIENT_STATUS` 25 | to `True` in your settings.py, which will cause Anymail to treat any 26 | non-:exc:`AnymailAPIError` response from your ESP as a successful send. 27 | 28 | 29 | .. exception:: AnymailAPIError 30 | 31 | If the ESP's API fails or returns an error response, the send call will 32 | raise an :exc:`!AnymailAPIError`. 33 | 34 | The exception's :attr:`status_code` and :attr:`response` attributes may 35 | help explain what went wrong. (Tip: you may also be able to check the API log in 36 | your ESP's dashboard. See :ref:`troubleshooting`.) 37 | 38 | In production, it's not unusual for sends to occasionally fail due to transient 39 | connectivity problems, ESP maintenance, or other operational issues. Typically 40 | these failures have a 5xx :attr:`status_code`. See :ref:`transient-errors` 41 | for suggestions on retrying these failed sends. 42 | 43 | 44 | .. exception:: AnymailInvalidAddress 45 | 46 | The send call will raise a :exc:`!AnymailInvalidAddress` error if you 47 | attempt to send a message with invalidly-formatted email addresses in 48 | the :attr:`from_email` or recipient lists. 49 | 50 | One source of this error can be using a display-name ("real name") containing 51 | commas or parentheses. Per :rfc:`5322`, you should use double quotes around 52 | the display-name portion of an email address: 53 | 54 | .. code-block:: python 55 | 56 | # won't work: 57 | send_mail(from_email='Widgets, Inc. ', ...) 58 | # must use double quotes around display-name containing comma: 59 | send_mail(from_email='"Widgets, Inc." ', ...) 60 | 61 | 62 | .. exception:: AnymailSerializationError 63 | 64 | The send call will raise a :exc:`!AnymailSerializationError` 65 | if there are message attributes Anymail doesn't know how to represent 66 | to your ESP. 67 | 68 | The most common cause of this error is including values other than 69 | strings and numbers in your :attr:`merge_data` or :attr:`metadata`. 70 | (E.g., you need to format `Decimal` and `date` data to 71 | strings before setting them into :attr:`merge_data`.) 72 | 73 | See :ref:`formatting-merge-data` for more information. 74 | -------------------------------------------------------------------------------- /docs/sending/index.rst: -------------------------------------------------------------------------------- 1 | .. _sending-email: 2 | 3 | Sending email 4 | ------------- 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | 9 | django_email 10 | anymail_additions 11 | templates 12 | tracking 13 | signals 14 | exceptions 15 | -------------------------------------------------------------------------------- /docs/sending/signals.rst: -------------------------------------------------------------------------------- 1 | .. _signals: 2 | 3 | Pre- and post-send signals 4 | ========================== 5 | 6 | Anymail provides :ref:`pre-send ` and :ref:`post-send ` 7 | signals you can connect to trigger actions whenever messages are sent through an Anymail backend. 8 | 9 | Be sure to read Django's `listening to signals`_ docs for information on defining 10 | and connecting signal receivers. 11 | 12 | .. _listening to signals: 13 | https://docs.djangoproject.com/en/stable/topics/signals/#listening-to-signals 14 | 15 | 16 | .. _pre-send-signal: 17 | 18 | Pre-send signal 19 | --------------- 20 | 21 | You can use Anymail's :data:`~anymail.signals.pre_send` signal to examine 22 | or modify messages before they are sent. 23 | For example, you could implement your own email suppression list: 24 | 25 | .. code-block:: python 26 | 27 | from anymail.exceptions import AnymailCancelSend 28 | from anymail.signals import pre_send 29 | from django.dispatch import receiver 30 | from email.utils import parseaddr 31 | 32 | from your_app.models import EmailBlockList 33 | 34 | @receiver(pre_send) 35 | def filter_blocked_recipients(sender, message, **kwargs): 36 | # Cancel the entire send if the from_email is blocked: 37 | if not ok_to_send(message.from_email): 38 | raise AnymailCancelSend("Blocked from_email") 39 | # Otherwise filter the recipients before sending: 40 | message.to = [addr for addr in message.to if ok_to_send(addr)] 41 | message.cc = [addr for addr in message.cc if ok_to_send(addr)] 42 | 43 | def ok_to_send(addr): 44 | # This assumes you've implemented an EmailBlockList model 45 | # that holds emails you want to reject... 46 | name, email = parseaddr(addr) # just want the part 47 | try: 48 | EmailBlockList.objects.get(email=email) 49 | return False # in the blocklist, so *not* OK to send 50 | except EmailBlockList.DoesNotExist: 51 | return True # *not* in the blocklist, so OK to send 52 | 53 | Any changes you make to the message in your pre-send signal receiver 54 | will be reflected in the ESP send API call, as shown for the filtered 55 | "to" and "cc" lists above. Note that this will modify the original 56 | EmailMessage (not a copy)---be sure this won't confuse your sending 57 | code that created the message. 58 | 59 | If you want to cancel the message altogether, your pre-send receiver 60 | function can raise an :exc:`~anymail.signals.AnymailCancelSend` exception, 61 | as shown for the "from_email" above. This will silently cancel the send 62 | without raising any other errors. 63 | 64 | 65 | .. data:: anymail.signals.pre_send 66 | 67 | Signal delivered before each EmailMessage is sent. 68 | 69 | Your pre_send receiver must be a function with this signature: 70 | 71 | .. function:: def my_pre_send_handler(sender, message, esp_name, **kwargs): 72 | 73 | (You can name it anything you want.) 74 | 75 | :param class sender: 76 | The Anymail backend class processing the message. 77 | This parameter is required by Django's signal mechanism, 78 | and despite the name has nothing to do with the *email message's* sender. 79 | (You generally won't need to examine this parameter.) 80 | :param ~django.core.mail.EmailMessage message: 81 | The message being sent. If your receiver modifies the message, those 82 | changes will be reflected in the ESP send call. 83 | :param str esp_name: 84 | The name of the ESP backend in use (e.g., "SendGrid" or "Mailgun"). 85 | :param \**kwargs: 86 | Required by Django's signal mechanism (to support future extensions). 87 | :raises: 88 | :exc:`anymail.exceptions.AnymailCancelSend` if your receiver wants 89 | to cancel this message without causing errors or interrupting a batch send. 90 | 91 | 92 | 93 | .. _post-send-signal: 94 | 95 | Post-send signal 96 | ---------------- 97 | 98 | You can use Anymail's :data:`~anymail.signals.post_send` signal to examine 99 | messages after they are sent. This is useful to centralize handling of 100 | the :ref:`sent status ` for all messages. 101 | 102 | For example, you could implement your own ESP logging dashboard 103 | (perhaps combined with Anymail's :ref:`event-tracking webhooks `): 104 | 105 | .. code-block:: python 106 | 107 | from anymail.signals import post_send 108 | from django.dispatch import receiver 109 | 110 | from your_app.models import SentMessage 111 | 112 | @receiver(post_send) 113 | def log_sent_message(sender, message, status, esp_name, **kwargs): 114 | # This assumes you've implemented a SentMessage model for tracking sends. 115 | # status.recipients is a dict of email: status for each recipient 116 | for email, recipient_status in status.recipients.items(): 117 | SentMessage.objects.create( 118 | esp=esp_name, 119 | message_id=recipient_status.message_id, # might be None if send failed 120 | email=email, 121 | subject=message.subject, 122 | status=recipient_status.status, # 'sent' or 'rejected' or ... 123 | ) 124 | 125 | 126 | .. data:: anymail.signals.post_send 127 | 128 | Signal delivered after each EmailMessage is sent. 129 | 130 | If you register multiple post-send receivers, Anymail will ensure that 131 | all of them are called, even if one raises an error. 132 | 133 | Your post_send receiver must be a function with this signature: 134 | 135 | .. function:: def my_post_send_handler(sender, message, status, esp_name, **kwargs): 136 | 137 | (You can name it anything you want.) 138 | 139 | :param class sender: 140 | The Anymail backend class processing the message. 141 | This parameter is required by Django's signal mechanism, 142 | and despite the name has nothing to do with the *email message's* sender. 143 | (You generally won't need to examine this parameter.) 144 | :param ~django.core.mail.EmailMessage message: 145 | The message that was sent. You should not modify this in a post-send receiver. 146 | :param ~anymail.message.AnymailStatus status: 147 | The normalized response from the ESP send call. (Also available as 148 | :attr:`message.anymail_status `.) 149 | :param str esp_name: 150 | The name of the ESP backend in use (e.g., "SendGrid" or "Mailgun"). 151 | :param \**kwargs: 152 | Required by Django's signal mechanism (to support future extensions). 153 | -------------------------------------------------------------------------------- /docs/tips/django_templates.rst: -------------------------------------------------------------------------------- 1 | .. _django-templates: 2 | 3 | Using Django templates for email 4 | ================================ 5 | 6 | ESP's templating languages and merge capabilities are generally not compatible 7 | with each other, which can make it hard to move email templates between them. 8 | 9 | But since you're working in Django, you already have access to the 10 | extremely-full-featured :doc:`Django templating system `. 11 | You don't even have to use Django's template syntax: it supports other 12 | template languages (like Jinja2). 13 | 14 | You're probably already using Django's templating system for your HTML pages, 15 | so it can be an easy decision to use it for your email, too. 16 | 17 | To compose email using *Django* templates, you can use Django's 18 | :func:`~django.template.loader.render_to_string` 19 | template shortcut to build the body and html. 20 | 21 | Example that builds an email from the templates ``message_subject.txt``, 22 | ``message_body.txt`` and ``message_body.html``: 23 | 24 | .. code-block:: python 25 | 26 | from django.core.mail import EmailMultiAlternatives 27 | from django.template.loader import render_to_string 28 | 29 | merge_data = { 30 | 'ORDERNO': "12345", 'TRACKINGNO': "1Z987" 31 | } 32 | 33 | subject = render_to_string("message_subject.txt", merge_data).strip() 34 | text_body = render_to_string("message_body.txt", merge_data) 35 | html_body = render_to_string("message_body.html", merge_data) 36 | 37 | msg = EmailMultiAlternatives(subject=subject, from_email="store@example.com", 38 | to=["customer@example.com"], body=text_body) 39 | msg.attach_alternative(html_body, "text/html") 40 | msg.send() 41 | 42 | Tip: use Django's :ttag:`{% autoescape off %}` template tag in your 43 | plaintext ``.txt`` templates to avoid inappropriate HTML escaping. 44 | 45 | 46 | Helpful add-ons 47 | --------------- 48 | 49 | These (third-party) packages can be helpful for building your email 50 | in Django: 51 | 52 | * :pypi:`django-templated-mail`, :pypi:`django-mail-templated`, or :pypi:`django-mail-templated-simple` 53 | for building messages from sets of Django templates. 54 | * :pypi:`django-pony-express` for a class-based approach to building messages 55 | from a Django template. 56 | * :pypi:`emark` for building messages from Markdown. 57 | * :pypi:`premailer` for inlining css before sending 58 | * :pypi:`BeautifulSoup`, :pypi:`lxml`, or :pypi:`html2text` for auto-generating plaintext from your html 59 | -------------------------------------------------------------------------------- /docs/tips/index.rst: -------------------------------------------------------------------------------- 1 | Tips, tricks, and advanced usage 2 | -------------------------------- 3 | 4 | Some suggestions and recipes for getting things 5 | done with Anymail: 6 | 7 | .. toctree:: 8 | :maxdepth: 1 9 | 10 | transient_errors 11 | multiple_backends 12 | django_templates 13 | securing_webhooks 14 | testing 15 | performance 16 | -------------------------------------------------------------------------------- /docs/tips/multiple_backends.rst: -------------------------------------------------------------------------------- 1 | .. _multiple-backends: 2 | 3 | Mixing email backends 4 | ===================== 5 | 6 | Since you are replacing Django's global :setting:`EMAIL_BACKEND`, by default 7 | Anymail will handle **all** outgoing mail, sending everything through your ESP. 8 | 9 | You can use Django mail's optional :func:`connection ` 10 | argument to send some mail through your ESP and others through a different system. 11 | 12 | This could be useful, for example, to deliver customer emails with the ESP, 13 | but send admin emails directly through an SMTP server: 14 | 15 | .. code-block:: python 16 | :emphasize-lines: 8,10,13,15,19-20,22 17 | 18 | from django.core.mail import send_mail, get_connection 19 | 20 | # send_mail connection defaults to the settings EMAIL_BACKEND, which 21 | # we've set to Anymail's Mailgun EmailBackend. This will be sent using Mailgun: 22 | send_mail("Thanks", "We sent your order", "sales@example.com", ["customer@example.com"]) 23 | 24 | # Get a connection to an SMTP backend, and send using that instead: 25 | smtp_backend = get_connection('django.core.mail.backends.smtp.EmailBackend') 26 | send_mail("Uh-Oh", "Need your attention", "admin@example.com", ["alert@example.com"], 27 | connection=smtp_backend) 28 | 29 | # You can even use multiple Anymail backends in the same app: 30 | sendgrid_backend = get_connection('anymail.backends.sendgrid.EmailBackend') 31 | send_mail("Password reset", "Here you go", "noreply@example.com", ["user@example.com"], 32 | connection=sendgrid_backend) 33 | 34 | # You can override settings.py settings with kwargs to get_connection. 35 | # This example supplies credentials for a different Mailgun sub-acccount: 36 | alt_mailgun_backend = get_connection('anymail.backends.mailgun.EmailBackend', 37 | api_key=MAILGUN_API_KEY_FOR_MARKETING) 38 | send_mail("Here's that info", "you wanted", "info@marketing.example.com", ["prospect@example.org"], 39 | connection=alt_mailgun_backend) 40 | 41 | 42 | You can supply a different connection to Django's 43 | :func:`~django.core.mail.send_mail` and :func:`~django.core.mail.send_mass_mail` helpers, 44 | and in the constructor for an 45 | :class:`~django.core.mail.EmailMessage` or :class:`~django.core.mail.EmailMultiAlternatives`. 46 | 47 | 48 | (See the :class:`django.utils.log.AdminEmailHandler` docs for more information 49 | on Django's admin error logging.) 50 | 51 | 52 | You could expand on this concept and create your own EmailBackend that 53 | dynamically switches between other Anymail backends---based on properties of the 54 | message, or other criteria you set. For example, `this gist`_ shows an EmailBackend 55 | that checks ESPs' status-page APIs, and automatically falls back to a different ESP 56 | when the first one isn't working. 57 | 58 | .. _this gist: 59 | https://gist.github.com/tgehrs/58ae571b6db64225c317bf83c06ec312 60 | -------------------------------------------------------------------------------- /docs/tips/performance.rst: -------------------------------------------------------------------------------- 1 | .. _performance: 2 | 3 | Batch send performance 4 | ====================== 5 | 6 | If you are sending batches of hundreds of emails at a time, you can improve 7 | performance slightly by reusing a single HTTP connection to your ESP's 8 | API, rather than creating (and tearing down) a new connection for each message. 9 | 10 | Most Anymail EmailBackends automatically reuse their HTTP connections when 11 | used with Django's batch-sending functions :func:`~django.core.mail.send_mass_mail` or 12 | :meth:`connection.send_messages`. See :ref:`django:topics-sending-multiple-emails` 13 | in the Django docs for more info and an example. 14 | 15 | If you need even more performance, you may want to consider your ESP's batch-sending 16 | features. When supported by your ESP, Anymail can send multiple messages with a single 17 | API call. See :ref:`batch-send` for details, and be sure to check the 18 | :ref:`ESP-specific info ` because batch sending capabilities vary 19 | significantly between ESPs. 20 | -------------------------------------------------------------------------------- /docs/tips/securing_webhooks.rst: -------------------------------------------------------------------------------- 1 | .. _securing-webhooks: 2 | 3 | Securing webhooks 4 | ================= 5 | 6 | If not used carefully, webhooks can create security vulnerabilities 7 | in your Django application. 8 | 9 | At minimum, you should **use https** and a **shared authentication secret** 10 | for your Anymail webhooks. (Really, for *any* webhooks.) 11 | 12 | 13 | .. sidebar:: Does this really matter? 14 | 15 | Short answer: yes! 16 | 17 | Do you allow unauthorized access to your APIs? Would you want 18 | someone eavesdropping on API calls? Of course not. Well, a webhook 19 | is just another API. 20 | 21 | Think about the data your ESP sends and what your app does with it. 22 | If your webhooks aren't secured, an attacker could... 23 | 24 | * accumulate a list of your customers' email addresses 25 | * fake bounces and spam reports, so you block valid user emails 26 | * see the full contents of email from your users 27 | * convincingly forge incoming mail, tricking your app into publishing 28 | spam or acting on falsified commands 29 | * overwhelm your DB with garbage data (do you store tracking info? 30 | incoming attachments?) 31 | 32 | ... or worse. Why take a chance? 33 | 34 | 35 | Use https 36 | --------- 37 | 38 | For security, your Django site must use https. The webhook URLs you 39 | give your ESP need to start with *https* (not *http*). 40 | 41 | Without https, the data your ESP sends your webhooks is exposed in transit. 42 | This can include your customers' email addresses, the contents of messages 43 | you receive through your ESP, the shared secret used to authorize calls 44 | to your webhooks (described in the next section), and other data you'd 45 | probably like to keep private. 46 | 47 | Configuring https is beyond the scope of Anymail, but there are many good 48 | tutorials on the web. If you've previously dismissed https as too expensive 49 | or too complicated, please take another look. Free https certificates are 50 | available from `Let's Encrypt`_, and many hosting providers now offer easy 51 | https configuration using Let's Encrypt or their own no-cost option. 52 | 53 | If you aren't able to use https on your Django site, then you should 54 | not set up your ESP's webhooks. 55 | 56 | .. _Let's Encrypt: https://letsencrypt.org/ 57 | 58 | 59 | .. setting:: ANYMAIL_WEBHOOK_SECRET 60 | 61 | Use a shared authentication secret 62 | ---------------------------------- 63 | 64 | A webhook is an ordinary URL---anyone can post anything to it. 65 | To avoid receiving random (or malicious) data in your webhook, 66 | you should use a shared random secret that your ESP can present 67 | with webhook data, to prove the post is coming from your ESP. 68 | 69 | Most ESPs recommend using HTTP basic authentication as this shared 70 | secret. Anymail includes support for this, via the 71 | :setting:`!ANYMAIL_WEBHOOK_SECRET` setting. 72 | Basic usage is covered in the 73 | :ref:`webhooks configuration ` docs. 74 | 75 | If something posts to your webhooks without the required shared 76 | secret as basic auth in the HTTP *Authorization* header, Anymail will 77 | raise an :exc:`AnymailWebhookValidationFailure` error, which is 78 | a subclass of Django's :exc:`~django.core.exceptions.SuspiciousOperation`. 79 | This will result in an HTTP 400 "bad request" response, without further processing 80 | the data or calling your signal receiver function. 81 | 82 | In addition to a single "random:random" string, you can give a list 83 | of authentication strings. Anymail will permit webhook calls that match 84 | any of the authentication strings: 85 | 86 | .. code-block:: python 87 | 88 | ANYMAIL = { 89 | ... 90 | 'WEBHOOK_SECRET': [ 91 | 'abcdefghijklmnop:qrstuvwxyz0123456789', 92 | 'ZYXWVUTSRQPONMLK:JIHGFEDCBA9876543210', 93 | ], 94 | } 95 | 96 | This facilitates credential rotation: first, append a new authentication 97 | string to the list, and deploy your Django site. Then, update the webhook 98 | URLs at your ESP to use the new authentication. Finally, remove the old 99 | (now unused) authentication string from the list and re-deploy. 100 | 101 | .. warning:: 102 | 103 | If your webhook URLs don't use https, this shared authentication 104 | secret won't stay secret, defeating its purpose. 105 | 106 | 107 | Signed webhooks 108 | --------------- 109 | 110 | Some ESPs implement webhook signing, which is another method of verifying 111 | the webhook data came from your ESP. Anymail will verify these signatures 112 | for ESPs that support them. See the docs for your 113 | :ref:`specific ESP ` for more details and configuration 114 | that may be required. 115 | 116 | Even with signed webhooks, it doesn't hurt to also use a shared secret. 117 | 118 | 119 | Additional steps 120 | ---------------- 121 | 122 | Webhooks aren't unique to Anymail or to ESPs. They're used for many 123 | different types of inter-site communication, and you can find additional 124 | recommendations for improving webhook security on the web. 125 | 126 | For example, you might consider: 127 | 128 | * Tracking :attr:`~anymail.signals.AnymailTrackingEvent.event_id`, 129 | to avoid accidental double-processing of the same events (or replay attacks) 130 | * Checking the webhook's :attr:`~anymail.signals.AnymailTrackingEvent.timestamp` 131 | is reasonably close the current time 132 | * Configuring your firewall to reject webhook calls that come from 133 | somewhere other than your ESP's documented IP addresses (if your ESP 134 | provides this information) 135 | * Rate-limiting webhook calls in your web server or using something 136 | like :pypi:`django-ratelimit` 137 | 138 | But you should start with using https and a random shared secret via HTTP auth. 139 | -------------------------------------------------------------------------------- /docs/tips/transient_errors.rst: -------------------------------------------------------------------------------- 1 | .. _transient-errors: 2 | 3 | Handling transient errors 4 | ========================= 5 | 6 | Applications using Anymail need to be prepared to deal with connectivity issues 7 | and other transient errors from your ESP's API (as with any networked API). 8 | 9 | Because Django doesn't have a built-in way to say "try this again in a few moments," 10 | Anymail doesn't have its own logic to retry network errors. The best way to handle 11 | transient ESP errors depends on your Django project: 12 | 13 | * If you already use something like :pypi:`celery` or :pypi:`Django channels ` 14 | for background task scheduling, that's usually the best choice for handling Anymail sends. 15 | Queue a task for every send, and wait to mark the task complete until the send succeeds 16 | (or repeatedly fails, according to whatever logic makes sense for your app). 17 | 18 | * Another option is the Pinax :pypi:`django-mailer` package, which queues and automatically 19 | retries failed sends for any Django EmailBackend, including Anymail. django-mailer maintains 20 | its send queue in your regular Django DB, which is a simple way to get started but may not 21 | scale well for very large volumes of outbound email. 22 | 23 | In addition to handling connectivity issues, either of these approaches also has the advantage 24 | of moving email sending to a background thread. This is a best practice for sending email from 25 | Django, as it allows your web views to respond faster. 26 | 27 | Automatic retries 28 | ----------------- 29 | 30 | Backends that use :pypi:`requests` for network calls can configure its built-in retry 31 | functionality. Subclass the Anymail backend and mount instances of 32 | :class:`~requests.adapters.HTTPAdapter` and :class:`~urllib3.util.Retry` configured with 33 | your settings on the :class:`~requests.Session` object in `create_session()`. 34 | 35 | Automatic retries aren't a substitute for sending emails in a background thread, they're 36 | a way to simplify your retry logic within the worker. Be aware that retrying `read` and `other` 37 | failures may result in sending duplicate emails. Requests will only attempt to retry idempotent 38 | HTTP verbs by default, you may need to whitelist the verbs used by your backend's API in 39 | `allowed_methods` to actually get any retries. It can also automatically retry error HTTP 40 | status codes for you but you may need to configure `status_forcelist` with the error HTTP status 41 | codes used by your backend provider. 42 | 43 | .. code-block:: python 44 | 45 | import anymail.backends.mandrill 46 | from django.conf import settings 47 | import requests.adapters 48 | 49 | 50 | class RetryableMandrillEmailBackend(anymail.backends.mandrill.EmailBackend): 51 | def __init__(self, *args, **kwargs): 52 | super().__init__(*args, **kwargs) 53 | retry = requests.adapters.Retry( 54 | total=settings.EMAIL_TOTAL_RETRIES, 55 | connect=settings.EMAIL_CONNECT_RETRIES, 56 | read=settings.EMAIL_READ_RETRIES, 57 | status=settings.EMAIL_HTTP_STATUS_RETRIES, 58 | other=settings.EMAIL_OTHER_RETRIES, 59 | allowed_methods=False, # Retry all HTTP verbs 60 | status_forcelist=settings.EMAIL_HTTP_STATUS_RETRYABLE, 61 | backoff_factor=settings.EMAIL_RETRY_BACKOFF_FACTOR, 62 | ) 63 | self.retryable_adapter = requests.adapters.HTTPAdapter(max_retries=retry) 64 | 65 | def create_session(self): 66 | session = super().create_session() 67 | session.mount("https://", self.retryable_adapter) 68 | return session 69 | -------------------------------------------------------------------------------- /hatch_build.py: -------------------------------------------------------------------------------- 1 | # Hatch custom build hook that generates dynamic readme. 2 | 3 | import re 4 | from pathlib import Path 5 | 6 | from hatchling.metadata.plugin.interface import MetadataHookInterface 7 | 8 | 9 | def freeze_readme_versions(text: str, version: str) -> str: 10 | """ 11 | Rewrite links in readme text to refer to specific version. 12 | (This assumes version X.Y will be tagged "vX.Y" in git.) 13 | """ 14 | release_tag = f"v{version}" 15 | # (?<=...) is "positive lookbehind": must be there, but won't get replaced 16 | text = re.sub( 17 | # GitHub Actions badge: badge.svg?branch=main --> badge.svg?tag=vX.Y.Z: 18 | r"(?<=badge\.svg\?)branch=main", 19 | f"tag={release_tag}", 20 | text, 21 | ) 22 | return re.sub( 23 | # GitHub Actions status links: branch:main --> branch:vX.Y.Z: 24 | r"(?<=branch:)main" 25 | # ReadTheDocs links: /stable --> /vX.Y.Z: 26 | r"|(?<=/)stable" 27 | # ReadTheDocs badge: version=stable --> version=vX.Y.Z: 28 | r"|(?<=version=)stable", 29 | release_tag, 30 | text, 31 | ) 32 | 33 | 34 | class CustomMetadataHook(MetadataHookInterface): 35 | def update(self, metadata): 36 | """ 37 | Update the project table's metadata. 38 | """ 39 | readme_path = Path(self.root) / self.config["readme"] 40 | content_type = self.config.get("content-type", "text/x-rst") 41 | version = metadata["version"] 42 | 43 | readme_text = readme_path.read_text() 44 | readme_text = freeze_readme_versions(readme_text, version) 45 | 46 | metadata["readme"] = { 47 | "content-type": content_type, 48 | "text": readme_text, 49 | } 50 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "django-anymail" 7 | dynamic = ["readme", "version"] 8 | license = {file = "LICENSE"} 9 | 10 | authors = [ 11 | {name = "Mike Edmunds", email = "medmunds@gmail.com"}, 12 | {name = "Anymail Contributors"}, 13 | ] 14 | description = """\ 15 | Django email backends and webhooks for Amazon SES, Brevo, 16 | MailerSend, Mailgun, Mailjet, Mandrill, Postal, Postmark, Resend, 17 | SendGrid, SparkPost and Unisender Go 18 | (EmailBackend, transactional email tracking and inbound email signals)\ 19 | """ 20 | # readme: see tool.hatch.metadata.hooks.custom below 21 | keywords = [ 22 | "Django", "email", "email backend", "EmailBackend", 23 | "ESP", "email service provider", "transactional mail", 24 | "email tracking", "inbound email", "webhook", 25 | "Amazon SES", "AWS SES", "Simple Email Service", 26 | "Brevo", "SendinBlue", 27 | "MailerSend", 28 | "Mailgun", "Mailjet", "Sinch", 29 | "Mandrill", "MailChimp", 30 | "Postal", 31 | "Postmark", "ActiveCampaign", 32 | "Resend", 33 | "SendGrid", "Twilio", 34 | "SparkPost", "Bird", 35 | "Unisender Go", 36 | ] 37 | classifiers = [ 38 | "Development Status :: 5 - Production/Stable", 39 | "Programming Language :: Python", 40 | "Programming Language :: Python :: Implementation :: PyPy", 41 | "Programming Language :: Python :: Implementation :: CPython", 42 | "Programming Language :: Python :: 3", 43 | "Programming Language :: Python :: 3.8", 44 | "Programming Language :: Python :: 3.9", 45 | "Programming Language :: Python :: 3.10", 46 | "Programming Language :: Python :: 3.11", 47 | "Programming Language :: Python :: 3.12", 48 | "Programming Language :: Python :: 3.13", 49 | "License :: OSI Approved :: BSD License", 50 | "Topic :: Communications :: Email", 51 | "Topic :: Software Development :: Libraries :: Python Modules", 52 | "Intended Audience :: Developers", 53 | "Framework :: Django", 54 | "Framework :: Django :: 4.0", 55 | "Framework :: Django :: 4.1", 56 | "Framework :: Django :: 4.2", 57 | "Framework :: Django :: 5.0", 58 | "Framework :: Django :: 5.1", 59 | "Framework :: Django :: 5.2", 60 | "Environment :: Web Environment", 61 | ] 62 | 63 | requires-python = ">=3.8" 64 | dependencies = [ 65 | "django>=4.0", 66 | "requests>=2.4.3", 67 | "urllib3>=1.25.0", # requests dependency: fixes RFC 7578 header encoding 68 | ] 69 | 70 | [project.optional-dependencies] 71 | # ESP-specific additional dependencies. 72 | # (For simplicity, requests is included in the base dependencies.) 73 | # (Do not use underscores in extra names: they get normalized to hyphens.) 74 | amazon-ses = ["boto3>=1.24.6"] 75 | brevo = [] 76 | mailersend = [] 77 | mailgun = [] 78 | mailjet = [] 79 | mandrill = [] 80 | postmark = [] 81 | resend = ["svix"] 82 | sendgrid = [] 83 | sendinblue = [] 84 | sparkpost = [] 85 | unisender-go = [] 86 | postal = [ 87 | # Postal requires cryptography for verifying webhooks. 88 | # Cryptography's wheels are broken on darwin-arm64 before Python 3.9, 89 | # and unbuildable on PyPy 3.8 due to PyO3 limitations. Since cpython 3.8 90 | # has also passed EOL, just require Python 3.9+ with Postal. 91 | "cryptography; python_version >= '3.9'" 92 | ] 93 | 94 | [project.urls] 95 | Homepage = "https://github.com/anymail/django-anymail" 96 | Documentation = "https://anymail.dev/en/stable/" 97 | Source = "https://github.com/anymail/django-anymail" 98 | Changelog = "https://anymail.dev/en/stable/changelog/" 99 | Tracker = "https://github.com/anymail/django-anymail/issues" 100 | 101 | [tool.hatch.build] 102 | packages = ["anymail"] 103 | # Hatch automatically includes pyproject.toml, LICENSE, and hatch_build.py. 104 | # Help it find the dynamic readme source (otherwise wheel will only build with 105 | # `hatch build`, not with `python -m build`): 106 | force-include = {"README.rst" = "README.rst"} 107 | 108 | [tool.hatch.metadata.hooks.custom] 109 | # Provides dynamic readme 110 | path = "hatch_build.py" 111 | readme = "README.rst" 112 | 113 | [tool.hatch.version] 114 | path = "anymail/_version.py" 115 | 116 | 117 | [tool.black] 118 | force-exclude = '^/tests/test_settings/settings_.*\.py' 119 | target-version = ["py38"] 120 | 121 | [tool.doc8] 122 | # for now, Anymail allows longer lines in docs source: 123 | max-line-length = 120 124 | 125 | [tool.flake8] 126 | # See .flake8 file in project root 127 | 128 | [tool.isort] 129 | combine_as_imports = true 130 | known_first_party = "anymail" 131 | profile = "black" 132 | py_version = "38" 133 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # Requirements for developing (not just using) the package 2 | 3 | hatch 4 | pre-commit 5 | tox<4 6 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # usage: python runtests.py [tests.test_x tests.test_y.SomeTestCase ...] 4 | 5 | import os 6 | import re 7 | import sys 8 | import warnings 9 | from pathlib import Path 10 | 11 | import django 12 | from django.conf import settings 13 | from django.test.utils import get_runner 14 | 15 | 16 | def find_test_settings(): 17 | """ 18 | Return dotted path to Django settings compatible with current Django version. 19 | 20 | Finds highest tests.test_settings.settings_N_M.py where N.M is <= Django version. 21 | (Generally, default Django settings don't change meaningfully between Django 22 | releases, so this will fall back to the most recent settings when there isn't an 23 | exact match for the current version, while allowing creation of new settings 24 | files to test significant differences.) 25 | """ 26 | django_version = django.VERSION[:2] # (major, minor) 27 | found_version = None # (major, minor) 28 | found_path = None 29 | 30 | for settings_path in Path("tests/test_settings").glob("settings_*.py"): 31 | try: 32 | (major, minor) = re.match( 33 | r"settings_(\d+)_(\d+)\.py", settings_path.name 34 | ).groups() 35 | settings_version = (int(major), int(minor)) 36 | except (AttributeError, TypeError, ValueError): 37 | raise ValueError( 38 | f"File '{settings_path!s}' doesn't match settings_N_M.py" 39 | ) from None 40 | if settings_version <= django_version: 41 | if found_version is None or settings_version > found_version: 42 | found_version = settings_version 43 | found_path = settings_path 44 | 45 | if found_path is None: 46 | raise ValueError(f"No suitable test_settings for Django {django.__version__}") 47 | 48 | # Convert Path("test/test_settings/settings_N_M.py") 49 | # to dotted module "test.test_settings.settings_N_M": 50 | return ".".join(found_path.with_suffix("").parts) 51 | 52 | 53 | def setup_and_run_tests(test_labels=None): 54 | """Discover and run project tests. Returns number of failures.""" 55 | test_labels = test_labels or ["tests"] 56 | 57 | tags = envlist("ANYMAIL_ONLY_TEST") 58 | exclude_tags = envlist("ANYMAIL_SKIP_TESTS") 59 | 60 | # In automated testing, don't run live tests unless specifically requested 61 | if envbool("CONTINUOUS_INTEGRATION") and not envbool("ANYMAIL_RUN_LIVE_TESTS"): 62 | exclude_tags.append("live") 63 | 64 | if tags: 65 | print("Only running tests tagged: %r" % tags) 66 | if exclude_tags: 67 | print("Excluding tests tagged: %r" % exclude_tags) 68 | 69 | # show DeprecationWarning and other default-ignored warnings: 70 | warnings.simplefilter("default") 71 | 72 | settings_module = find_test_settings() 73 | print(f"Using settings module {settings_module!r}.") 74 | os.environ["DJANGO_SETTINGS_MODULE"] = settings_module 75 | django.setup() 76 | 77 | TestRunner = get_runner(settings) 78 | test_runner = TestRunner(verbosity=1, tags=tags, exclude_tags=exclude_tags) 79 | return test_runner.run_tests(test_labels) 80 | 81 | 82 | def runtests(test_labels=None): 83 | """Run project tests and exit""" 84 | # Used as setup test_suite: must either exit or return a TestSuite 85 | failures = setup_and_run_tests(test_labels) 86 | sys.exit(bool(failures)) 87 | 88 | 89 | def envbool(var, default=False): 90 | """Returns value of environment variable var as a bool, or default if not set/empty. 91 | 92 | Converts `'true'` and similar string representations to `True`, 93 | and `'false'` and similar string representations to `False`. 94 | """ 95 | # Adapted from the old :func:`~distutils.util.strtobool` 96 | val = os.getenv(var, "").strip().lower() 97 | if val == "": 98 | return default 99 | elif val in ("y", "yes", "t", "true", "on", "1"): 100 | return True 101 | elif val in ("n", "no", "f", "false", "off", "0"): 102 | return False 103 | else: 104 | raise ValueError("invalid boolean value env[%r]=%r" % (var, val)) 105 | 106 | 107 | def envlist(var): 108 | """Returns value of environment variable var split in a comma-separated list. 109 | 110 | Returns an empty list if variable is empty or not set. 111 | """ 112 | val = [item.strip() for item in os.getenv(var, "").split(",")] 113 | if val == [""]: 114 | # "Splitting an empty string with a specified separator returns ['']" 115 | val = [] 116 | return val 117 | 118 | 119 | if __name__ == "__main__": 120 | runtests(test_labels=sys.argv[1:]) 121 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anymail/django-anymail/29c446ff645169ce832a0263aa9baca938a46809/tests/__init__.py -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | # Additional packages needed only for running tests 2 | responses 3 | -------------------------------------------------------------------------------- /tests/test_base_backends.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from django.test import SimpleTestCase, override_settings, tag 4 | 5 | from anymail.backends.base_requests import AnymailRequestsBackend, RequestsPayload 6 | from anymail.message import AnymailMessage, AnymailRecipientStatus 7 | from tests.utils import AnymailTestMixin 8 | 9 | from .mock_requests_backend import RequestsBackendMockAPITestCase 10 | 11 | 12 | class MinimalRequestsBackend(AnymailRequestsBackend): 13 | """(useful only for these tests)""" 14 | 15 | esp_name = "Example" 16 | api_url = "https://httpbin.org/post" # helpful echoback endpoint for live testing 17 | 18 | def __init__(self, **kwargs): 19 | super().__init__(self.api_url, **kwargs) 20 | 21 | def build_message_payload(self, message, defaults): 22 | _payload_init = getattr(message, "_payload_init", {}) 23 | return MinimalRequestsPayload(message, defaults, self, **_payload_init) 24 | 25 | def parse_recipient_status(self, response, payload, message): 26 | return {"to@example.com": AnymailRecipientStatus("message-id", "sent")} 27 | 28 | 29 | class MinimalRequestsPayload(RequestsPayload): 30 | def init_payload(self): 31 | pass 32 | 33 | def _noop(self, *args, **kwargs): 34 | pass 35 | 36 | set_from_email = _noop 37 | set_recipients = _noop 38 | set_subject = _noop 39 | set_reply_to = _noop 40 | set_extra_headers = _noop 41 | set_text_body = _noop 42 | set_html_body = _noop 43 | add_attachment = _noop 44 | 45 | 46 | @override_settings(EMAIL_BACKEND="tests.test_base_backends.MinimalRequestsBackend") 47 | class RequestsBackendBaseTestCase(RequestsBackendMockAPITestCase): 48 | """Test common functionality in AnymailRequestsBackend""" 49 | 50 | def setUp(self): 51 | super().setUp() 52 | self.message = AnymailMessage( 53 | "Subject", "Text Body", "from@example.com", ["to@example.com"] 54 | ) 55 | 56 | def test_minimal_requests_backend(self): 57 | """Make sure the testing backend defined above actually works""" 58 | self.message.send() 59 | self.assert_esp_called("https://httpbin.org/post") 60 | 61 | def test_timeout_default(self): 62 | """All requests have a 30 second default timeout""" 63 | self.message.send() 64 | timeout = self.get_api_call_arg("timeout") 65 | self.assertEqual(timeout, 30) 66 | 67 | @override_settings(ANYMAIL_REQUESTS_TIMEOUT=5) 68 | def test_timeout_setting(self): 69 | """You can use the Anymail setting REQUESTS_TIMEOUT to override the default""" 70 | self.message.send() 71 | timeout = self.get_api_call_arg("timeout") 72 | self.assertEqual(timeout, 5) 73 | 74 | @mock.patch(f"{__name__}.MinimalRequestsBackend.create_session") 75 | def test_create_session_error_fail_silently(self, mock_create_session): 76 | # If create_session fails and fail_silently is True, 77 | # make sure _send doesn't raise a misleading error. 78 | mock_create_session.side_effect = ValueError("couldn't create session") 79 | sent = self.message.send(fail_silently=True) 80 | self.assertEqual(sent, 0) 81 | 82 | 83 | @tag("live") 84 | @override_settings(EMAIL_BACKEND="tests.test_base_backends.MinimalRequestsBackend") 85 | class RequestsBackendLiveTestCase(AnymailTestMixin, SimpleTestCase): 86 | @override_settings(ANYMAIL_DEBUG_API_REQUESTS=True) 87 | def test_debug_logging(self): 88 | message = AnymailMessage( 89 | "Subject", "Text Body", "from@example.com", ["to@example.com"] 90 | ) 91 | message._payload_init = dict( 92 | data="Request body", 93 | headers={ 94 | "Content-Type": "text/plain", 95 | "Accept": "application/json", 96 | }, 97 | ) 98 | with self.assertPrints("===== Anymail API request") as outbuf: 99 | message.send() 100 | 101 | # Header order and response data vary too much to do a full comparison, 102 | # but make sure that the output contains some expected pieces of the request 103 | # and the response 104 | output = outbuf.getvalue() 105 | self.assertIn("\nPOST https://httpbin.org/post\n", output) 106 | self.assertIn("\nUser-Agent: django-anymail/", output) 107 | self.assertIn("\nAccept: application/json\n", output) 108 | self.assertIn("\nContent-Type: text/plain\n", output) # request 109 | self.assertIn("\n\nRequest body\n", output) 110 | self.assertIn("\n----- Response\n", output) 111 | self.assertIn("\nHTTP 200 OK\n", output) 112 | self.assertIn("\nContent-Type: application/json\n", output) # response 113 | 114 | def test_no_debug_logging(self): 115 | # Make sure it doesn't output anything when DEBUG_API_REQUESTS is not set 116 | message = AnymailMessage( 117 | "Subject", "Text Body", "from@example.com", ["to@example.com"] 118 | ) 119 | message._payload_init = dict( 120 | data="Request body", 121 | headers={ 122 | "Content-Type": "text/plain", 123 | "Accept": "application/json", 124 | }, 125 | ) 126 | with self.assertPrints("", match="equal"): 127 | message.send() 128 | -------------------------------------------------------------------------------- /tests/test_brevo_integration.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | from datetime import datetime, timedelta 4 | from email.utils import formataddr 5 | 6 | from django.test import SimpleTestCase, override_settings, tag 7 | 8 | from anymail.exceptions import AnymailAPIError 9 | from anymail.message import AnymailMessage 10 | 11 | from .utils import AnymailTestMixin 12 | 13 | ANYMAIL_TEST_BREVO_API_KEY = os.getenv("ANYMAIL_TEST_BREVO_API_KEY") 14 | ANYMAIL_TEST_BREVO_DOMAIN = os.getenv("ANYMAIL_TEST_BREVO_DOMAIN") 15 | 16 | 17 | @tag("brevo", "live") 18 | @unittest.skipUnless( 19 | ANYMAIL_TEST_BREVO_API_KEY and ANYMAIL_TEST_BREVO_DOMAIN, 20 | "Set ANYMAIL_TEST_BREVO_API_KEY and ANYMAIL_TEST_BREVO_DOMAIN " 21 | "environment variables to run Brevo integration tests", 22 | ) 23 | @override_settings( 24 | ANYMAIL_BREVO_API_KEY=ANYMAIL_TEST_BREVO_API_KEY, 25 | ANYMAIL_BREVO_SEND_DEFAULTS=dict(), 26 | EMAIL_BACKEND="anymail.backends.brevo.EmailBackend", 27 | ) 28 | class BrevoBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): 29 | """Brevo v3 API integration tests 30 | 31 | Brevo doesn't have sandbox so these tests run 32 | against the **live** Brevo API, using the 33 | environment variable `ANYMAIL_TEST_BREVO_API_KEY` as the API key, 34 | and `ANYMAIL_TEST_BREVO_DOMAIN` to construct sender addresses. 35 | If those variables are not set, these tests won't run. 36 | 37 | https://developers.brevo.com/docs/faq#how-can-i-test-the-api 38 | 39 | """ 40 | 41 | def setUp(self): 42 | super().setUp() 43 | self.from_email = "from@%s" % ANYMAIL_TEST_BREVO_DOMAIN 44 | self.message = AnymailMessage( 45 | "Anymail Brevo integration test", 46 | "Text content", 47 | self.from_email, 48 | ["test+to1@anymail.dev"], 49 | ) 50 | self.message.attach_alternative("

HTML content

", "text/html") 51 | 52 | def test_simple_send(self): 53 | # Example of getting the Brevo send status and message id from the message 54 | sent_count = self.message.send() 55 | self.assertEqual(sent_count, 1) 56 | 57 | anymail_status = self.message.anymail_status 58 | sent_status = anymail_status.recipients["test+to1@anymail.dev"].status 59 | message_id = anymail_status.recipients["test+to1@anymail.dev"].message_id 60 | 61 | self.assertEqual(sent_status, "queued") # Brevo always queues 62 | # Message-ID can be ...@smtp-relay.mail.fr or .sendinblue.com: 63 | self.assertRegex(message_id, r"\<.+@.+\>") 64 | # set of all recipient statuses: 65 | self.assertEqual(anymail_status.status, {sent_status}) 66 | self.assertEqual(anymail_status.message_id, message_id) 67 | 68 | def test_all_options(self): 69 | send_at = datetime.now() + timedelta(minutes=2) 70 | message = AnymailMessage( 71 | subject="Anymail Brevo all-options integration test", 72 | body="This is the text body", 73 | from_email=formataddr(("Test From, with comma", self.from_email)), 74 | to=["test+to1@anymail.dev", '"Recipient 2, OK?" '], 75 | cc=["test+cc1@anymail.dev", "Copy 2 "], 76 | bcc=["test+bcc1@anymail.dev", "Blind Copy 2 "], 77 | # Brevo API v3 only supports single reply-to 78 | reply_to=['"Reply, with comma" '], 79 | headers={"X-Anymail-Test": "value", "X-Anymail-Count": 3}, 80 | metadata={"meta1": "simple string", "meta2": 2}, 81 | send_at=send_at, 82 | tags=["tag 1", "tag 2"], 83 | ) 84 | # Brevo requires an HTML body: 85 | message.attach_alternative("

HTML content

", "text/html") 86 | 87 | message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain") 88 | message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv") 89 | 90 | message.send() 91 | # Brevo always queues: 92 | self.assertEqual(message.anymail_status.status, {"queued"}) 93 | self.assertRegex(message.anymail_status.message_id, r"\<.+@.+\>") 94 | 95 | def test_template(self): 96 | message = AnymailMessage( 97 | # There is a *new-style* template with this id in the Anymail test account: 98 | template_id=5, 99 | # Override template sender: 100 | from_email=formataddr(("Sender", self.from_email)), 101 | to=["Recipient 1 ", "test+to2@anymail.dev"], 102 | reply_to=["Do not reply "], 103 | tags=["using-template"], 104 | # The Anymail test template includes `{{ params.SHIP_DATE }}` 105 | # and `{{ params.ORDER_ID }}` substitutions 106 | merge_data={ 107 | "test+to1@anymail.dev": {"ORDER_ID": "12345"}, 108 | "test+to2@anymail.dev": {"ORDER_ID": "23456"}, 109 | }, 110 | merge_global_data={"SHIP_DATE": "yesterday"}, 111 | metadata={"customer-id": "unknown", "meta2": 2}, 112 | merge_metadata={ 113 | "test+to1@anymail.dev": {"customer-id": "ZXK9123"}, 114 | "test+to2@anymail.dev": {"customer-id": "ZZT4192"}, 115 | }, 116 | headers={ 117 | "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", 118 | "List-Unsubscribe": "", 119 | }, 120 | merge_headers={ 121 | "test+to1@anymail.dev": { 122 | "List-Unsubscribe": "", 123 | }, 124 | "test+to2@anymail.dev": { 125 | "List-Unsubscribe": "", 126 | }, 127 | }, 128 | ) 129 | 130 | message.attach("attachment1.txt", "Here is some\ntext", "text/plain") 131 | 132 | message.send() 133 | # Brevo always queues: 134 | self.assertEqual(message.anymail_status.status, {"queued"}) 135 | recipient_status = message.anymail_status.recipients 136 | self.assertEqual(recipient_status["test+to1@anymail.dev"].status, "queued") 137 | self.assertEqual(recipient_status["test+to2@anymail.dev"].status, "queued") 138 | self.assertRegex( 139 | recipient_status["test+to1@anymail.dev"].message_id, r"\<.+@.+\>" 140 | ) 141 | self.assertRegex( 142 | recipient_status["test+to2@anymail.dev"].message_id, r"\<.+@.+\>" 143 | ) 144 | # Each recipient gets their own message_id: 145 | self.assertNotEqual( 146 | recipient_status["test+to1@anymail.dev"].message_id, 147 | recipient_status["test+to2@anymail.dev"].message_id, 148 | ) 149 | 150 | @override_settings(ANYMAIL_BREVO_API_KEY="Hey, that's not an API key!") 151 | def test_invalid_api_key(self): 152 | with self.assertRaises(AnymailAPIError) as cm: 153 | self.message.send() 154 | err = cm.exception 155 | self.assertEqual(err.status_code, 401) 156 | # Make sure the exception message includes Brevo's response: 157 | self.assertIn("Key not found", str(err)) 158 | -------------------------------------------------------------------------------- /tests/test_checks.py: -------------------------------------------------------------------------------- 1 | from django.core import checks 2 | from django.test import SimpleTestCase 3 | from django.test.utils import override_settings 4 | 5 | from anymail.checks import check_deprecated_settings, check_insecure_settings 6 | 7 | from .utils import AnymailTestMixin 8 | 9 | 10 | class DeprecatedSettingsTests(AnymailTestMixin, SimpleTestCase): 11 | @override_settings(ANYMAIL={"WEBHOOK_AUTHORIZATION": "abcde:12345"}) 12 | def test_webhook_authorization(self): 13 | errors = check_deprecated_settings(None) 14 | self.assertEqual( 15 | errors, 16 | [ 17 | checks.Error( 18 | "The ANYMAIL setting 'WEBHOOK_AUTHORIZATION' has been renamed" 19 | " 'WEBHOOK_SECRET' to improve security.", 20 | hint="You must update your settings.py.", 21 | id="anymail.E001", 22 | ) 23 | ], 24 | ) 25 | 26 | @override_settings(ANYMAIL_WEBHOOK_AUTHORIZATION="abcde:12345", ANYMAIL={}) 27 | def test_anymail_webhook_authorization(self): 28 | errors = check_deprecated_settings(None) 29 | self.assertEqual( 30 | errors, 31 | [ 32 | checks.Error( 33 | "The ANYMAIL_WEBHOOK_AUTHORIZATION setting has been renamed" 34 | " ANYMAIL_WEBHOOK_SECRET to improve security.", 35 | hint="You must update your settings.py.", 36 | id="anymail.E001", 37 | ) 38 | ], 39 | ) 40 | 41 | 42 | class InsecureSettingsTests(AnymailTestMixin, SimpleTestCase): 43 | @override_settings(ANYMAIL={"DEBUG_API_REQUESTS": True}) 44 | def test_debug_api_requests_deployed(self): 45 | errors = check_insecure_settings(None) 46 | self.assertEqual(len(errors), 1) 47 | self.assertEqual(errors[0].id, "anymail.W002") 48 | 49 | @override_settings(ANYMAIL={"DEBUG_API_REQUESTS": True}, DEBUG=True) 50 | def test_debug_api_requests_debug(self): 51 | errors = check_insecure_settings(None) 52 | self.assertEqual(len(errors), 0) # no warning in DEBUG (non-production) config 53 | -------------------------------------------------------------------------------- /tests/test_files/postmark-inbound-test-payload-with-raw.json: -------------------------------------------------------------------------------- 1 | { 2 | "FromName": "Postmarkapp Support", 3 | "MessageStream": "inbound", 4 | "From": "support@postmarkapp.com", 5 | "FromFull": { 6 | "Email": "support@postmarkapp.com", 7 | "Name": "Postmarkapp Support", 8 | "MailboxHash": "" 9 | }, 10 | "To": "\"Firstname Lastname\" ", 11 | "ToFull": [ 12 | { 13 | "Email": "mailbox+SampleHash@inbound.postmarkapp.com", 14 | "Name": "Firstname Lastname", 15 | "MailboxHash": "SampleHash" 16 | } 17 | ], 18 | "Cc": "\"First Cc\" , secondCc@postmarkapp.com", 19 | "CcFull": [ 20 | { 21 | "Email": "firstcc@postmarkapp.com", 22 | "Name": "First Cc", 23 | "MailboxHash": "" 24 | }, 25 | { 26 | "Email": "secondCc@postmarkapp.com", 27 | "Name": "", 28 | "MailboxHash": "" 29 | } 30 | ], 31 | "Bcc": "\"First Bcc\" ", 32 | "BccFull": [ 33 | { 34 | "Email": "firstbcc@postmarkapp.com", 35 | "Name": "First Bcc", 36 | "MailboxHash": "" 37 | } 38 | ], 39 | "OriginalRecipient": "mailbox+SampleHash@inbound.postmarkapp.com", 40 | "Subject": "Test subject", 41 | "MessageID": "00000000-0000-0000-0000-000000000000", 42 | "ReplyTo": "replyto@example.com", 43 | "MailboxHash": "SampleHash", 44 | "Date": "Fri, 5 May 2023 17:41:16 -0400", 45 | "TextBody": "This is a test text body.", 46 | "HtmlBody": "

This is a test html body.<\/p><\/body><\/html>", 47 | "StrippedTextReply": "This is the reply text", 48 | "RawEmail": "From: Postmarkapp Support \r\nTo: Firstname Lastname \r\nSubject: Test subject\r\n\r\nThis is a test text body.\r\n", 49 | "Tag": "TestTag", 50 | "Headers": [ 51 | { 52 | "Name": "X-Header-Test", 53 | "Value": "" 54 | } 55 | ], 56 | "Attachments": [ 57 | { 58 | "Name": "test.txt", 59 | "ContentType": "text/plain", 60 | "Data": "VGhpcyBpcyBhdHRhY2htZW50IGNvbnRlbnRzLCBiYXNlLTY0IGVuY29kZWQu", 61 | "ContentLength": 45 62 | } 63 | ] 64 | } 65 | -------------------------------------------------------------------------------- /tests/test_files/postmark-inbound-test-payload.json: -------------------------------------------------------------------------------- 1 | { 2 | "FromName": "Postmarkapp Support", 3 | "MessageStream": "inbound", 4 | "From": "support@postmarkapp.com", 5 | "FromFull": { 6 | "Email": "support@postmarkapp.com", 7 | "Name": "Postmarkapp Support", 8 | "MailboxHash": "" 9 | }, 10 | "To": "\"Firstname Lastname\" ", 11 | "ToFull": [ 12 | { 13 | "Email": "mailbox+SampleHash@inbound.postmarkapp.com", 14 | "Name": "Firstname Lastname", 15 | "MailboxHash": "SampleHash" 16 | } 17 | ], 18 | "Cc": "\"First Cc\" , secondCc@postmarkapp.com", 19 | "CcFull": [ 20 | { 21 | "Email": "firstcc@postmarkapp.com", 22 | "Name": "First Cc", 23 | "MailboxHash": "" 24 | }, 25 | { 26 | "Email": "secondCc@postmarkapp.com", 27 | "Name": "", 28 | "MailboxHash": "" 29 | } 30 | ], 31 | "Bcc": "\"First Bcc\" ", 32 | "BccFull": [ 33 | { 34 | "Email": "firstbcc@postmarkapp.com", 35 | "Name": "First Bcc", 36 | "MailboxHash": "" 37 | } 38 | ], 39 | "OriginalRecipient": "mailbox+SampleHash@inbound.postmarkapp.com", 40 | "Subject": "Test subject", 41 | "MessageID": "00000000-0000-0000-0000-000000000000", 42 | "ReplyTo": "replyto@example.com", 43 | "MailboxHash": "SampleHash", 44 | "Date": "Fri, 5 May 2023 17:44:33 -0400", 45 | "TextBody": "This is a test text body.", 46 | "HtmlBody": "

This is a test html body.<\/p><\/body><\/html>", 47 | "StrippedTextReply": "This is the reply text", 48 | "Tag": "TestTag", 49 | "Headers": [ 50 | { 51 | "Name": "X-Header-Test", 52 | "Value": "" 53 | } 54 | ], 55 | "Attachments": [ 56 | { 57 | "Name": "test.txt", 58 | "ContentType": "text/plain", 59 | "Data": "VGhpcyBpcyBhdHRhY2htZW50IGNvbnRlbnRzLCBiYXNlLTY0IGVuY29kZWQu", 60 | "ContentLength": 45 61 | } 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /tests/test_files/sample_email.txt: -------------------------------------------------------------------------------- 1 | Received: by luna.mailgun.net with SMTP mgrt 8734663311733; Fri, 03 May 2013 2 | 18:26:27 +0000 3 | Content-Type: multipart/alternative; 4 | boundary="eb663d73ae0a4d6c9153cc0aec8b7520" 5 | Mime-Version: 1.0 6 | Subject: Test email 7 | From: Someone 8 | To: someoneelse@example.com 9 | Reply-To: reply.to@example.com 10 | Message-Id: <20130503182626.18666.16540@example.com> 11 | List-Unsubscribe: 14 | X-Mailgun-Sid: WyIwNzI5MCIsICJhbGljZUBleGFtcGxlLmNvbSIsICI2Il0= 15 | X-Mailgun-Variables: {"my_var_1": "Mailgun Variable #1", "my-var-2": "awesome"} 16 | Date: Fri, 03 May 2013 18:26:27 +0000 17 | Sender: someone@example.com 18 | 19 | --eb663d73ae0a4d6c9153cc0aec8b7520 20 | Mime-Version: 1.0 21 | Content-Type: text/plain; charset="ascii" 22 | Content-Transfer-Encoding: 7bit 23 | 24 | Hi Bob, This is a message. Thanks! 25 | 26 | --eb663d73ae0a4d6c9153cc0aec8b7520 27 | Mime-Version: 1.0 28 | Content-Type: text/html; charset="ascii" 29 | Content-Transfer-Encoding: 7bit 30 | 31 | 32 | Hi Bob, This is a message. Thanks! 33 |
34 | 35 | --eb663d73ae0a4d6c9153cc0aec8b7520-- 36 | -------------------------------------------------------------------------------- /tests/test_files/sample_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anymail/django-anymail/29c446ff645169ce832a0263aa9baca938a46809/tests/test_files/sample_image.png -------------------------------------------------------------------------------- /tests/test_files/unisender-go-tracking-test-payload.json.raw: -------------------------------------------------------------------------------- 1 | {"auth":"b3cb4d6aef9d07095805c39e792e0542","events_by_user":[{"user_id":5960727,"project_name":"Testing","project_id":"6862471","events":[{"event_name":"transactional_email_status","event_data":{"job_id":"1sn15Z-0007Le-GtVN","email":"unisendergo@anymail.dev","status":"clicked","event_time":"2024-09-08 19:56:41","url":"https:\/\/example.com","delivery_info":{"user_agent":"Mozilla\/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/128.0.0.0 Safari\/537.36","ip":"66.179.153.169"}}}]}]} -------------------------------------------------------------------------------- /tests/test_mandrill_inbound.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | from unittest.mock import ANY 3 | 4 | from django.test import override_settings, tag 5 | 6 | from anymail.inbound import AnymailInboundMessage 7 | from anymail.signals import AnymailInboundEvent 8 | from anymail.webhooks.mandrill import MandrillCombinedWebhookView 9 | 10 | from .test_mandrill_webhooks import TEST_WEBHOOK_KEY, mandrill_args 11 | from .webhook_cases import WebhookTestCase 12 | 13 | 14 | @tag("mandrill") 15 | @override_settings(ANYMAIL_MANDRILL_WEBHOOK_KEY=TEST_WEBHOOK_KEY) 16 | class MandrillInboundTestCase(WebhookTestCase): 17 | def test_inbound_basics(self): 18 | raw_event = { 19 | "event": "inbound", 20 | "ts": 1507856722, 21 | "msg": { 22 | "raw_msg": dedent( 23 | """\ 24 | From: A tester 25 | Date: Thu, 12 Oct 2017 18:03:30 -0700 26 | Message-ID: 27 | Subject: Test subject 28 | To: "Test, Inbound" , other@example.com 29 | MIME-Version: 1.0 30 | Content-Type: multipart/alternative; boundary="94eb2c05e174adb140055b6339c5" 31 | 32 | --94eb2c05e174adb140055b6339c5 33 | Content-Type: text/plain; charset="UTF-8" 34 | Content-Transfer-Encoding: quoted-printable 35 | 36 | It's a body=E2=80=A6 37 | 38 | --94eb2c05e174adb140055b6339c5 39 | Content-Type: text/html; charset="UTF-8" 40 | Content-Transfer-Encoding: quoted-printable 41 | 42 |

It's a body=E2=80=A6
43 | 44 | --94eb2c05e174adb140055b6339c5-- 45 | """ # NOQA: E501 46 | ), 47 | "email": "delivered-to@example.com", 48 | # Mandrill populates "sender" only for outbound message events 49 | "sender": None, 50 | "spam_report": { 51 | "score": 1.7, 52 | }, 53 | # Anymail ignores Mandrill's other inbound event fields 54 | # (which are all redundant with raw_msg) 55 | }, 56 | } 57 | 58 | response = self.client.post( 59 | **mandrill_args(events=[raw_event], path="/anymail/mandrill/") 60 | ) 61 | self.assertEqual(response.status_code, 200) 62 | kwargs = self.assert_handler_called_once_with( 63 | self.inbound_handler, 64 | sender=MandrillCombinedWebhookView, 65 | event=ANY, 66 | esp_name="Mandrill", 67 | ) 68 | # Inbound should not dispatch tracking signal: 69 | self.assertEqual(self.tracking_handler.call_count, 0) 70 | 71 | event = kwargs["event"] 72 | self.assertIsInstance(event, AnymailInboundEvent) 73 | self.assertEqual(event.event_type, "inbound") 74 | self.assertEqual(event.timestamp.isoformat(), "2017-10-13T01:05:22+00:00") 75 | self.assertIsNone(event.event_id) # Mandrill doesn't provide inbound event id 76 | self.assertIsInstance(event.message, AnymailInboundMessage) 77 | self.assertEqual(event.esp_event, raw_event) 78 | 79 | message = event.message 80 | self.assertEqual(message.from_email.display_name, "A tester") 81 | self.assertEqual(message.from_email.addr_spec, "test@example.org") 82 | self.assertEqual(len(message.to), 2) 83 | self.assertEqual(message.to[0].display_name, "Test, Inbound") 84 | self.assertEqual(message.to[0].addr_spec, "test@inbound.example.com") 85 | self.assertEqual(message.to[1].addr_spec, "other@example.com") 86 | self.assertEqual(message.subject, "Test subject") 87 | self.assertEqual(message.date.isoformat(" "), "2017-10-12 18:03:30-07:00") 88 | self.assertEqual(message.text, "It's a body\N{HORIZONTAL ELLIPSIS}\n") 89 | self.assertEqual( 90 | message.html, 91 | """
It's a body\N{HORIZONTAL ELLIPSIS}
\n""", 92 | ) 93 | 94 | self.assertIsNone(message.envelope_sender) # Mandrill doesn't provide sender 95 | self.assertEqual(message.envelope_recipient, "delivered-to@example.com") 96 | # Mandrill doesn't provide stripped plaintext body: 97 | self.assertIsNone(message.stripped_text) 98 | # Mandrill doesn't provide stripped html: 99 | self.assertIsNone(message.stripped_html) 100 | # Mandrill doesn't provide spam boolean: 101 | self.assertIsNone(message.spam_detected) 102 | self.assertEqual(message.spam_score, 1.7) 103 | 104 | # Anymail will also parse attachments (if any) from the raw mime. 105 | # We don't bother testing that here; see test_inbound for examples. 106 | -------------------------------------------------------------------------------- /tests/test_message.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django.core.mail import EmailMultiAlternatives 4 | from django.test import SimpleTestCase 5 | 6 | from anymail.message import AnymailRecipientStatus, AnymailStatus, attach_inline_image 7 | 8 | from .utils import AnymailTestMixin, sample_image_content 9 | 10 | 11 | class InlineImageTests(AnymailTestMixin, SimpleTestCase): 12 | def setUp(self): 13 | self.message = EmailMultiAlternatives() 14 | super().setUp() 15 | 16 | @patch("socket.getfqdn") 17 | def test_default_domain(self, mock_getfqdn): 18 | """The default Content-ID domain should *not* use local hostname""" 19 | # (This avoids problems with ESPs that re-use Content-ID as attachment 20 | # filename: if the local hostname ends in ".com", you can end up with 21 | # an inline attachment filename that causes Gmail to reject the message.) 22 | mock_getfqdn.return_value = "server.example.com" 23 | cid = attach_inline_image(self.message, sample_image_content()) 24 | self.assertRegex( 25 | cid, 26 | r"[\w.]+@inline", 27 | "Content-ID should be a valid Message-ID, " "but _not_ @server.example.com", 28 | ) 29 | 30 | def test_domain_override(self): 31 | cid = attach_inline_image( 32 | self.message, sample_image_content(), domain="example.org" 33 | ) 34 | self.assertRegex( 35 | cid, 36 | r"[\w.]+@example\.org", 37 | "Content-ID should be a valid Message-ID @example.org", 38 | ) 39 | 40 | 41 | class AnymailStatusTests(AnymailTestMixin, SimpleTestCase): 42 | def test_single_recipient(self): 43 | recipients = { 44 | "one@example.com": AnymailRecipientStatus("12345", "sent"), 45 | } 46 | status = AnymailStatus() 47 | status.set_recipient_status(recipients) 48 | self.assertEqual(status.status, {"sent"}) 49 | self.assertEqual(status.message_id, "12345") 50 | self.assertEqual(status.recipients, recipients) 51 | self.assertEqual( 52 | repr(status), 53 | "AnymailStatus", 54 | ) 55 | self.assertEqual( 56 | repr(status.recipients["one@example.com"]), 57 | "AnymailRecipientStatus('12345', 'sent')", 58 | ) 59 | 60 | def test_multiple_recipients(self): 61 | recipients = { 62 | "one@example.com": AnymailRecipientStatus("12345", "sent"), 63 | "two@example.com": AnymailRecipientStatus("45678", "queued"), 64 | } 65 | status = AnymailStatus() 66 | status.set_recipient_status(recipients) 67 | self.assertEqual(status.status, {"queued", "sent"}) 68 | self.assertEqual(status.message_id, {"12345", "45678"}) 69 | self.assertEqual(status.recipients, recipients) 70 | self.assertEqual( 71 | repr(status), 72 | "AnymailStatus", 74 | ) 75 | 76 | def test_multiple_recipients_same_message_id(self): 77 | # status.message_id collapses when it's the same for all recipients 78 | recipients = { 79 | "one@example.com": AnymailRecipientStatus("12345", "sent"), 80 | "two@example.com": AnymailRecipientStatus("12345", "queued"), 81 | } 82 | status = AnymailStatus() 83 | status.set_recipient_status(recipients) 84 | self.assertEqual(status.message_id, "12345") 85 | self.assertEqual( 86 | repr(status), 87 | "AnymailStatus", 89 | ) 90 | 91 | def test_none(self): 92 | status = AnymailStatus() 93 | self.assertIsNone(status.status) 94 | self.assertIsNone(status.message_id) 95 | self.assertEqual(repr(status), "AnymailStatus") 96 | 97 | def test_invalid_message_id(self): 98 | with self.assertRaisesMessage(TypeError, "Invalid message_id"): 99 | AnymailRecipientStatus(["id-list", "is-not-valid"], "queued") 100 | 101 | def test_invalid_status(self): 102 | with self.assertRaisesMessage(ValueError, "Invalid status"): 103 | AnymailRecipientStatus("12345", "not-a-known-status") 104 | -------------------------------------------------------------------------------- /tests/test_postal_integration.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | from email.utils import formataddr 4 | 5 | from django.test import SimpleTestCase, override_settings, tag 6 | 7 | from anymail.exceptions import AnymailAPIError 8 | from anymail.message import AnymailMessage 9 | 10 | from .utils import AnymailTestMixin 11 | 12 | ANYMAIL_TEST_POSTAL_API_KEY = os.getenv("ANYMAIL_TEST_POSTAL_API_KEY") 13 | ANYMAIL_TEST_POSTAL_API_URL = os.getenv("ANYMAIL_TEST_POSTAL_API_URL") 14 | ANYMAIL_TEST_POSTAL_DOMAIN = os.getenv("ANYMAIL_TEST_POSTAL_DOMAIN") 15 | 16 | 17 | @tag("postal", "live") 18 | @unittest.skipUnless( 19 | ANYMAIL_TEST_POSTAL_API_KEY 20 | and ANYMAIL_TEST_POSTAL_API_URL 21 | and ANYMAIL_TEST_POSTAL_DOMAIN, 22 | "Set ANYMAIL_TEST_POSTAL_API_KEY and ANYMAIL_TEST_POSTAL_API_URL and" 23 | " ANYMAIL_TEST_POSTAL_DOMAIN environment variables to run Postal integration tests", 24 | ) 25 | @override_settings( 26 | ANYMAIL_POSTAL_API_KEY=ANYMAIL_TEST_POSTAL_API_KEY, 27 | ANYMAIL_POSTAL_API_URL=ANYMAIL_TEST_POSTAL_API_URL, 28 | EMAIL_BACKEND="anymail.backends.postal.EmailBackend", 29 | ) 30 | class PostalBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): 31 | """Postal API integration tests 32 | 33 | These tests run against the **live** Postal API, using the 34 | environment variable `ANYMAIL_TEST_POSTAL_API_KEY` as the API key and 35 | `ANYMAIL_TEST_POSTAL_API_URL` as server url. 36 | If these variables are not set, these tests won't run. 37 | """ 38 | 39 | def setUp(self): 40 | super().setUp() 41 | self.from_email = "from@%s" % ANYMAIL_TEST_POSTAL_DOMAIN 42 | self.message = AnymailMessage( 43 | "Anymail Postal integration test", 44 | "Text content", 45 | self.from_email, 46 | ["test+to1@anymail.dev"], 47 | ) 48 | self.message.attach_alternative("

HTML content

", "text/html") 49 | 50 | def test_simple_send(self): 51 | # Example of getting the Postal send status and message id from the message 52 | sent_count = self.message.send() 53 | self.assertEqual(sent_count, 1) 54 | 55 | anymail_status = self.message.anymail_status 56 | sent_status = anymail_status.recipients["test+to1@anymail.dev"].status 57 | message_id = anymail_status.recipients["test+to1@anymail.dev"].message_id 58 | 59 | self.assertEqual(sent_status, "queued") 60 | self.assertGreater(len(message_id), 0) # non-empty string 61 | # set of all recipient statuses: 62 | self.assertEqual(anymail_status.status, {sent_status}) 63 | self.assertEqual(anymail_status.message_id, message_id) 64 | 65 | def test_all_options(self): 66 | message = AnymailMessage( 67 | subject="Anymail Postal all-options integration test", 68 | body="This is the text body", 69 | from_email=formataddr(("Test From, with comma", self.from_email)), 70 | envelope_sender="bounces@%s" % ANYMAIL_TEST_POSTAL_DOMAIN, 71 | to=["test+to1@anymail.dev", "Recipient 2 "], 72 | cc=["test+cc1@anymail.dev", "Copy 2 "], 73 | bcc=["test+bcc1@anymail.dev", "Blind Copy 2 "], 74 | reply_to=["reply1@example.com"], 75 | headers={"X-Anymail-Test": "value"}, 76 | tags=["tag 1"], # max one tag 77 | ) 78 | message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain") 79 | message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv") 80 | 81 | message.send() 82 | self.assertEqual(message.anymail_status.status, {"queued"}) 83 | self.assertEqual( 84 | message.anymail_status.recipients["test+to1@anymail.dev"].status, "queued" 85 | ) 86 | self.assertEqual( 87 | message.anymail_status.recipients["test+to2@anymail.dev"].status, "queued" 88 | ) 89 | # distinct messages should have different message_ids: 90 | self.assertNotEqual( 91 | message.anymail_status.recipients["test+to1@anymail.dev"].message_id, 92 | message.anymail_status.recipients["teset+to2@anymail.dev"].message_id, 93 | ) 94 | 95 | def test_invalid_from(self): 96 | self.message.from_email = "webmaster@localhost" # Django's default From 97 | with self.assertRaises(AnymailAPIError) as cm: 98 | self.message.send() 99 | err = cm.exception 100 | response = err.response.json() 101 | self.assertEqual(err.status_code, 200) 102 | self.assertEqual(response["status"], "error") 103 | self.assertIn( 104 | "The From address is not authorised to send mail from this server", 105 | response["data"]["message"], 106 | ) 107 | self.assertIn("UnauthenticatedFromAddress", response["data"]["code"]) 108 | 109 | @override_settings(ANYMAIL_POSTAL_API_KEY="Hey, that's not an API key!") 110 | def test_invalid_server_token(self): 111 | with self.assertRaises(AnymailAPIError) as cm: 112 | self.message.send() 113 | err = cm.exception 114 | response = err.response.json() 115 | self.assertEqual(err.status_code, 200) 116 | self.assertEqual(response["status"], "error") 117 | self.assertIn( 118 | "The API token provided in X-Server-API-Key was not valid.", 119 | response["data"]["message"], 120 | ) 121 | self.assertIn("InvalidServerAPIKey", response["data"]["code"]) 122 | -------------------------------------------------------------------------------- /tests/test_resend_integration.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | from email.utils import formataddr 4 | 5 | from django.test import SimpleTestCase, override_settings, tag 6 | 7 | from anymail.exceptions import AnymailAPIError 8 | from anymail.message import AnymailMessage 9 | 10 | from .utils import AnymailTestMixin 11 | 12 | ANYMAIL_TEST_RESEND_API_KEY = os.getenv("ANYMAIL_TEST_RESEND_API_KEY") 13 | ANYMAIL_TEST_RESEND_DOMAIN = os.getenv("ANYMAIL_TEST_RESEND_DOMAIN") 14 | 15 | 16 | @tag("resend", "live") 17 | @unittest.skipUnless( 18 | ANYMAIL_TEST_RESEND_API_KEY and ANYMAIL_TEST_RESEND_DOMAIN, 19 | "Set ANYMAIL_TEST_RESEND_API_KEY and ANYMAIL_TEST_RESEND_DOMAIN " 20 | "environment variables to run Resend integration tests", 21 | ) 22 | @override_settings( 23 | ANYMAIL_RESEND_API_KEY=ANYMAIL_TEST_RESEND_API_KEY, 24 | EMAIL_BACKEND="anymail.backends.resend.EmailBackend", 25 | ) 26 | class ResendBackendIntegrationTests(AnymailTestMixin, SimpleTestCase): 27 | """Resend.com API integration tests 28 | 29 | Resend doesn't have sandbox so these tests run 30 | against the **live** Resend API, using the 31 | environment variable `ANYMAIL_TEST_RESEND_API_KEY` as the API key, 32 | and `ANYMAIL_TEST_RESEND_DOMAIN` to construct sender addresses. 33 | If those variables are not set, these tests won't run. 34 | 35 | """ 36 | 37 | def setUp(self): 38 | super().setUp() 39 | self.from_email = "from@%s" % ANYMAIL_TEST_RESEND_DOMAIN 40 | self.message = AnymailMessage( 41 | "Anymail Resend integration test", 42 | "Text content", 43 | self.from_email, 44 | ["test+to1@anymail.dev"], 45 | ) 46 | self.message.attach_alternative("

HTML content

", "text/html") 47 | 48 | def test_simple_send(self): 49 | # Example of getting the Resend message id from the message 50 | sent_count = self.message.send() 51 | self.assertEqual(sent_count, 1) 52 | 53 | anymail_status = self.message.anymail_status 54 | sent_status = anymail_status.recipients["test+to1@anymail.dev"].status 55 | message_id = anymail_status.recipients["test+to1@anymail.dev"].message_id 56 | 57 | self.assertEqual(sent_status, "queued") # Resend always queues 58 | self.assertGreater(len(message_id), 0) # non-empty string 59 | # set of all recipient statuses: 60 | self.assertEqual(anymail_status.status, {sent_status}) 61 | self.assertEqual(anymail_status.message_id, message_id) 62 | 63 | def test_all_options(self): 64 | message = AnymailMessage( 65 | subject="Anymail Resend all-options integration test", 66 | body="This is the text body", 67 | # Verify workarounds for address formatting issues: 68 | from_email=formataddr(("Test «Från», med komma", self.from_email)), 69 | to=["test+to1@anymail.dev", '"Recipient 2, OK?" '], 70 | cc=["test+cc1@anymail.dev", "Copy 2 "], 71 | bcc=["test+bcc1@anymail.dev", "Blind Copy 2 "], 72 | reply_to=['"Reply, with comma" ', "reply2@example.com"], 73 | headers={"X-Anymail-Test": "value", "X-Anymail-Count": 3}, 74 | metadata={"meta1": "simple string", "meta2": 2}, 75 | tags=["tag 1", "tag 2"], 76 | # Resend supports send_at or attachments, but not both at once. 77 | # send_at=datetime.now() + timedelta(minutes=2), 78 | ) 79 | message.attach_alternative("

HTML content

", "text/html") 80 | 81 | message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain") 82 | message.attach("attachment2.csv", "ID,Name\n1,Amy Lina", "text/csv") 83 | 84 | message.send() 85 | # Resend always queues: 86 | self.assertEqual(message.anymail_status.status, {"queued"}) 87 | self.assertGreater( 88 | len(message.anymail_status.message_id), 0 89 | ) # non-empty string 90 | 91 | def test_batch_send(self): 92 | # merge_metadata, merge_headers, or merge_data will use batch send API 93 | message = AnymailMessage( 94 | subject="Anymail Resend batch sendintegration test", 95 | body="This is the text body", 96 | from_email=self.from_email, 97 | to=["test+to1@anymail.dev", '"Recipient 2" '], 98 | metadata={"meta1": "simple string", "meta2": 2}, 99 | merge_metadata={ 100 | "test+to1@anymail.dev": {"meta3": "recipient 1"}, 101 | "test+to2@anymail.dev": {"meta3": "recipient 2"}, 102 | }, 103 | tags=["tag 1", "tag 2"], 104 | headers={ 105 | "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", 106 | "List-Unsubscribe": "", 107 | }, 108 | merge_headers={ 109 | "test+to1@anymail.dev": { 110 | "List-Unsubscribe": "", 111 | }, 112 | "test+to2@anymail.dev": { 113 | "List-Unsubscribe": "", 114 | }, 115 | }, 116 | ) 117 | message.attach_alternative("

HTML content

", "text/html") 118 | # Resend does not support attachments in batch send (2024-11-21) 119 | # message.attach("attachment1.txt", "Here is some\ntext for you", "text/plain") 120 | 121 | message.send() 122 | # Resend always queues: 123 | self.assertEqual(message.anymail_status.status, {"queued"}) 124 | recipient_status = message.anymail_status.recipients 125 | self.assertEqual(recipient_status["test+to1@anymail.dev"].status, "queued") 126 | self.assertEqual(recipient_status["test+to2@anymail.dev"].status, "queued") 127 | self.assertRegex(recipient_status["test+to1@anymail.dev"].message_id, r".+") 128 | self.assertRegex(recipient_status["test+to2@anymail.dev"].message_id, r".+") 129 | # Each recipient gets their own message_id: 130 | self.assertNotEqual( 131 | recipient_status["test+to1@anymail.dev"].message_id, 132 | recipient_status["test+to2@anymail.dev"].message_id, 133 | ) 134 | 135 | @unittest.skip("Resend has stopped responding to bad/missing API keys (12/2023)") 136 | @override_settings(ANYMAIL_RESEND_API_KEY="Hey, that's not an API key!") 137 | def test_invalid_api_key(self): 138 | with self.assertRaisesMessage(AnymailAPIError, "API key is invalid"): 139 | self.message.send() 140 | -------------------------------------------------------------------------------- /tests/test_send_signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import receiver 2 | 3 | from anymail.backends.test import EmailBackend as TestEmailBackend 4 | from anymail.exceptions import AnymailCancelSend, AnymailRecipientsRefused 5 | from anymail.message import AnymailRecipientStatus 6 | from anymail.signals import post_send, pre_send 7 | 8 | from .test_general_backend import TestBackendTestCase 9 | 10 | 11 | class TestPreSendSignal(TestBackendTestCase): 12 | """Test Anymail's pre_send signal""" 13 | 14 | def test_pre_send(self): 15 | """Pre-send receivers invoked for each message, before sending""" 16 | 17 | @receiver(pre_send, weak=False) 18 | def handle_pre_send(sender, message, esp_name, **kwargs): 19 | self.assertEqual(self.get_send_count(), 0) # not sent yet 20 | self.assertEqual(sender, TestEmailBackend) 21 | self.assertEqual(message, self.message) 22 | # the TestEmailBackend's ESP is named "Test": 23 | self.assertEqual(esp_name, "Test") 24 | self.receiver_called = True 25 | 26 | self.addCleanup(pre_send.disconnect, receiver=handle_pre_send) 27 | 28 | self.receiver_called = False 29 | self.message.send() 30 | self.assertTrue(self.receiver_called) 31 | self.assertEqual(self.get_send_count(), 1) # sent now 32 | 33 | def test_modify_message_in_pre_send(self): 34 | """Pre-send receivers can modify message""" 35 | 36 | @receiver(pre_send, weak=False) 37 | def handle_pre_send(sender, message, esp_name, **kwargs): 38 | message.to = [email for email in message.to if not email.startswith("bad")] 39 | message.body += "\nIf you have received this message in error, ignore it" 40 | 41 | self.addCleanup(pre_send.disconnect, receiver=handle_pre_send) 42 | 43 | self.message.to = ["legit@example.com", "bad@example.com"] 44 | self.message.send() 45 | params = self.get_send_params() 46 | self.assertEqual( 47 | # params['to'] is EmailAddress list: 48 | [email.addr_spec for email in params["to"]], 49 | ["legit@example.com"], 50 | ) 51 | self.assertRegex( 52 | params["text_body"], 53 | r"If you have received this message in error, ignore it$", 54 | ) 55 | 56 | def test_cancel_in_pre_send(self): 57 | """Pre-send receiver can cancel the send""" 58 | 59 | @receiver(pre_send, weak=False) 60 | def cancel_pre_send(sender, message, esp_name, **kwargs): 61 | raise AnymailCancelSend("whoa there") 62 | 63 | self.addCleanup(pre_send.disconnect, receiver=cancel_pre_send) 64 | 65 | self.message.send() 66 | self.assertEqual(self.get_send_count(), 0) # send API not called 67 | 68 | 69 | class TestPostSendSignal(TestBackendTestCase): 70 | """Test Anymail's post_send signal""" 71 | 72 | def test_post_send(self): 73 | """Post-send receiver called for each message, after sending""" 74 | 75 | @receiver(post_send, weak=False) 76 | def handle_post_send(sender, message, status, esp_name, **kwargs): 77 | self.assertEqual(self.get_send_count(), 1) # already sent 78 | self.assertEqual(sender, TestEmailBackend) 79 | self.assertEqual(message, self.message) 80 | self.assertEqual(status.status, {"sent"}) 81 | self.assertEqual(status.message_id, 0) 82 | self.assertEqual(status.recipients["to@example.com"].status, "sent") 83 | self.assertEqual(status.recipients["to@example.com"].message_id, 0) 84 | # the TestEmailBackend's ESP is named "Test": 85 | self.assertEqual(esp_name, "Test") 86 | self.receiver_called = True 87 | 88 | self.addCleanup(post_send.disconnect, receiver=handle_post_send) 89 | 90 | self.receiver_called = False 91 | self.message.send() 92 | self.assertTrue(self.receiver_called) 93 | 94 | def test_post_send_exception(self): 95 | """All post-send receivers called, even if one throws""" 96 | 97 | @receiver(post_send, weak=False) 98 | def handler_1(sender, message, status, esp_name, **kwargs): 99 | raise ValueError("oops") 100 | 101 | self.addCleanup(post_send.disconnect, receiver=handler_1) 102 | 103 | @receiver(post_send, weak=False) 104 | def handler_2(sender, message, status, esp_name, **kwargs): 105 | self.handler_2_called = True 106 | 107 | self.addCleanup(post_send.disconnect, receiver=handler_2) 108 | 109 | self.handler_2_called = False 110 | with self.assertRaises(ValueError): 111 | self.message.send() 112 | self.assertTrue(self.handler_2_called) 113 | 114 | def test_rejected_recipients(self): 115 | """Post-send receiver even if AnymailRecipientsRefused is raised""" 116 | 117 | @receiver(post_send, weak=False) 118 | def handle_post_send(sender, message, status, esp_name, **kwargs): 119 | self.receiver_called = True 120 | 121 | self.addCleanup(post_send.disconnect, receiver=handle_post_send) 122 | 123 | self.message.anymail_test_response = { 124 | "recipient_status": { 125 | "to@example.com": AnymailRecipientStatus( 126 | message_id=None, status="rejected" 127 | ) 128 | } 129 | } 130 | 131 | self.receiver_called = False 132 | with self.assertRaises(AnymailRecipientsRefused): 133 | self.message.send() 134 | self.assertTrue(self.receiver_called) 135 | -------------------------------------------------------------------------------- /tests/test_sendinblue_deprecations.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import ANY 2 | 3 | from django.core.mail import EmailMessage, send_mail 4 | from django.test import ignore_warnings, override_settings, tag 5 | 6 | from anymail.exceptions import AnymailConfigurationError, AnymailDeprecationWarning 7 | from anymail.webhooks.sendinblue import ( 8 | SendinBlueInboundWebhookView, 9 | SendinBlueTrackingWebhookView, 10 | ) 11 | 12 | from .mock_requests_backend import RequestsBackendMockAPITestCase 13 | from .webhook_cases import WebhookTestCase 14 | 15 | 16 | @tag("brevo", "sendinblue") 17 | @override_settings( 18 | EMAIL_BACKEND="anymail.backends.sendinblue.EmailBackend", 19 | ANYMAIL={"SENDINBLUE_API_KEY": "test_api_key"}, 20 | ) 21 | @ignore_warnings(category=AnymailDeprecationWarning) 22 | class SendinBlueBackendDeprecationTests(RequestsBackendMockAPITestCase): 23 | DEFAULT_RAW_RESPONSE = ( 24 | b'{"messageId":"<201801020304.1234567890@smtp-relay.mailin.fr>"}' 25 | ) 26 | DEFAULT_STATUS_CODE = 201 # Brevo v3 uses '201 Created' for success (in most cases) 27 | 28 | def test_deprecation_warning(self): 29 | message = EmailMessage( 30 | "Subject", "Body", "from@example.com", ["to@example.com"] 31 | ) 32 | with self.assertWarnsMessage( 33 | AnymailDeprecationWarning, 34 | "`anymail.backends.sendinblue.EmailBackend` has been renamed" 35 | " `anymail.backends.brevo.EmailBackend`.", 36 | ): 37 | message.send() 38 | self.assert_esp_called("https://api.brevo.com/v3/smtp/email") 39 | 40 | @override_settings(ANYMAIL={"BREVO_API_KEY": "test_api_key"}) 41 | def test_missing_api_key_error_uses_correct_setting_name(self): 42 | # The sendinblue.EmailBackend requires SENDINBLUE_ settings names 43 | with self.assertRaisesMessage(AnymailConfigurationError, "SENDINBLUE_API_KEY"): 44 | send_mail("Subject", "Body", "from@example.com", ["to@example.com"]) 45 | 46 | 47 | @tag("brevo", "sendinblue") 48 | @ignore_warnings(category=AnymailDeprecationWarning) 49 | class SendinBlueTrackingWebhookDeprecationTests(WebhookTestCase): 50 | def test_deprecation_warning(self): 51 | with self.assertWarnsMessage( 52 | AnymailDeprecationWarning, 53 | "Anymail's SendinBlue webhook URLs are deprecated.", 54 | ): 55 | response = self.client.post( 56 | "/anymail/sendinblue/tracking/", 57 | content_type="application/json", 58 | data="{}", 59 | ) 60 | self.assertEqual(response.status_code, 200) 61 | # Old url uses old names to preserve compatibility: 62 | self.assert_handler_called_once_with( 63 | self.tracking_handler, 64 | sender=SendinBlueTrackingWebhookView, # *not* BrevoTrackingWebhookView 65 | event=ANY, 66 | esp_name="SendinBlue", # *not* "Brevo" 67 | ) 68 | 69 | def test_misconfigured_inbound(self): 70 | # Uses old esp_name when called on old URL 71 | errmsg = ( 72 | "You seem to have set Brevo's *inbound* webhook URL" 73 | " to Anymail's SendinBlue *tracking* webhook URL." 74 | ) 75 | with self.assertRaisesMessage(AnymailConfigurationError, errmsg): 76 | self.client.post( 77 | "/anymail/sendinblue/tracking/", 78 | content_type="application/json", 79 | data={"items": []}, 80 | ) 81 | 82 | 83 | @tag("brevo", "sendinblue") 84 | @override_settings(ANYMAIL_SENDINBLUE_API_KEY="test-api-key") 85 | @ignore_warnings(category=AnymailDeprecationWarning) 86 | class SendinBlueInboundWebhookDeprecationTests(WebhookTestCase): 87 | def test_deprecation_warning(self): 88 | with self.assertWarnsMessage( 89 | AnymailDeprecationWarning, 90 | "Anymail's SendinBlue webhook URLs are deprecated.", 91 | ): 92 | response = self.client.post( 93 | "/anymail/sendinblue/inbound/", 94 | content_type="application/json", 95 | data='{"items":[{}]}', 96 | ) 97 | self.assertEqual(response.status_code, 200) 98 | # Old url uses old names to preserve compatibility: 99 | self.assert_handler_called_once_with( 100 | self.inbound_handler, 101 | sender=SendinBlueInboundWebhookView, # *not* BrevoInboundWebhookView 102 | event=ANY, 103 | esp_name="SendinBlue", # *not* "Brevo" 104 | ) 105 | 106 | def test_misconfigured_tracking(self): 107 | # Uses old esp_name when called on old URL 108 | errmsg = ( 109 | "You seem to have set Brevo's *tracking* webhook URL" 110 | " to Anymail's SendinBlue *inbound* webhook URL." 111 | ) 112 | with self.assertRaisesMessage(AnymailConfigurationError, errmsg): 113 | self.client.post( 114 | "/anymail/sendinblue/inbound/", 115 | content_type="application/json", 116 | data={"event": "delivered"}, 117 | ) 118 | -------------------------------------------------------------------------------- /tests/test_settings/__init__.py: -------------------------------------------------------------------------------- 1 | # These are the default "django-admin startproject" settings.py files 2 | # for each supported version of Django, with: 3 | # 4 | # * "anymail" appended to INSTALLED_APPS 5 | # * ROOT_URLCONF = 'tests.test_settings.urls' 6 | # 7 | # Keeping a file for each Django version is simpler than trying 8 | # to maintain warning-free compatibility with all Django versions 9 | # in a single settings list. 10 | # 11 | # It also helps ensure compatibility with default apps, middleware, etc. 12 | # 13 | # You can typically find the default settings.py template in 14 | # SITE_PACKAGES/django/conf/project_template/project_name/settings.py-tpl 15 | -------------------------------------------------------------------------------- /tests/test_settings/settings_4_0.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for Anymail tests. 3 | 4 | Generated by 'django-admin startproject' using Django 4.0.1. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.0/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'NOT_FOR_PRODUCTION_USE' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 'anymail', 41 | ] 42 | 43 | MIDDLEWARE = [ 44 | 'django.middleware.security.SecurityMiddleware', 45 | 'django.contrib.sessions.middleware.SessionMiddleware', 46 | 'django.middleware.common.CommonMiddleware', 47 | 'django.middleware.csrf.CsrfViewMiddleware', 48 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 49 | 'django.contrib.messages.middleware.MessageMiddleware', 50 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 51 | ] 52 | 53 | ROOT_URLCONF = 'tests.test_settings.urls' 54 | 55 | TEMPLATES = [ 56 | { 57 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 58 | 'DIRS': [], 59 | 'APP_DIRS': True, 60 | 'OPTIONS': { 61 | 'context_processors': [ 62 | 'django.template.context_processors.debug', 63 | 'django.template.context_processors.request', 64 | 'django.contrib.auth.context_processors.auth', 65 | 'django.contrib.messages.context_processors.messages', 66 | ], 67 | }, 68 | }, 69 | ] 70 | 71 | WSGI_APPLICATION = 'tests.wsgi.application' 72 | 73 | 74 | # Database 75 | # https://docs.djangoproject.com/en/4.0/ref/settings/#databases 76 | 77 | DATABASES = { 78 | 'default': { 79 | 'ENGINE': 'django.db.backends.sqlite3', 80 | 'NAME': BASE_DIR / 'db.sqlite3', 81 | } 82 | } 83 | 84 | 85 | # Password validation 86 | # https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators 87 | 88 | AUTH_PASSWORD_VALIDATORS = [ 89 | { 90 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 91 | }, 92 | { 93 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 94 | }, 95 | { 96 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 97 | }, 98 | { 99 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 100 | }, 101 | ] 102 | 103 | 104 | # Internationalization 105 | # https://docs.djangoproject.com/en/4.0/topics/i18n/ 106 | 107 | LANGUAGE_CODE = 'en-us' 108 | 109 | TIME_ZONE = 'UTC' 110 | 111 | USE_I18N = True 112 | 113 | USE_TZ = True 114 | 115 | 116 | # Static files (CSS, JavaScript, Images) 117 | # https://docs.djangoproject.com/en/4.0/howto/static-files/ 118 | 119 | STATIC_URL = 'static/' 120 | 121 | # Default primary key field type 122 | # https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field 123 | 124 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 125 | -------------------------------------------------------------------------------- /tests/test_settings/settings_5_0.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for Anymail tests. 3 | 4 | Generated by 'django-admin startproject' using Django 5.0.6. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/5.0/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = "NOT_FOR_PRODUCTION_USE" 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | "django.contrib.admin", 35 | "django.contrib.auth", 36 | "django.contrib.contenttypes", 37 | "django.contrib.sessions", 38 | "django.contrib.messages", 39 | "django.contrib.staticfiles", 40 | "anymail", 41 | ] 42 | 43 | MIDDLEWARE = [ 44 | "django.middleware.security.SecurityMiddleware", 45 | "django.contrib.sessions.middleware.SessionMiddleware", 46 | "django.middleware.common.CommonMiddleware", 47 | "django.middleware.csrf.CsrfViewMiddleware", 48 | "django.contrib.auth.middleware.AuthenticationMiddleware", 49 | "django.contrib.messages.middleware.MessageMiddleware", 50 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 51 | ] 52 | 53 | ROOT_URLCONF = "tests.test_settings.urls" 54 | 55 | TEMPLATES = [ 56 | { 57 | "BACKEND": "django.template.backends.django.DjangoTemplates", 58 | "DIRS": [], 59 | "APP_DIRS": True, 60 | "OPTIONS": { 61 | "context_processors": [ 62 | "django.template.context_processors.debug", 63 | "django.template.context_processors.request", 64 | "django.contrib.auth.context_processors.auth", 65 | "django.contrib.messages.context_processors.messages", 66 | ], 67 | }, 68 | }, 69 | ] 70 | 71 | WSGI_APPLICATION = "tests.wsgi.application" 72 | 73 | 74 | # Database 75 | # https://docs.djangoproject.com/en/5.0/ref/settings/#databases 76 | 77 | DATABASES = { 78 | "default": { 79 | "ENGINE": "django.db.backends.sqlite3", 80 | "NAME": BASE_DIR / "db.sqlite3", 81 | } 82 | } 83 | 84 | 85 | # Password validation 86 | # https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators 87 | 88 | AUTH_PASSWORD_VALIDATORS = [ 89 | { 90 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 91 | }, 92 | { 93 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 94 | }, 95 | { 96 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 97 | }, 98 | { 99 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 100 | }, 101 | ] 102 | 103 | 104 | # Internationalization 105 | # https://docs.djangoproject.com/en/5.0/topics/i18n/ 106 | 107 | LANGUAGE_CODE = "en-us" 108 | 109 | TIME_ZONE = "UTC" 110 | 111 | USE_I18N = True 112 | 113 | USE_TZ = True 114 | 115 | 116 | # Static files (CSS, JavaScript, Images) 117 | # https://docs.djangoproject.com/en/5.0/howto/static-files/ 118 | 119 | STATIC_URL = "static/" 120 | 121 | # Default primary key field type 122 | # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field 123 | 124 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 125 | -------------------------------------------------------------------------------- /tests/test_settings/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | 3 | urlpatterns = [ 4 | path("anymail/", include("anymail.urls")), 5 | ] 6 | -------------------------------------------------------------------------------- /tests/utils_postal.py: -------------------------------------------------------------------------------- 1 | from base64 import b64encode 2 | 3 | from django.test import override_settings 4 | 5 | from tests.utils import ClientWithCsrfChecks 6 | 7 | HAS_CRYPTOGRAPHY = True 8 | try: 9 | from cryptography.hazmat.primitives import hashes, serialization 10 | from cryptography.hazmat.primitives.asymmetric import padding, rsa 11 | except ImportError: 12 | HAS_CRYPTOGRAPHY = False 13 | 14 | 15 | def make_key(): 16 | """Generate RSA public key with short key size, for testing only""" 17 | private_key = rsa.generate_private_key( 18 | public_exponent=65537, 19 | key_size=1024, 20 | ) 21 | return private_key 22 | 23 | 24 | def derive_public_webhook_key(private_key): 25 | """Derive public""" 26 | public_key = private_key.public_key() 27 | public_bytes = public_key.public_bytes( 28 | encoding=serialization.Encoding.PEM, 29 | format=serialization.PublicFormat.SubjectPublicKeyInfo, 30 | ) 31 | public_bytes = b"\n".join(public_bytes.splitlines()[1:-1]) 32 | return public_bytes.decode("utf-8") 33 | 34 | 35 | def sign(private_key, message): 36 | """Sign message with private key""" 37 | signature = private_key.sign(message, padding.PKCS1v15(), hashes.SHA1()) 38 | return signature 39 | 40 | 41 | class _ClientWithPostalSignature(ClientWithCsrfChecks): 42 | private_key = None 43 | 44 | def set_private_key(self, private_key): 45 | self.private_key = private_key 46 | 47 | def post(self, *args, **kwargs): 48 | signature = b64encode(sign(self.private_key, kwargs["data"].encode("utf-8"))) 49 | kwargs.setdefault("HTTP_X_POSTAL_SIGNATURE", signature) 50 | 51 | webhook_key = derive_public_webhook_key(self.private_key) 52 | with override_settings(ANYMAIL={"POSTAL_WEBHOOK_KEY": webhook_key}): 53 | return super().post(*args, **kwargs) 54 | 55 | 56 | ClientWithPostalSignature = _ClientWithPostalSignature if HAS_CRYPTOGRAPHY else None 57 | -------------------------------------------------------------------------------- /tests/webhook_cases.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from unittest.mock import ANY, create_autospec 3 | 4 | from django.test import SimpleTestCase, override_settings 5 | 6 | from anymail.exceptions import AnymailInsecureWebhookWarning 7 | from anymail.signals import inbound, tracking 8 | 9 | from .utils import AnymailTestMixin, ClientWithCsrfChecks 10 | 11 | 12 | def event_handler(sender, event, esp_name, **kwargs): 13 | """Prototypical webhook signal handler""" 14 | pass 15 | 16 | 17 | @override_settings(ANYMAIL={"WEBHOOK_SECRET": "username:password"}) 18 | class WebhookTestCase(AnymailTestMixin, SimpleTestCase): 19 | """Base for testing webhooks 20 | 21 | - connects webhook signal handlers 22 | - sets up basic auth by default (since most ESP webhooks warn if it's not enabled) 23 | """ 24 | 25 | client_class = ClientWithCsrfChecks 26 | 27 | def setUp(self): 28 | super().setUp() 29 | # Use correct basic auth by default (individual tests can override): 30 | self.set_basic_auth() 31 | 32 | # Install mocked signal handlers 33 | self.tracking_handler = create_autospec(event_handler) 34 | tracking.connect(self.tracking_handler) 35 | self.addCleanup(tracking.disconnect, self.tracking_handler) 36 | 37 | self.inbound_handler = create_autospec(event_handler) 38 | inbound.connect(self.inbound_handler) 39 | self.addCleanup(inbound.disconnect, self.inbound_handler) 40 | 41 | def set_basic_auth(self, username="username", password="password"): 42 | """Set basic auth for all subsequent test client requests""" 43 | credentials = base64.b64encode( 44 | "{}:{}".format(username, password).encode("utf-8") 45 | ).decode("utf-8") 46 | self.client.defaults["HTTP_AUTHORIZATION"] = "Basic {}".format(credentials) 47 | 48 | def clear_basic_auth(self): 49 | self.client.defaults.pop("HTTP_AUTHORIZATION", None) 50 | 51 | def assert_handler_called_once_with( 52 | self, mockfn, *expected_args, **expected_kwargs 53 | ): 54 | """Verifies mockfn was called with expected_args and at least expected_kwargs. 55 | 56 | Ignores *additional* actual kwargs 57 | (which might be added by Django signal dispatch). 58 | (This differs from mock.assert_called_once_with.) 59 | 60 | Returns the actual kwargs. 61 | """ 62 | self.assertEqual(mockfn.call_count, 1) 63 | actual_args, actual_kwargs = mockfn.call_args 64 | self.assertEqual(actual_args, expected_args) 65 | for key, expected_value in expected_kwargs.items(): 66 | if expected_value is ANY: 67 | self.assertIn(key, actual_kwargs) 68 | else: 69 | self.assertEqual(actual_kwargs[key], expected_value) 70 | return actual_kwargs 71 | 72 | def get_kwargs(self, mockfn): 73 | """Return the kwargs passed to the most recent call to mockfn""" 74 | self.assertIsNotNone(mockfn.call_args) # mockfn hasn't been called yet 75 | actual_args, actual_kwargs = mockfn.call_args 76 | return actual_kwargs 77 | 78 | 79 | class WebhookBasicAuthTestCase(WebhookTestCase): 80 | """Common test cases for webhook basic authentication. 81 | 82 | Instantiate for each ESP's webhooks by: 83 | - subclassing 84 | - defining call_webhook to invoke the ESP's webhook 85 | - adding or overriding any tests as appropriate 86 | """ 87 | 88 | def __init__(self, methodName="runTest"): 89 | if self.__class__ is WebhookBasicAuthTestCase: 90 | # don't run these tests on the abstract base implementation 91 | methodName = "runNoTestsInBaseClass" 92 | super().__init__(methodName) 93 | 94 | def runNoTestsInBaseClass(self): 95 | pass 96 | 97 | #: subclass set False if other webhook verification used 98 | should_warn_if_no_auth = True 99 | 100 | def call_webhook(self): 101 | # Concrete test cases should call a webhook via self.client.post, 102 | # and return the response 103 | raise NotImplementedError() 104 | 105 | @override_settings(ANYMAIL={}) # Clear the WEBHOOK_AUTH settings from superclass 106 | def test_warns_if_no_auth(self): 107 | if self.should_warn_if_no_auth: 108 | with self.assertWarns(AnymailInsecureWebhookWarning): 109 | response = self.call_webhook() 110 | else: 111 | with self.assertDoesNotWarn(AnymailInsecureWebhookWarning): 112 | response = self.call_webhook() 113 | self.assertEqual(response.status_code, 200) 114 | 115 | def test_verifies_basic_auth(self): 116 | response = self.call_webhook() 117 | self.assertEqual(response.status_code, 200) 118 | 119 | def test_verifies_bad_auth(self): 120 | self.set_basic_auth("baduser", "wrongpassword") 121 | response = self.call_webhook() 122 | self.assertEqual(response.status_code, 400) 123 | 124 | def test_verifies_missing_auth(self): 125 | self.clear_basic_auth() 126 | response = self.call_webhook() 127 | self.assertEqual(response.status_code, 400) 128 | 129 | @override_settings(ANYMAIL={"WEBHOOK_SECRET": ["cred1:pass1", "cred2:pass2"]}) 130 | def test_supports_credential_rotation(self): 131 | """You can supply a list of basic auth credentials, and any is allowed""" 132 | self.set_basic_auth("cred1", "pass1") 133 | response = self.call_webhook() 134 | self.assertEqual(response.status_code, 200) 135 | 136 | self.set_basic_auth("cred2", "pass2") 137 | response = self.call_webhook() 138 | self.assertEqual(response.status_code, 200) 139 | 140 | self.set_basic_auth("baduser", "wrongpassword") 141 | response = self.call_webhook() 142 | self.assertEqual(response.status_code, 400) 143 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | # Anymail supports the same Python versions as Django, plus PyPy. 4 | # https://docs.djangoproject.com/en/dev/faq/install/#what-python-version-can-i-use-with-django 5 | # Factors: django-python-extras 6 | # Test lint, docs, earliest/latest Django first, to catch most errors early... 7 | lint 8 | django52-py313-all 9 | django40-py38-all 10 | docs 11 | # ... then test all the other supported combinations: 12 | # Django 5.2: Python 3.10, 3.11, 3.12 and 3.13 (3.13 is above) 13 | django52-py{310,311,312}-all 14 | # Django 5.1: Python 3.10, 3.11, and 3.12 15 | django51-py{310,311,312}-all 16 | # Django 5.0: Python 3.10, 3.11, and 3.12 17 | django50-py{310,311,312}-all 18 | # Django 4.2: Python 3.8, 3.9, 3.10, 3.11 19 | django42-py{38,39,310,311,py38,py39}-all 20 | # Django 4.1: Python 3.8, 3.9, 3.10 21 | django41-py{38,39,310,py38,py39}-all 22 | # Django 4.0: Python 3.8 (above), 3.9, 3.10 23 | django40-py{39,310,py38,py39}-all 24 | # ... then pre-releases (if available) and current development: 25 | # Django 6.0 dev: Python 3.12 and 3.13 26 | djangoDev-py{312,313}-all 27 | # ... then partial installation (limit extras): 28 | django52-py313-{none,amazon_ses,postal,resend} 29 | # tox requires isolated builds to use pyproject.toml build config: 30 | isolated_build = True 31 | 32 | [testenv] 33 | args_are_paths = false 34 | # Download latest version of pip/setuptools available on each Python version: 35 | download = true 36 | deps = 37 | -rtests/requirements.txt 38 | django40: django~=4.0.0 39 | django41: django~=4.1.0 40 | django42: django~=4.2.0 41 | django50: django~=5.0.0 42 | django51: django~=5.1.0 43 | django52: django~=5.2.0a0 44 | # django60: django~=6.0.0a0 45 | djangoDev: https://github.com/django/django/tarball/main 46 | extras = 47 | # Install [esp-name] extras only when testing "all" or esp_name factor. 48 | # (Only ESPs with extra dependencies need to be listed here. 49 | # Careful: tox factors (on the left) use underscore; extra names use hyphen.) 50 | all,amazon_ses: amazon-ses 51 | all,postal: postal 52 | all,resend: resend 53 | setenv = 54 | # tell runtests.py to limit some test tags based on extras factor 55 | # (resend should work with or without its extras, so it isn't in `none`) 56 | none: ANYMAIL_SKIP_TESTS=amazon_ses,postal 57 | amazon_ses: ANYMAIL_ONLY_TEST=amazon_ses 58 | brevo: ANYMAIL_ONLY_TEST=brevo 59 | mailersend: ANYMAIL_ONLY_TEST=mailersend 60 | mailgun: ANYMAIL_ONLY_TEST=mailgun 61 | mailjet: ANYMAIL_ONLY_TEST=mailjet 62 | mandrill: ANYMAIL_ONLY_TEST=mandrill 63 | postal: ANYMAIL_ONLY_TEST=postal 64 | postmark: ANYMAIL_ONLY_TEST=postmark 65 | resend: ANYMAIL_ONLY_TEST=resend 66 | sendgrid: ANYMAIL_ONLY_TEST=sendgrid 67 | unisender_go: ANYMAIL_ONLY_TEST=unisender_go 68 | sparkpost: ANYMAIL_ONLY_TEST=sparkpost 69 | ignore_outcome = 70 | # CI that wants to handle errors itself can set TOX_OVERRIDE_IGNORE_OUTCOME=false 71 | djangoDev: {env:TOX_OVERRIDE_IGNORE_OUTCOME:true} 72 | commands_pre = 73 | python -VV 74 | python -m pip --version 75 | python -c 'import django; print("Django", django.__version__)' 76 | commands = 77 | python runtests.py {posargs} 78 | passenv = 79 | ANYMAIL_ONLY_TEST 80 | ANYMAIL_SKIP_TESTS 81 | ANYMAIL_RUN_LIVE_TESTS 82 | CONTINUOUS_INTEGRATION 83 | ANYMAIL_TEST_* 84 | 85 | [testenv:lint] 86 | basepython = python3.13 87 | skip_install = true 88 | passenv = 89 | CONTINUOUS_INTEGRATION 90 | # Make sure pre-commit can clone hook repos over ssh or http proxy. 91 | # https://pre-commit.com/#usage-with-tox 92 | SSH_AUTH_SOCK 93 | http_proxy 94 | https_proxy 95 | no_proxy 96 | # (but not any of the live test API keys) 97 | deps = 98 | pre-commit 99 | commands_pre = 100 | python -VV 101 | pre-commit --version 102 | commands = 103 | pre-commit validate-config 104 | pre-commit run --all-files 105 | 106 | [testenv:docs] 107 | basepython = python3.13 108 | passenv = 109 | CONTINUOUS_INTEGRATION 110 | GOOGLE_ANALYTICS_ID 111 | # (but not any of the live test API keys) 112 | setenv = 113 | DOCS_BUILD_DIR={envdir}/_html 114 | deps = 115 | -rdocs/requirements.txt 116 | commands_pre = 117 | python -VV 118 | sphinx-build --version 119 | commands = 120 | # Build and verify docs: 121 | sphinx-build -W -b dirhtml docs {env:DOCS_BUILD_DIR} 122 | # Build and verify package metadata readme. 123 | # Errors here are in README.rst: 124 | python docs/_readme/render.py \ 125 | --package django-anymail \ 126 | --out {env:DOCS_BUILD_DIR}/readme.html 127 | --------------------------------------------------------------------------------