Skip to content

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