Skip to content

Commit c924c9e

Browse files
committed
Add developer notes on supporting new ESPs
(This should eventually migrate to the docs.)
1 parent 771d404 commit c924c9e

File tree

1 file changed

+247
-0
lines changed

1 file changed

+247
-0
lines changed

ADDING_ESPS.md

+247
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
# Adding a new ESP
2+
3+
Some developer notes on adding support for a new ESP.
4+
5+
Please refer to the comments in Anymail's code---most of the
6+
extension points are (reasonably) well documented, and will
7+
indicate what you need to implement and what Anymail provides
8+
for you.
9+
10+
This document adds general background and covers some design
11+
decisions that aren't necessarily obvious from the code.
12+
13+
14+
## Getting started
15+
16+
* Don't want to do *all* of this? **That's OK!** A partial PR
17+
is better than no PR. And opening a work-in-progress PR
18+
early is a really good idea.
19+
* Don't want to do *any* of this? Use GitHub issues to request
20+
support for other ESPs in Anymail.
21+
* It's often easiest to copy and modify the existing code
22+
for an ESP with a similar API. There are some hints in each
23+
section below what might be "similar".
24+
25+
26+
### Which ESPs?
27+
28+
Anymail is best suited to *transactional* ESP APIs. The Django core
29+
mail package it builds on isn't a good match for most *bulk* mail APIs.
30+
(If you can't specify an individual recipient email address and at
31+
least some of the message content, it's probably not a transactional API.)
32+
33+
Similarly, Anymail is best suited to ESPs that offer some value-added
34+
features beyond simply sending email. If you'd get exactly the same
35+
results by pointing Django's built-in SMTP EmailBackend at the ESP's
36+
SMTP endpoint, there's really no need to add it to Anymail.
37+
38+
We strongly prefer ESPs where we'll be able to run live integration
39+
tests regularly. That requires the ESP have a free tier (testing is
40+
extremely low volume), a sandbox API, or that they offer developer
41+
accounts for open source projects like Anymail.
42+
43+
44+
## EmailBackend and payload
45+
46+
Anymail abstracts a lot of common functionality into its base classes;
47+
your code should be able to focus on the ESP-specific parts.
48+
49+
You'll subclass a backend and a payload for your ESP implementation:
50+
51+
* Backend (subclass `AnymailBaseBackend` or `AnymailRequestsBackend`):
52+
implements Django's email API, orchestrates the overall sending process
53+
for multiple messages.
54+
55+
* Payload (subclass `BasePayload` or `RequestsPayload`)
56+
implements conversion of a single Django `EmailMessage` to parameters
57+
for the ESP API.
58+
59+
Whether you start from the base or requests version depends on whether
60+
you'll be using an ESP client library or calling their HTTP API directly.
61+
62+
63+
### Client lib or HTTP API?
64+
65+
Which to pick? It's a bit of a judgement call:
66+
67+
* Often, ESP Python client libraries don't seem to be actively maintained.
68+
Definitely avoid those.
69+
70+
* Some client libraries are just thin wrappers around Requests (or urllib).
71+
There's little value in using those, and you'll lose some optimizations
72+
built into `AnymailRequestsBackend`.
73+
74+
* Surprisingly often, client libraries (unintentionally) impose limitations
75+
that are more restrictive than than (or conflict with) the underlying ESP API.
76+
You should report those bugs against the library. (Or if they were already
77+
reported a long time ago, see the first point above.)
78+
79+
* Some ESP APIs have really complex (or obscure) payload formats,
80+
or authorization schemes that are non-trivial to implement in Requests.
81+
If the client library handles this for you, it's a better choice.
82+
83+
When in doubt, it's usually fine to use `AnymailRequestsBackend` and write
84+
directly to the HTTP API.
85+
86+
87+
### Requests backend (using HTTP API)
88+
89+
Good staring points for similar ESP APIs:
90+
* JSON payload: Postmark
91+
* Form-encoded payload: Mailgun
92+
93+
Different API endpoints for (e.g.,) template vs. regular send?
94+
Implement `get_api_endpoint()` in your Payload.
95+
96+
Need to encode JSON in the payload? Use `self.serialize_json()`
97+
(it has some extra error handling).
98+
99+
Need to parse JSON in the API response? Use `self.deserialize_json_response()`
100+
(same reason).
101+
102+
103+
### Base backend (using client lib)
104+
105+
Good starting points: Test backend; SparkPost
106+
107+
Don't forget add an `'extras_require'` entry for your ESP in setup.py.
108+
Also update `'tests_require'`.
109+
110+
If the client lib supports the notion of a reusable API "connection"
111+
(or session), you should override `open()` and `close()` to provide
112+
API state caching. See the notes in the base implementation.
113+
(The RequestsBackend implements this using Requests sessions.)
114+
115+
116+
### Payloads
117+
118+
Look for the "Abstract implementation" comment in `base.BasePayload`.
119+
Your payload should consider implementing everything below there.
120+
121+
122+
#### Email addresses
123+
124+
All payload methods dealing with email addresses (recipients, from, etc.) are
125+
passed `anymail.utils.EmailAddress` objects, so you don't have to parse them.
126+
127+
`email.display_name` is the name, `email.addr_spec` is the email, and
128+
`str(email)` is both fully formatted, with a properly-quoted display name.
129+
130+
For recipients, you can implement whichever of these Payload methods
131+
is most convenient for the ESP API:
132+
* `set_to(emails)`, `set_cc(emails)`, and `set_bcc(emails)`
133+
* `set_recipients(type, emails)`
134+
* `add_recipient(type, email)`
135+
136+
137+
#### Attachments
138+
139+
The payload `set_attachments()`/`add_attachment()` methods are passed
140+
`anymail.utils.Attachment` objects, which are normalized so you don't have
141+
to handle the variety of formats Django allows. All have `name`, `content`
142+
(as a bytestream) and `mimetype` properties.
143+
144+
Use `att.inline` to determine if the attachment is meant to be inline.
145+
(Don't just check for content-id.) If so, `att.content_id` is the Content-ID
146+
with angle brackets, and `att.cid` is without angle brackets.
147+
148+
Use `att.base64content` if your ESP wants base64-encoded data.
149+
150+
151+
#### AnymailUnsupportedFeature and validating parameters
152+
153+
Should your payload use `self.unsupported_feature()`? The rule of thumb is:
154+
155+
* If it *cannot be accurately communicated* to the ESP API, that's unsupported.
156+
E.g., the user provided multiple `tags` but the ESP's "Tag" parameter only accepts
157+
a (single) string value.
158+
159+
* Anymail avoids enforcing ESP policies (because these tend to change over time, and
160+
we don't want to update our code). So if it *can* be accurately communicated to the
161+
ESP API, that's *not* unsupported---even if the ESP docs say it's not allowed.
162+
E.g., the user provided 10 `tags`, the ESP's "Tags" parameter accepts a list,
163+
but is documented maximum 3 tags. Anymail should pass the list of 10 tags,
164+
and let the ESP error if it chooses.
165+
166+
Similarly, Anymail doesn't enforce allowed attachment types, maximum attachment size,
167+
maximum number of recipients, etc. That's the ESP's responsibility.
168+
169+
One exception: if the ESP mis-handles certain input (e.g., drops the message
170+
but returns "success"; mangles email display names), and seems unlikely to fix the problem,
171+
we'll typically add a warning or workaround to Anymail.
172+
(As well as reporting the problem to the ESP.)
173+
174+
175+
#### Batch send and splitting `to`
176+
177+
One of the more complicated Payload functions is handling multiple `to` addresses properly.
178+
179+
If the user has set `merge_data`, a separate message should get sent to each `to` address,
180+
and recipients should not see the full To list. If `merge_data` is not set, a single message
181+
should be sent with all addresses in the To header.
182+
183+
Most backends handle this in the Payload's `serialize_data` method, by restructuring
184+
the payload if `merge_data` is not None.
185+
186+
187+
#### Tests
188+
189+
Every backend needs mock tests, that use a mocked API to verify the ESP is being called
190+
correctly. It's often easiest to copy and modify the backend tests for an ESP with a similar
191+
API.
192+
193+
Ideally, every backend should also have live integration tests, because sometimes the docs
194+
don't quite match the real world. (And because ESPs have been known to change APIs without
195+
notice.) Anymail's CI runs the live integration tests at least weekly.
196+
197+
198+
## Webhooks
199+
200+
ESP webhook documentation is *almost always* vague on at least some aspects of the webhook
201+
event data, and example payloads in their docs are often outdated (and/or manually constructed
202+
and inaccurate).
203+
204+
Runscope (or similar) is an extremely useful tool for collecting actual webhook payloads.
205+
206+
207+
### Tracking webhooks
208+
209+
Good starting points:
210+
211+
* JSON event payload: SendGrid, Postmark
212+
* Form data event payload: Mailgun
213+
214+
(more to come)
215+
216+
### Inbound webhooks
217+
218+
Raw MIME vs. ESP-parsed fields? If you're given both, the raw MIME is usually easier to work with.
219+
220+
(more to come)
221+
222+
223+
## Project goals
224+
225+
Anymail aims to:
226+
227+
* Normalize common transactional ESP functionality (to simplify
228+
switching between ESPs)
229+
230+
* But allow access to the full ESP feature set, through
231+
`esp_extra` (so Anymail doesn't force users into
232+
least-common-denominator functionality, or prevent use of
233+
newly-released ESP features)
234+
235+
* Present a Pythonic, Djangotic API, and play well with Django
236+
and other Django reusable apps
237+
238+
* Maintain compatibility with all currently supported Django versions---and
239+
even unsupported minor versions in between (so Anymail isn't the package
240+
that forces you to upgrade Django---or that prevents you from upgrading
241+
when you're ready)
242+
243+
Many of these goals incorporate lessons learned from Anymail's predecessor
244+
Djrill project. And they mean that django-anymail is biased toward implementing
245+
*relatively* thin wrappers over ESP transactional sending, tracking, and receiving APIs.
246+
Anything that would add Django models to Anymail is probably out of scope.
247+
(But could be a great companion package.)

0 commit comments

Comments
 (0)