Skip to content

Mail

Sending email touches a lot of fiddly concerns at once — building a MIME message, rendering an HTML body, attaching files, talking to an SMTP server, and not blocking the request while you do it. arvel wraps all of that behind an async Mailable (the message) and a driver-based manager (how it's delivered): in development a log driver records messages without touching the network; in production an smtp driver sends for real. Your code is identical either way — only config changes.

This page covers writing a Mailable, sending it, the drivers, queueing mail, and testing it without sending anything.

Needs the [mail] extra

uv add 'arvel[mail]' (aiosmtplib for SMTP, plus markdown rendering). The log driver works without a server, so it's ideal for development and tests.

A Mailable

A Mailable is your message class. Override build() and set the subject and HTML body with the fluent setters:

from arvel.mail import Mailable

class WelcomeMail(Mailable):
    def __init__(self, user):
        super().__init__()
        self.user = user

    def build(self):
        return self.subject("Welcome to Acme").html(
            f"<h1>Hi {self.user.name}</h1><p>Glad you're here.</p>"
        )

subject() and html() return self, so they chain. build() is called for you when the message is rendered — you never call it directly.

You can also write the body in Markdown, rendered through a small component theme (styled buttons/panels/tables, not raw md→html) — needs the [mail] extra:

def build(self):
    return self.subject("Welcome").markdown(
        "# Hi\n\nThanks for **joining** Acme.\n\n[button: Get Started](https://acme.test/start)"
    )

[button: Text](url) on its own line becomes a centered, styled call-to-action; blockquotes become shaded "panels"; GFM tables get borders/padding — all inline CSS (email-safe).

You can also attach files: .attach("invoices/2026.pdf") or .attach_data(png_bytes, "logo.png").

Plain-text alternative

Every send is multipart/alternative with both an HTML and a text/plain part — mail clients that prefer plain text (and spam filters) get real content, not an empty body. Without any text(), one is auto-derived by stripping tags from the HTML/markdown body; set an explicit one when the auto-derived version isn't good enough:

def build(self):
    return self.subject("Welcome").html("<h1>Hi</h1>").text("Hi — thanks for joining!")

Sending

Open a pending send with the recipients, then send the Mailable:

from arvel import Mail

await Mail.to(user).send(WelcomeMail(user))
await Mail.to("[email protected]").send(AlertMail(incident))

to() accepts a user object (its email attribute is used) or a raw address string, and multiple recipients:

await Mail.to(alice, bob, "[email protected]").send(DigestMail())

Add cc / bcc recipients by chaining; bcc recipients aren't shown to the others:

await Mail.to(user).cc(manager).bcc("[email protected]").send(InvoiceMail(order))

Set the sender on the Mailable with from_ (named with a trailing underscore — from is a Python keyword) and reply_to:

class WelcomeMail(Mailable):
    def build(self):
        return (self.subject("Welcome")
                    .from_("[email protected]")
                    .reply_to("[email protected]")
                    .markdown("# Hi"))

Drivers

The active driver comes from mail.default in config:

Driver Behaviour Needs
log (default) records each message in memory; sends nothing nothing
smtp sends via a real aiosmtplib.SMTP connection mail.smtp host/port config
# config/mail.py
MAIL = {
    "default": "smtp",
    "smtp": {"host": "smtp.acme.test", "port": 587},
}

Worked example: a controller action

async def register(request):
    user = await User.create(**request.validated())
    await Mail.to(user).send(WelcomeMail(user))   # log in dev, real SMTP in prod
    return {"status": "registered"}

Variations

Queue the send

Email is slow — push it to the background so the request returns immediately. Dispatch a job that sends the mail (see Queues & Jobs):

class SendWelcome(Job):
    def __init__(self, user): self.user = user        # serialized as (User, pk)
    async def handle(self):
        await Mail.to(self.user).send(WelcomeMail(self.user))

await SendWelcome.dispatch(user)

Or let the mailable queue itself: make it a ShouldQueue and Mail.to(...).send(...) enqueues it automatically (when a queue is bound) instead of sending inline. The mailable is serialized as its class + attribute state (model attributes become (class, pk) refs, re-fetched in the worker), so it travels safely across a real broker (redis); build() runs in the worker. Keep a queued mailable's attributes simple/serializable (ids, strings, models). Add attachments inside build() (which runs in the worker), not before dispatch — raw bytes set on the instance don't round-trip through the broker's JSON cleanly.

Common mistakes & gotchas

  • Forgetting super().__init__() in a Mailable's __init__ — the subject/body buffers live on the base, so skipping it raises an AttributeError when you set them.
  • Calling build() yourself. Rendering calls it; calling it twice just rebuilds. Put your content in build(), not in __init__.
  • Expecting log to send. In dev nothing leaves the process — that's the point. Switch mail.default to smtp (and configure a host) to actually send.
  • A bare string with no @. to("ops") is treated as the literal address; pass a real email or a user with an email attribute.

Testing

Swap a recording fake and assert what was sent — no SMTP, no log inspection:

from arvel.testing import fake
from arvel import Mail

mail = fake(Mail)
await register(request)
mail.assert_sent(WelcomeMail)

How it works

Mail is a facade over a MailManager (a driver manager). to() returns a PendingMail holding the recipients; send() renders the Mailable to a stdlib EmailMessage (multipart/alternative: text then HTML), stamps the To header, and hands it to the active transport. The log transport appends to an in-memory list; the smtp transport opens an aiosmtplib.SMTP connection and sends. aiosmtplib is imported lazily, so import arvel stays light until you actually send.

See also