Choosing a backend
Epistole sends the same message through any backend, so choosing one is a deployment decision, not a code decision.
Use SMTP unless something stops you. It works with every mail system. It carries the largest messages. It sends exactly the MIME Epistole built.
Use Graph when your tenant has turned SMTP AUTH off, or when you need a retry hint on throttling. Accept its body limit before you choose it. Epistole treats it as 4 MB, taken conservatively from Microsoft’s unitless “4 MB”. That figure is not yet measured against a live tenant.
Use Gmail when you are already authenticated against a Google account and would rather not manage an SMTP credential.
| SMTP | Gmail | Graph | |
|---|---|---|---|
| Mail systems served | any | Google accounts | Exchange Online |
| Largest body | whole-message limit | whole-message limit | 4 MB, no path past it |
| Largest message | server SIZE, 35 MB on a default Exchange Online tenant |
25 MB of attachment, 35 MB of request | 35 MB default, 1 MB to 150 MB configurable |
| Largest attachment | shares the message limit | shares the message limit | 150 MB, via upload session |
| Per-recipient refusals | visible | not expressible | not expressible |
| Retry hint | none | none documented | Retry-After |
| MIME you send | unchanged | unchanged, except the Message-ID |
rebuilt by Exchange |
| Recipients per message | server policy | 500 | 500 |
| Credentials | anonymous, Password, or OAuth | ServiceAccount or AuthorizedUser | ClientSecret, Certificate, or ManagedIdentity |
Gmail and Graph also take any object with get_token, the TokenCredential shape azure-identity implements. SMTP takes one inside OAuth, with an explicit scope=. Epistole pre-checks only what a vendor documents: Gmail’s 35 MiB request and 500 recipients, and Graph’s 150 MB attachment, 500 recipients, and custom header names that start with x-. SMTP gets none, because smtplib already negotiates SIZE with the server. Everywhere else, Epistole maps the service’s reply onto the same error a pre-check would have raised.
The choice depends on four things.
Check the body size. Graph caps the entire write request at 4 MB, a figure Microsoft publishes without units. Graph has no chunked path for a message body. So a large embedded HTML report cannot be sent through Graph at all. SMTP and Gmail measure against the whole message, so a body of several MB is routine on both. If you send through Graph, attach the report as a file and keep the body small.
Check whether your tenant allows SMTP AUTH. On Exchange Online, security defaults and any policy that blocks basic authentication switch SMTP client submission off. The trend is toward switching it off. Graph works under all of these settings. The per-mailbox SMTP AUTH setting overrides the organization setting, so one enabled mailbox is the documented workaround.
Weigh fidelity against features. SMTP and Gmail take the complete RFC 5322 message Epistole builds, so the recipient receives the MIME structure you send. The Gmail API does replace its Message-ID with one of its own. Graph takes a flat JSON array, and Exchange serializes the MIME later. So Epistole can guarantee that your cid: references resolve, but not the MIME structure around them. In exchange, Graph is the only backend that returns how long to wait when it throttles you.
Weigh the access each permission grants. For a plain send the three are comparable: an Exchange Online SMTP OAuth grant with no claim added, the gmail.send scope, Mail.Send. SMTP through Gmail is the exception. XOAUTH2 there requires https://mail.google.com/, which grants full mailbox access. So the Gmail backend requests less access than SMTP does. Once a serialized Graph message exceeds 4 MB, Epistole takes the draft path. That path needs Mail.ReadWrite, which grants reading every message in scope. If you send through Graph, keep the whole message under 4 MB. A security reviewer will prefer the narrower grant. The 3 MB figure you may have seen is a second, internal threshold. Inside the draft path, Epistole uses it to choose between one call and an upload session for an attachment. It changes no permission.
ConsoleBackend and MemoryBackend are backends like any other. Swapping one in changes the constructor and nothing else. MemoryBackend records what it accepted as backend.submissions, so a test reads submissions[0].message.to_. ConsoleBackend prints a readable rendering rather than the raw bytes any one backend sends.
These limits were quoted on 2026-09-08, and they change over time. Re-check one before relying on it. The sources are Graph request limits, Graph large attachments, Exchange Online limits, Gmail sending limits, Exchange rebuilds MIME, SMTP SIZE, RFC 1870, and SMTP AUTH on Exchange Online. Fuller working is in docs/research/send-boundary-semantics.md and docs/research/attachment-and-inline-rules.md.