Mail¶
Introduction¶
Sending email doesn't have to be complicated. Arvel provides a clean, simple email API powered by mailables — small classes that describe a single message. Each mailable defines its envelope (sender, recipient, subject), its content (HTML and/or plain-text body), and any attachments. The Mail facade then renders and delivers it through a configured driver.
Configuration¶
Mail is configured through MailConfig (the MAIL_* environment variables):
MAIL_DEFAULT=smtp
MAIL_FROM_ADDRESS=[email protected]
MAIL_FROM_NAME="Arvel App"
# SMTP driver settings (prefixed MAIL_SMTP_)
MAIL_SMTP_HOST=smtp.example.com
MAIL_SMTP_PORT=587
MAIL_SMTP_USERNAME=[email protected]
MAIL_SMTP_PASSWORD=secret
MAIL_SMTP_ENCRYPTION=tls
MAIL_DEFAULT picks the driver (log, array, or smtp). SMTP-specific settings live under the MAIL_SMTP_ prefix.
Drivers¶
| Driver | Behavior |
|---|---|
log | Writes the rendered message to the log — good for local development |
array | Captures messages in memory (used by Mail.fake()) |
smtp | Delivers over SMTP |
Note
SMTP attachment handling is partial. If you rely heavily on attachments, verify the behavior against the SmtpMailDriver source for your use case.
Registering the Provider¶
Mail is opt-in. Add MailServiceProvider to bootstrap/providers.py. It binds the Mail facade during its boot phase; without it, Mail raises a not-bound error.
Generating Mailables¶
A mailable is a class that subclasses Mailable and implements two methods: envelope() and content(). Place them under app/mail/.
Writing Mailables¶
from arvel.mail.mailable import Mailable
from arvel.mail.envelope import Envelope
from arvel.mail.content import Content
class WelcomeMail(Mailable):
def __init__(self, user_name: str) -> None:
self.user_name = user_name
def envelope(self) -> Envelope:
return Envelope(
from_address="[email protected]",
to=["[email protected]"],
subject="Welcome to Arvel!",
)
def content(self) -> Content:
return Content(html_view="emails/welcome.html", text_view="emails/welcome.txt")
Note
Envelope requires from_address, to, and subject. When you send with Mail.to(...).send(...), the facade overrides the recipient, but from_address still has to be set (typically from MAIL_FROM_ADDRESS).
Configuring the Envelope¶
The Envelope carries the addressing metadata — subject, and optionally the from/to/cc/bcc fields. When you don't set a sender, the configured MAIL_FROM_ADDRESS / MAIL_FROM_NAME are used.
Configuring the Content¶
Content carries the body in one of two modes. Inline bodies go in html= / text= as literal strings. Template bodies go in html_view= / text_view= as Jinja2 template names, with data= holding the shared context. Inline and template forms are mutually exclusive per body, and at least one body must be set:
# Inline body — used verbatim
Content(html="<h1>Hello</h1>")
# Template-rendered body — names resolved by the Jinja2 environment
Content(
html_view="emails/welcome.html",
text_view="emails/welcome.txt",
data={"name": "Ada"},
)
Note
Passing a template path to html=/text= sends that string literally — template rendering only happens for html_view=/text_view=. When only an HTML body is given, the mailer auto-derives a plain-text alternative.
Attachments¶
Override attachments() to return a list of Attachment objects. The default is no attachments:
from arvel.mail.attachment import Attachment
class InvoiceMail(Mailable):
def attachments(self) -> list[Attachment]:
return [
Attachment(
name="2026-01.pdf",
mime="application/pdf",
path="invoices/2026-01.pdf",
)
]
Attachment takes a name and mime, plus either a path (file on disk) or data (raw bytes).
Sending Mail¶
Use the fluent to(...).send(mailable) chain. Both steps run through the Mail facade, and send is a coroutine:
from arvel.facades.mail import Mail
await Mail.to("[email protected]").send(WelcomeMail(user_name="Ada"))
Testing¶
Mail.fake() swaps the active driver for an in-memory one. It works both directly and as a context manager (which restores the original driver on exit):
with Mail.fake() as mailbox:
await Mail.to("[email protected]").send(WelcomeMail(user_name="Ada"))
assert len(mailbox.sent) == 1