---------------------------------------------------------------------- This is the API documentation for the epistole library. ---------------------------------------------------------------------- ## Backends A backend holds the credential and the from address, and it sends or opens a connection. SMTPBackend(host: 'str', *, port: 'int' = 587, security: "Literal['starttls', 'tls', 'none']" = 'starttls', from_address: 'str', credential: 'Password | OAuth | None' = None) -> 'None' An SMTP backend submits each message to an SMTP server through `smtplib`. `connect()` opens the socket, starts TLS unless `security` is `"none"`, and authenticates. So a wrong password or a rejected token raises `AuthenticationError` on that line. Every socket operation times out after 60 seconds, and no setting changes it. See ADR-0005 and ADR-0017. A server may refuse some recipients and accept the rest. The send then returns normally with the refusals in `SendResult.refused`, so a caller who ignores the send result loses them. See ADR-0004. There is no size pre-check. `smtplib` sends the message size to a server that advertises `SIZE`, and the server's `552` raises `RejectedError`. See ADR-0019. Parameters ---------- host The server's host name. TLS checks the server's certificate against it. port Pass 465 with `security="tls"`, because 587 is the STARTTLS port. security `"starttls"` upgrades after EHLO and raises `TransportError` when the server does not offer it. `"tls"` starts TLS on connect. `"none"` sends in plaintext, a password included. There is no opportunistic mode, and nothing infers the mode from the port. credential `None` submits anonymously. A `Password` authenticates through PLAIN, LOGIN or CRAM-MD5, and an `OAuth` authenticates with an access token through XOAUTH2. Raises ------ TypeError When `credential` is none of `Password`, `OAuth` and `None`. ImportError When an `OAuth` holds a Graph or Gmail value, and that backend's extra is not installed. ValueError When `security` is not one of the three modes. GmailBackend(*, from_address: 'str', credential: 'ServiceAccount | AuthorizedUser | TokenCredential') -> 'None' A Gmail backend sends each message through the Gmail API's `messages.send`, as the RFC 5322 message SMTP would write. `connect()` reads the credential's file, builds one HTTP client, and gets an access token. So a rejected credential raises `AuthenticationError` on that line. A token request makes one attempt, and a failure on Google's side raises `ProviderError` there. It sends nothing to the Gmail API, so a token without the scope raises on the first send instead. Every request times out after 60 seconds, and no setting changes it. See ADR-0005 and ADR-0009. Epistole requests the scope `https://www.googleapis.com/auth/gmail.send` alone, which grants no right to read or delete messages. See ADR-0011. Before writing, a send raises `RejectedError` for more than 500 recipients, or for a message over 36,700,160 bytes once encoded. Both limits are Google's. See ADR-0019. Gmail accepts or refuses the whole message, so `SendResult.refused` is always empty. Gmail replaces the `Message-ID` Epistole sets with its own. When `from_address` is neither the account nor one of its verified aliases, Gmail sends from the account's own address and raises nothing. `docs/research/live-send-findings.md` records both. Parameters ---------- credential Epistole calls a `TokenCredential`'s `get_token` with the `gmail.send` scope before each request. Raises ------ TypeError When `credential` is none of the three. ImportError When `epistole[gmail]` is not installed. GraphBackend(*, from_address: 'str', credential: 'ClientSecret | Certificate | ManagedIdentity | TokenCredential') -> 'None' A Graph backend sends each message through Microsoft Graph, as JSON. `connect()` builds one HTTP client and gets an access token. So a rejected credential raises `AuthenticationError` on that line. A tenant ID that does not exist in Entra raises `msal`'s `ValueError` there instead. It sends nothing to Graph, so an app without the `Mail.Send` permission raises on the first send instead. Every request times out after 60 seconds, and no setting changes it. See ADR-0005 and ADR-0009. Every request names the from address's mailbox as `/users/{addr-spec}`. No request uses `/me`, because an app-only token has no signed-in user. See ADR-0012. A Graph body holds HTML or plain text, not both. So an HTML message goes out without its plain text, and Exchange derives its own. See ADR-0012. A message whose `sendMail` request would be 4,000,000 bytes or more goes through a draft instead: Epistole creates the draft, adds each attachment by its own call, and sends it. That path needs the `Mail.ReadWrite` permission as well as `Mail.Send`. Without it, the send raises `AuthenticationError`. When a send fails partway, Epistole deletes the draft. See ADR-0012. Before writing, a send raises `RejectedError` for more than 500 recipients, for an attachment over 150,000,000 bytes, or for a custom header whose name does not start with `x-`. See ADR-0016 and ADR-0019. Graph accepts or refuses the whole message, so `SendResult.refused` is always empty. Parameters ---------- credential Epistole calls a `TokenCredential`'s `get_token` with the scope `https://graph.microsoft.com/.default` before each request. Raises ------ TypeError When `credential` is none of the four. ImportError When `epistole[graph]` is not installed. ConsoleBackend(*, from_address: 'str' = 'epistole@example.invalid', stream: 'TextIO | None' = None) -> 'None' A console backend writes a rendering of each submission to a stream instead of sending it. It writes the plain text in full, and the HTML and each attachment as a size. Nothing should parse the rendering. It is never the bytes a backend sends. See ADR-0015. Parameters ---------- stream `None` means `sys.stdout` as it is at each send, because pytest's `capsys` and Jupyter replace it after import. Examples -------- ```python from epistole import ConsoleBackend, Message backend = ConsoleBackend(from_address="reports@example.com") backend.send( Message(text="Weekly numbers") .to("ada@example.com") .subject("Weekly numbers") .attach(b"%PDF", filename="weekly.pdf") ) # From: reports@example.com # To: ada@example.com # Message-ID: <179021486392.66219.11904006491029159968@example.com> # Date: Wed, 23 Sep 2026 21:54:23 -0400 # Subject: Weekly numbers # Attachment: weekly.pdf (application/pdf, 4 bytes) # # Weekly numbers # ------------------------------------------------------------------------------- ``` MemoryBackend(*, from_address: 'str' = 'epistole@example.invalid', refuse: 'Mapping[str, Refusal] | None' = None) -> 'None' A memory backend records each submission instead of sending it. See ADR-0015 for its design. Parameters ---------- refuse Maps an address to the refusal it gets, matched by addr-spec. A key that is not an address raises here, rather than never matching. Attributes ---------- submissions Every submission at least one recipient accepted, from every connection, in the order the submits completed. Reset it with `list.clear()`. Examples -------- ```python from epistole import MemoryBackend, Message, Refusal backend = MemoryBackend(from_address="reports@example.com") backend.send(Message(text="Weekly numbers").to("ada@example.com")) backend.submissions[0].message.to_ # ("ada@example.com",) backend.submissions[0].from_address # "reports@example.com" ``` Refuse one recipient, and the backend accepts the rest, as SMTP does: ```python backend = MemoryBackend(refuse={"ada@example.com": Refusal(550, "No such mailbox")}) result = backend.send( Message(text="Weekly numbers").to("ada@example.com", "bob@example.com") ) result.refused # {"ada@example.com": Refusal(code=550, reason="No such mailbox")} ``` ## Credentials Each backend module exports its credentials. Password(username: 'str', password: 'str') -> None A password is the username and password `SMTPBackend` authenticates with, through PLAIN, LOGIN or CRAM-MD5. `connect()` uses the first of the three that the server offers, and no other after a `535`. It raises `AuthenticationError` without sending the password when the server offers none of them. See ADR-0011. `smtplib` encodes both as ASCII, so `connect()` raises `UnicodeEncodeError` for any other character. Attributes ---------- username The name the server authenticates, often the from address. password The secret. It is not in the `repr`, so no traceback or log line holds it. OAuth(username: 'str', credential: 'graph.ClientSecret | graph.Certificate | graph.ManagedIdentity | gmail.ServiceAccount | gmail.AuthorizedUser | TokenCredential', scope: 'str | None' = None) -> None An OAuth credential authenticates to SMTP with an access token instead of a password, through XOAUTH2. `connect()` gets one token from `credential`, and sends it with `username` through `smtplib.SMTP.auth`. It sends no token to a server that does not offer `AUTH XOAUTH2`, and raises `AuthenticationError` instead. Epistole derives the scope from the issuer. A `graph.ClientSecret` or `graph.Certificate` requests `https://outlook.office365.com/.default`. A `graph.ManagedIdentity` requests the resource `https://outlook.office365.com` instead, because `msal`'s managed identity client takes no scope. A Gmail value requests `https://mail.google.com/`, which Gmail requires though it also grants reading and deleting every message. Epistole cannot read the issuer of a `TokenCredential`, so it takes `scope` instead. See ADR-0011. Attributes ---------- username The mailbox the token belongs to, sent as XOAUTH2's `user`. `smtplib` encodes it as ASCII, so `connect()` raises `UnicodeEncodeError` for any other character. credential A value from `epistole.graph` or `epistole.gmail`, or a `TokenCredential`. scope The scope a `TokenCredential`'s `get_token` receives. It is required with a `TokenCredential`, and `OAuth` raises `TypeError` for it with any other credential. ServiceAccount(path: 'Path', subject: 'str') -> None A service account sends as `subject` through domain-wide delegation. A Workspace administrator grants the service account's client ID the `https://www.googleapis.com/auth/gmail.send` scope. Inside `smtp.OAuth`, it requests `https://mail.google.com/` instead, so the administrator grants that. Attributes ---------- path The service account's JSON key file. `connect()` reads it. subject The mailbox the service account acts as. AuthorizedUser(path: 'Path') -> None An authorized user is a saved user consent. Epistole runs no consent flow and never rewrites the file. `connect()` ignores any access token the file holds and always requests one. So a refresh token that Google has expired or revoked raises `AuthenticationError` on `connect()`. Inside `smtp.OAuth`, it requests `https://mail.google.com/`, so the consent must include that scope. Attributes ---------- path The JSON file that holds the refresh token, as `google.oauth2.credentials.Credentials.to_json()` writes it. `connect()` reads it. ClientSecret(tenant_id: 'str', client_id: 'str', client_secret: 'str') -> None A client secret authenticates an Entra app registration, which needs the `Mail.Send` application permission. Epistole requests the scope `https://graph.microsoft.com/.default`, which grants the permissions an administrator consented to for the app. Inside `smtp.OAuth`, it requests `https://outlook.office365.com/.default` instead. See ADR-0011. Attributes ---------- tenant_id The directory the app is registered in, as a GUID or a domain. client_id The app's application ID. client_secret The secret. It is not in the `repr`, so no traceback or log line holds it. Certificate(tenant_id: 'str', client_id: 'str', pfx: 'Path | None' = None, passphrase: 'str | None' = None, private_key: 'str | None' = None, thumbprint: 'str | None' = None) -> None A certificate authenticates an Entra app registration with a certificate instead of a secret. It takes one of the two forms `msal` accepts: `pfx` with an optional `passphrase`, or `private_key` and `thumbprint` together. Any other combination raises `TypeError`. Prefer `pfx`, because `msal` deprecates the second form for its SHA-1 thumbprint. See ADR-0011. Attributes ---------- tenant_id The directory the app is registered in, as a GUID or a domain. client_id The app's application ID. pfx A PKCS #12 file that holds the private key and the certificate. `connect()` reads it. passphrase The passphrase of an encrypted `pfx`. It is not in the `repr`. private_key The private key, in unencrypted PEM. It is not in the `repr`. thumbprint The certificate's SHA-1 thumbprint, in hex. ManagedIdentity(client_id: 'str | None' = None) -> None A managed identity is the identity Azure gives the resource the code runs on, so the caller stores no secret. Epistole requests the resource `https://graph.microsoft.com`, because `msal`'s managed identity client takes a resource and no scope. Inside `smtp.OAuth`, it requests `https://outlook.office365.com` instead. It does not work on Service Fabric, where `msal` requires its own `requests.Session` to pin the endpoint's certificate. See ADR-0011. Attributes ---------- client_id The client ID of a user-assigned identity, or `None` for the system-assigned one. ## Message A message is an immutable value, and each builder method returns a copy. Message(*, html: 'str | None' = None, markdown: 'str | None' = None, text: 'str | None' = None, text_renderer: 'Callable[[str], str] | None' = None) -> 'None' A message holds the content, addressing, subject, custom headers, and attachments a caller builds, as one frozen value. It compares and hashes by content. Every builder method returns a new message and leaves the receiver unchanged. A method named for a field replaces that field, so `.to("a").to("b")` addresses `b` alone. `.attach()` and `.embed()` append instead. Nothing removes a field, so each address method takes at least one address. See ADR-0002. A builder method's value is an attribute with the method's name plus a trailing underscore, because the plain name is the method. See ADR-0007. Parameters ---------- html The HTML. Epistole moves each `data:` image in an `` into an inline image and rewrites the `src` to `cid:`. It then derives the plain text from the result with `html_to_text`, unless the caller supplies `text` or `text_renderer`. See ADR-0003. markdown The Markdown source. Epistole renders the HTML from it on markdown-it-py's `commonmark` preset and sends the source as the plain text, unless the caller supplies `text`. text The plain text. Epistole sends it verbatim and derives nothing from `html`. It may be `""` when the subject holds the whole message. text_renderer Derives the plain text from the rewritten `html` in place of `html_to_text`. It runs once, at construction. The message does not keep it, so equality compares content alone. An exception it raises propagates unchanged. Attributes ---------- to_, cc_, bcc_, reply_to_ The addresses the builder method of the same name set. subject_ The subject `.subject()` set, or `None`. headers_ The custom headers `.headers()` set, as a read-only mapping in the caller's order. It is empty until `.headers()` sets it, and it never holds a header Epistole writes. html The HTML the caller supplied or Epistole rendered from `markdown`, or `None` on a text-only message. The rewrite has replaced each `data:` image in it with a `cid:` reference. text The plain text. It is the Markdown source for `markdown=`. It is `""` for `text=""`, and also for HTML that holds no text, such as a lone image with no alt text. attachments What `.attach()` added, in call order. inline_images The inline images the `data:` rewrite made come first, one per distinct media type and bytes. What `.embed()` added follows, in call order. Raises ------ TypeError When both `html` and `markdown` are supplied, when no content is supplied, or when `text_renderer` accompanies `text` or `markdown`. When `html`, `markdown`, or `text` is not a `str`, or when `text_renderer` returns something other than a `str`. ValueError When `html`, `markdown`, or `text` holds a surrogate, or when `text_renderer` returns text that holds one. When the payload of a `data:` image does not decode. See ADR-0003 and ADR-0008. ImportError When `markdown` is supplied and `epistole[markdown]` is not installed. ## Values What a send takes and returns. Address(name: str, email: str) -> Self An `Address` is a `str` that holds one address in its display-name form. `email.utils.formataddr` writes the value, so a hand-written string with the same text is indistinguishable from it. See ADR-0014. Parameters ---------- name The display name, quoted or RFC 2047-encoded as needed. An empty name leaves the address bare. email The address, in ASCII. Pass a non-ASCII address as a plain string instead. Raises ------ TypeError When `name` or `email` is not a `str`. ValueError When `name` or `email` holds a surrogate. When `email` is not ASCII, with the `UnicodeEncodeError` as `__cause__`. Examples -------- ```python from epistole import Address Address("Ada Lovelace", "ada@example.com") # "Ada Lovelace " Address("Lovelace, Ada", "ada@example.com") # '"Lovelace, Ada" ' ``` Attachment(filename: 'str', content_type: 'str', data: 'bytes', content_id: 'str | None') -> None An attachment is bytes with a filename and a content type that a message carries. `Message.attach()` and `Message.embed()` build one. See ADR-0018. Attributes ---------- filename The name the recipient sees. content_type A bare media type, with no parameters. Within a message it is never a `message/*` or `multipart/*` type, so a transport can write the bytes as base64. data The bytes, which the builder method reads at call time. content_id The name the HTML uses after `cid:` on an inline image, or `None` on an attachment. Refusal(code: 'int', reason: 'str') -> None A refusal records one recipient a mail service refused. It is public because `MemoryBackend(refuse=)` takes one. A bounce is not a refusal, and Epistole never receives one. See ADR-0004. Attributes ---------- code The SMTP reply code, or the HTTP status where an API refuses one address. reason The text the service returned, never bytes. SendResult(message_id: 'str', date: 'datetime', refused: 'Mapping[str, Refusal]') -> None A send result records that a mail service accepted one submission. Acceptance is not delivery, so a send result never means that anyone received the message. See ADR-0004. Attributes ---------- message_id The `Message-ID` the send set, with its angle brackets. The Gmail API replaces it with its own, so a recipient of a `GmailBackend` send sees a different one. date The `Date` the send set, timezone-aware in the sending machine's offset. refused The recipients the service refused while accepting the rest, keyed by the caller's recipient string. Only SMTP and `MemoryBackend(refuse=)` fill it. Submission(message: 'Message', from_address: 'str', message_id: 'str', date: 'datetime') -> None A submission is one message passed to one transport once. Only `Connection.send` builds one. Sending a message twice makes two submissions with two ids, because identity belongs to the send. See ADR-0015. Attributes ---------- message The message as the caller built it. from_address The backend's from address, which a `Message` never carries. message_id The `Message-ID` Epistole generated, with its angle brackets. date The `Date` Epistole set, timezone-aware in the sending machine's offset. ## Base classes and protocols What a third-party backend implements. Backend(*, from_address: 'str') -> 'None' A backend holds the configuration for sending through one mail service. It holds immutable configuration and no live link, so threads can share it. It is not a context manager: `connect()` opens a link. A subclass implements `_open()` and nothing else. See ADR-0005 and ADR-0006. Attributes ---------- from_address The mailbox every submission is sent from, checked at construction. No send can override it. See ADR-0001. Connection(backend: 'Backend', transport: 'Transport', /) -> 'None' A connection is one live link a backend opened, for many sends until it closes for good. Build one with `Backend.connect()`. It belongs to one thread and one `with`, and never reopens. It checks each message and builds the submission and the send result, so a third-party backend writes only a `Transport`. See ADR-0005. Attributes ---------- backend The backend that opened this connection. Transport(*args, **kwargs) A transport is the object a backend opens, and the only code that communicates with a mail service. A third-party backend writes a transport and nothing else. Both methods take positional-only arguments, so `submit(self, sub)` still matches. TokenCredential(*args, **kwargs) A token credential returns an access token on demand, in the shape `azure.core.credentials` defines. An `azure-identity` credential satisfies it with no dependency on `azure-core`. A backend uses the credential unchanged and calls `get_token` before each request, so the credential should cache its own tokens. See ADR-0011. AccessToken(*args, **kwargs) An access token is the value `TokenCredential.get_token` returns. Its members are read-only, so `azure.core.credentials.AccessToken`, a `NamedTuple`, satisfies it. ## Exceptions Every error Epistole raises for a failed send subclasses `EpistoleError`. EpistoleError(message: 'str', /, *, backend: 'Backend | None' = None) -> 'None' An `EpistoleError` reports an error reply from a mail service, or a network failure. Epistole raises `TypeError` or `ValueError` instead for a mistake it finds before any network call. A retry loop catches the transient set `(ThrottledError, TransportError, ProviderError)` by name. See ADR-0004. Attributes ---------- backend The backend the error came from. `Connection.send` and `Backend.connect` set it, so it is `None` only on an error inspected before it propagates. RejectedError(message: 'str', /, *, backend: 'Backend | None' = None) -> 'None' The service rejected the message as invalid, or the message failed a backend pre-check. The error is permanent. A pre-check raises it with no `__cause__`. See ADR-0004. SenderRefusedError(message: 'str', /, *, backend: 'Backend | None' = None) -> 'None' The service refused to send as the backend's from address. The fix is an administrator's grant, not a change to the message. "Sender" here does not mean RFC 5322's `Sender` header. RecipientsRefusedError(message: 'str', /, *, refused: 'Mapping[str, Refusal]', backend: 'Backend | None' = None) -> 'None' The service refused every recipient, so nothing was submitted. Only `Connection.send` raises it, so its `__cause__` is `None`. When the service refuses only some recipients, the send returns them in `SendResult.refused` instead. Attributes ---------- refused Each recipient's refusal, keyed by the caller's recipient string. AuthenticationError(message: 'str', /, *, backend: 'Backend | None' = None) -> 'None' The service rejected the credential, or the credential lacks a permission. Both are permanent until someone changes a setting. So an expired token that refreshes cleanly is not an error, and a refresh the token endpoint rejects is. A token endpoint that fails on the provider's side raises `ProviderError` instead. ThrottledError(message: 'str', /, *, retry_after: 'float | None' = None, backend: 'Backend | None' = None) -> 'None' The service throttled the request. Epistole never sleeps or retries. The SMTP backend never raises this error. See ADR-0004. Attributes ---------- retry_after Seconds from the `Retry-After` header in either RFC 9110 form, or `None` when the response had none. TransportError(message: 'str', /, *, backend: 'Backend | None' = None) -> 'None' The link to the service failed at connect, in TLS, by disconnect or by timeout. It is the only error that closes the connection. A `504` is a `ProviderError`, because the service returned it. See ADR-0004. ProviderError(message: 'str', /, *, backend: 'Backend | None' = None) -> 'None' The service returned its own `5xx`, or a reply that no mapping table matches. See ADR-0004 for the mapping tables. ## Functions html_to_text(html: str, /) -> str Derive plain text from `html`. It writes a link as `label `, or as the label alone when the label is already the URL or the `mailto:` address, or when the URL is a fragment such as `#top`. It keeps the line breaks and indentation of a `
` block, such as a code block or a log. It starts a list item with `- `. It writes a table row on one line with ` | ` between its cells when the cells hold only inline content. A block or a `
` inside a cell starts a new line, so the paragraphs of a layout table stay apart. It writes empty cells too, so it never shifts a value into the wrong column. It writes an image as its alt text in brackets. It drops `