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 |
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 anAttributeErrorwhen you set them. - Calling
build()yourself. Rendering calls it; calling it twice just rebuilds. Put your content inbuild(), not in__init__. - Expecting
logto send. In dev nothing leaves the process — that's the point. Switchmail.defaulttosmtp(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 anemailattribute.
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¶
- Queues & Jobs — sending mail in the background.
- Events · Validation