|
| 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