.
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 |
22 |
27 |
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("