Sending Emails
Most apps send mail eventually: a confirmation when someone signs up, a password reset link, a receipt, a weekly digest. The mechanics are unglamorous - compose a message, hand it to something that talks SMTP (or an HTTP API), hope it lands in the inbox - but the surrounding decisions matter: where the email lives in your code, how its body is rendered, whether sending blocks the request or runs in the background, and what happens to it in development and tests.
This guide covers all of that in Proper.
After reading this guide, you will know:
- How an email is shaped in a Proper app - the class, the template, the layout, and the controller call that sends it.
- When to send through
send()vs.send_later(), and which mailer backend belongs in development, tests, and production. - How to test email-sending code without ever touching SMTP.
1. The shape of email in Proper
Before any API reference, it's worth walking through one real email end-to-end. The auth addon ships with a password-reset flow that exercises every part of the system: a class, a template, a layout, a controller, and a background task. Each section after this one decomposes one piece of it.
Here is the layout in a freshly generated app with the auth addon installed:
myapp/
├── emails/
│ ├── __init__.py
│ ├── base_email.py # BaseEmail adds send_later()
│ └── password_reset_email.py # one class per email
├── views/
│ ├── emails/
│ │ └── password_reset.jx # the HTML template
│ └── layouts/
│ └── email.jx # the layout every email wraps in
├── tasks/
│ └── __init__.py # ships with send_email_task
└── config/
└── main.py # MAILERS, MAILER, and MAILER_DEFAULT_OPTIONS
1.1 The class
PasswordResetEmail lives in emails/password_reset_email.py. It carries the data a single message needs - in this case, two URLs derived from the user - and not much else:
# emails/password_reset_email.py
from ..main import app
from .base_email import BaseEmail
class PasswordResetEmail(BaseEmail):
subject = "Reset your password"
def __init__(self, user, **kwargs):
super().__init__(**kwargs)
token = user.generate_token_for("password_reset")
self.validate_url = app.url_for(
"PasswordReset.edit", token=token, _full=True)
self.reset_url = app.url_for("PasswordReset.new", _full=True)
A few things to notice:
subjectis a class attribute. Anything you set at class level becomes the default for every instance; you can still override it per-call.- The constructor receives the domain object (
user), not just primitives. Pullinguser.emailand friends inside the email class - rather than at the call site - keeps the controller short and the email class self-contained. - Instance attributes like
self.validate_urlaren't required parameters ofEmailMessage. They're free-form, and the template will see them as variables - we'll get to template auto-discovery in Templates. _full=Trueonapp.url_for()produces an absolute URL with scheme and host. Emails travel outside your app, so a relative URL is useless. Always pass_full=True.
1.2 The template
The email body comes from views/emails/password_reset.jx:
{#import "layouts/email.jx" as Layout #}
{#def subject, validate_url, reset_url #}
<Layout>
<p>
I heard that you lost your password. Sorry about that!
</p>
<p>
But don't worry! You can use the following link to reset your password:
<a href="{{ validate_url }}">{{ validate_url }}</a>.
</p>
<p>
If you don't use this link within 3 hours, it will expire.
To get a new password reset link, visit
<a href="{{ reset_url }}">{{ reset_url }}</a>.
</p>
</Layout>
{#def subject, validate_url, reset_url #} declares the variables the template expects. Every instance attribute on the email class is passed in as a template variable, so self.validate_url becomes {{ validate_url }}.
1.3 The layout
views/layouts/email.jx is a thin HTML document that every email wraps in. The generated one is intentionally minimal:
{#def lang='en' #}
<!DOCTYPE html>
<html lang="{{ lang }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
/* Email styles need to be inline */
</style>
</head>
<body>
{{ content }}
</body>
</html>
That empty <style> block with the inline-only comment is honest: most email clients strip or ignore <style> tags. The pragmatic answer today is to write inline style="..." attributes on the elements that need them. A CSS-inlining pass at render time is a likely future addition - see Where this could grow.
1.4 The controller call
A controller sends the email in two lines:
# controllers/password_reset_controller.py
from ..emails.password_reset_email import PasswordResetEmail
def create(self):
self.form = PasswordResetForm(self.params)
if self.form.is_invalid:
return self.redo()
user = User.get_by_login(self.form.save()["login"])
PasswordResetEmail(user).send_later(to=user.email)
self.response.redirect_to("PasswordReset.show", token="sent")
PasswordResetEmail(user) builds the message; .send_later(to=user.email) hands it to the background queue and returns immediately. The user sees the redirect right away, and a worker picks up the actual SMTP conversation seconds later. The request didn't have to wait for it.
That's the whole loop. The rest of this guide is the reference for each piece.
2. Generating an email
The proper g email generator stubs the three files that go together. Pass a PascalCase name, singular, without an _email suffix:
$ proper g email Welcome
That creates emails/welcome_email.py and views/emails/welcome.jx.
The Python file gets the _email suffix; the template does not. That keeps the emails/ folder readable as a list of nouns (welcome_email.py, password_reset_email.py, invoice_email.py) while the templates stay short (welcome.jx). The template discovery rule, covered in Templates, bridges the two.
The generated class is a skeleton:
# emails/welcome_email.py
from .base_email import BaseEmail
class WelcomeEmail(BaseEmail):
subject = ""
def __init__(self, **kwargs):
super().__init__(**kwargs)
Fill in subject, accept whatever domain objects the email needs, and stash anything the template should see as self.something. Then write the template at views/emails/welcome.jx.
3. The email class
Every email in your app is a subclass of BaseEmail, which is itself a thin subclass of proper.emails.EmailMessage. The hierarchy:
EmailMessage(from the framework) - composes a message, renders templates, sends through the configured mailer.BaseEmail(in your app, atemails/base_email.py) - addssend_later()so you can hand the message off to the background queue. You can edit this class.- Your specific emails (
WelcomeEmail,PasswordResetEmail, ...) - one per kind of message.
For one-off messages you don't need a class at all - EmailMessage is usable directly:
from proper.emails import EmailMessage
EmailMessage(
subject="Welcome!",
body="Thanks for signing up.",
to="alice@example.com",
).send()
But the class-per-email pattern is the convention, because emails almost always end up needing some setup (build URLs, look up records, decide a subject), and the class is the natural place for it.
3.1 Constructor arguments
All arguments to EmailMessage are keyword-only:
EmailMessage(
from_email="hello@example.com",
subject="Welcome!",
body="Thanks for signing up.",
to="alice@example.com",
cc=["manager@example.com"],
bcc=["audit@example.com"],
reply_to=["support@example.com"],
headers={"X-Campaign": "spring-launch"},
)
to, cc, bcc, and reply_to each accept a single string or a list of strings - whichever is more convenient. Pass a list when you mean it: a single to= with multiple addresses joined by commas is not the same thing.
3.2 Class-level defaults
Any attribute you set at class level becomes the default for every instance. subject is the most common, but from_email, content_subtype, and charset work the same way:
class NewsletterEmail(BaseEmail):
subject = "Your weekly digest"
from_email = "news@example.com"
content_subtype = "html"
A constructor argument overrides the class-level default for that one instance.
3.3 Free-form attributes
Anything you assign in __init__ beyond the constructor's known fields is yours. Those attributes are passed straight to the template as variables, which is the whole reason for the convention:
class InvoiceEmail(BaseEmail):
subject = "Your invoice"
def __init__(self, invoice, **kwargs):
super().__init__(**kwargs)
self.invoice = invoice
self.total = invoice.total
self.download_url = app.url_for(
"Invoice.download", invoice, _full=True)
The template sees {{ invoice }}, {{ total }}, {{ download_url }}.
3.4 MAILER_DEFAULT_OPTIONS
App-wide defaults live in config/main.py:
MAILER_DEFAULT_OPTIONS = {
"from": "no-reply@example.com",
"reply_to": ["support@example.com"],
"headers": {"X-App": "MyApp"},
}
Any field not supplied by the constructor or by a class default falls back to this dict. So from_email typically lives here, once - no email class needs to repeat it. The accepted keys are from, subject, to, bcc, cc, reply_to, and headers.
3.5 Adjusting after construction
update() lets you tweak a message after it's built - useful when the sender decides the recipient, not the email class:
email = WelcomeEmail(user)
email.update(to=user.email, headers={"X-Tenant": tenant.id})
email.send()
send() and send_later() both accept the same keyword arguments and call update() internally, so the more common form is the one-liner you saw earlier:
WelcomeEmail(user).send_later(to=user.email)
4. Templates
Setting body= explicitly works, but the usual path is to let the email auto-render a template. When send() (or the worker behind send_later()) sees an empty body, it goes looking for templates by class-module name.
4.1 The discovery rule
For a class in emails/welcome_email.py, Proper strips the _email suffix from the module name and looks under views/emails/:
views/emails/welcome.jx- the HTML versionviews/emails/welcome.txt.jx- the plain text version
You can have one, both, or neither:
| HTML present | Text present | Result |
|---|---|---|
| yes | yes | multipart/alternative with both |
| yes | no | multipart/alternative; the text part is auto-generated |
| no | yes | plain text only |
| no | no | body comes from the constructor or class default |
The auto-generated text fallback runs the HTML body through a small HTML-to-text converter. It's serviceable for simple transactional mail; for newsletters and rich HTML, write the text template yourself - it almost always reads better.
If your class doesn't follow the _email suffix convention (say, emails/welcome.py instead of emails/welcome_email.py), the lookup just uses the module name unchanged. The suffix rule is a strip, not a requirement.
4.2 What the template sees
Every instance attribute on the email becomes a template variable. There's no separate "assigns" step:
class WelcomeEmail(BaseEmail):
subject = "Welcome to MyApp"
def __init__(self, user, **kwargs):
super().__init__(**kwargs)
self.user = user
self.dashboard_url = app.url_for("Dashboard.index", _full=True)
{#import "layouts/email.jx" as Layout #}
{#def user, dashboard_url #}
<Layout>
<p>Hi {{ user.name }},</p>
<p>
Your account is ready.
<a href="{{ dashboard_url }}">Go to your dashboard</a>.
</p>
</Layout>
The {#def ... #} line declares the variables the template expects, which is also how Jx forces you to acknowledge them - omitting a variable from {#def #} and referencing it in the template is an error, not a silent None.
4.3 The layout
Email templates almost always wrap in views/layouts/email.jx, the document shell. The generated layout is intentionally minimal - no styles, no header, no footer - because what looks tasteful in your app isn't necessarily what an email client will accept. Edit it freely.
Two things to know about the layout in practice:
- Styles must be inline. Gmail, Outlook, and most webmail clients strip or scope
<style>blocks. Writestyle="..."attributes on individual elements, or write a small set of utility classes that you then inline. A render-time CSS inliner is an obvious future addition - see Where this could grow. - Always use
_full=Truefor URLs.app.url_for("X.show", obj)returns/things/42; in an inbox, that's a 404.app.url_for("X.show", obj, _full=True)returnshttps://yourapp.com/things/42, which is what the recipient needs. The full URL is built from your config'sPROTOCOLandHOST, so set those correctly per environment.
5. Attachments and alternatives
5.1 Attaching files
attach_file() adds a file from disk. The MIME type is guessed from the extension; pass mimetype= to override:
email = InvoiceEmail(invoice)
email.attach_file("/path/to/invoice.pdf")
email.attach_file("/path/to/data.csv", mimetype="text/csv")
email.send_later(to=customer.email)
The resulting message is multipart/mixed, with the body as the first part and each attachment as a sibling. Attaching one or more files turns every outgoing message into multipart, even if the body is a single short paragraph - that's fine, every client handles it.
5.2 Alternative content parts
attach_alternative(content, mimetype) adds an alternative representation of the body - the same content in a different format, not a separate file. The classic use is offering a plain-text fallback alongside HTML:
email = NewsletterEmail()
email.body = "<h1>This week</h1><p>...</p>"
email.content_subtype = "html"
email.attach_alternative("This week\n========\n\n...", "text/plain")
In normal use you don't reach for this directly - the template auto-discovery in the previous section calls attach_alternative() for you when both an HTML and a text template exist.
5.3 Inline images
Inline images (the <img src="cid:logo"> pattern, where the image data travels with the message and renders inside the body) require a multipart/related structure that EmailMessage does not yet expose. The framework's attach_file() produces an attached file, not an inline part; attach_alternative() produces an alternative body, not a related resource.
The pragmatic workarounds today are:
- Host the image externally. Reference it with a normal
https://URL. Most modern clients block remote images by default, but they let users opt in - and for transactional mail this is usually fine. - Inline as a data URI.
<img src="data:image/png;base64,...">. Bigger payload, but no server hop. - Drop to the stdlib
emailpackage for that specific message and assemble themultipart/relatedyourself, then send it throughapp.mailer.send_now().
First-class CID support is on the list - see Where this could grow.
6. Sending: now vs. later
Two methods send a message; pick by whether the caller is willing to wait.
6.1 send() - synchronous
EmailMessage.send() hands the message straight to the configured mailer:
WelcomeEmail(user).send(to=user.email)
The call blocks until the mailer returns. In production with the SMTP backend, that means waiting on a TCP round-trip and an SMTP conversation - usually a fraction of a second, sometimes longer if the server is slow or refuses to connect. If it happens inside a request handler, the user waits with it.
send() is the right call for: tests, scripts, scheduled jobs already running in a worker, and any place where blocking is not a problem.
Like send_later(), it accepts an optional via= argument to route the message through a specific named mailer.
6.2 send_later() - asynchronous
BaseEmail.send_later() enqueues a background task that sends the message:
WelcomeEmail(user).send_later(to=user.email)
The call returns immediately. The actual SMTP work happens in the worker process, off the request path. For email triggered by a web request - the password reset, the welcome message, the receipt - this is the default. Users get their redirect; the email lands a few seconds later.
send_later() is defined in your app's BaseEmail:
# emails/base_email.py
from proper.emails import EmailMessage
from ..tasks import send_email_task
class BaseEmail(EmailMessage):
def send_later(self, *, via=None, **options):
self.update(**options)
send_email_task(message=self.serialize(), via=via)
And the task it calls is one of the few things shipped in tasks/__init__.py:
# tasks/__init__.py
from proper.emails import EmailMessageDict
from ..main import app
@app.queue.task()
def send_email_task(message: EmailMessageDict, via: str | None = None):
mailer = app.mailers[via] if via else app.mailer
mailer.send_now(message)
Both files are in the generated app from day one. If you need to do something extra around sending - retries, tagging, multi-tenant routing - this is where to edit.
6.3 What happens in dev and tests
In dev and test, the queue runs in immediate mode: send_email_task(...) runs synchronously, in the calling process, the moment you invoke it. So send_later() behaves like send() - the message goes through the mailer right away, no worker needed. Your test assertions see the email immediately; your dev console prints it without you starting workers.py.
In production, where the queue points at Redis (or another real backend), send_later() enqueues a task message and a separate worker process picks it up. The worker must actually be running; see Background Tasks → Running Workers for how to keep one alive.
6.4 Sending through a specific backend
Both send() and send_later() take an optional via= argument that routes one message through a named backend instead of the default:
# this one message goes through Postmark, even if the default is SES
ReceiptEmail(order).send_later(via="postmark", to=order.email)
The name has to be a key in your MAILERS config (see Backends); an unknown name raises ConfigError. Omit via= and the message goes through the default - the common case.
This is the clean answer to "bulk mail through one provider, transactional through another": declare both backends in MAILERS, set the default, and pass via= on the few messages that need the other one. If you ever need a backend directly, it's at app.mailers["name"] - app.mailer is just shorthand for the default.
7. Backends
Backends are declared in the MAILERS config in config/main.py - a dict mapping a name to a backend config. Each config's type key names the class; every other key is passed to its constructor. MAILER then names which backend is the default. The subsections below show each one as a MAILERS entry.
7.1 ToConsoleMailer - development
"console": {"type": "proper.emails.ToConsoleMailer"}
Writes the full rendered RFC 5322 message to stdout, with a --- separator between messages. Useful for "did the right thing get composed" - you can verify the subject, recipients, headers, and body without standing up an SMTP server or copying real addresses into your dev environment.
Pass stream= to redirect somewhere other than sys.stdout:
import sys
"console": {"type": "proper.emails.ToConsoleMailer", "stream": sys.stderr}
7.2 ToMemoryMailer - tests
"memory": {"type": "proper.emails.ToMemoryMailer"}
Stores sent messages in an in-memory list at app.mailer.outbox instead of sending them. This is what powers the testing pattern in the next section. Each entry is a fully rendered email.message.EmailMessage (the stdlib class), so you inspect its headers and parts the way you would any other Python email.
7.3 SMTPMailer - production
"ses_smtp": {
"type": "proper.emails.SMTPMailer",
"host": "smtp.example.com",
"port": 587,
"username": os.getenv("SMTP_USERNAME"),
"password": os.getenv("SMTP_PASSWORD"),
"use_tls": True,
}
The full option set:
| Option | Default | Description |
|---|---|---|
host |
"localhost" |
SMTP server hostname |
port |
587 |
SMTP server port |
username |
None |
Authentication username |
password |
None |
Authentication password |
use_tls |
False |
STARTTLS - upgrade a plaintext connection |
use_ssl |
False |
Implicit SSL/TLS - encrypted from the start (port 465) |
timeout |
None |
Connection timeout in seconds |
ssl_keyfile |
None |
Path to client SSL key |
ssl_certfile |
None |
Path to client SSL certificate |
fail_silently |
False |
Swallow SMTP errors instead of raising |
use_tls and use_ssl are mutually exclusive - constructing the mailer with both set raises ValueError. Use use_tls=True on port 587 (the modern default); use use_ssl=True on port 465 for legacy "SMTPS" servers.
The SMTPMailer is thread-safe and pools its connection: a single send_now() call with multiple messages reuses one TCP connection for all of them.
7.4 Per-environment configuration
The generated config/main.py switches backend by environment:
import os
env = os.getenv("APP_ENV", "dev")
MAILERS = {
"console": {"type": "proper.emails.ToConsoleMailer"},
"memory": {"type": "proper.emails.ToMemoryMailer"},
"ses_smtp": {
"type": "proper.emails.SMTPMailer",
"host": "smtp.example.com",
"port": 587,
"username": os.getenv("SMTP_USERNAME"),
"password": os.getenv("SMTP_PASSWORD"),
"use_tls": True,
},
}
# Default - print to console in dev
MAILER = "console"
MAILER_DEFAULT_OPTIONS = {
"from": "no-reply@example.com",
}
if env == "test":
MAILER = "memory"
elif env == "prod":
MAILER = "ses_smtp"
Declare the backends once; MAILER is the only thing that changes per environment. The same email classes and the same send_later() calls run behind whichever one is the default.
7.5 Writing a custom backend
If you send through an HTTP API (Resend, Postmark, SendGrid, SES) rather than SMTP, you write a small class that subclasses BaseMailer and overrides send_now(). Everything else - rendering, attachments, headers, address encoding - is inherited.
The sketch:
# myapp/mailers/resend_mailer.py
import httpx
from proper.emails import BaseMailer, EmailMessageDict
class ResendMailer(BaseMailer):
def __init__(self, *, api_key: str, **kwargs):
super().__init__(**kwargs)
self.api_key = api_key
self.client = httpx.Client(
base_url="https://api.resend.com",
headers={"Authorization": f"Bearer {api_key}"},
)
def send_now(self, *messages: EmailMessageDict) -> int:
sent = 0
for message in messages:
# inherited - returns a stdlib EmailMessage
rendered = self.render(message)
is_html = message["content_subtype"] == "html"
is_text = message["content_subtype"] == "plain"
response = self.client.post("/emails", json={
"from": rendered["From"],
"to": message["to"],
"subject": rendered["Subject"],
"html": message["body"] if is_html else None,
"text": message["body"] if is_text else None,
})
if response.is_success:
sent += 1
elif not self.fail_silently:
response.raise_for_status()
return sent
Then add it to MAILERS - as the default, or as a backend you reach with via=:
MAILERS = {
# ...the bundled backends...
"resend": {
"type": "myapp.mailers.resend_mailer.ResendMailer",
"api_key": os.getenv("RESEND_API_KEY"),
},
}
MAILER = "resend"
The render() method on BaseMailer does the heavy lifting (multipart assembly, attachments, IDNA encoding); your send_now() only has to translate the result into whatever shape your provider expects. A real implementation would also serialize attachments and alternatives into the provider's payload - the sketch above is just enough to show the seam.
8. Testing
In tests, MAILER is "memory" (the in-memory backend), and send_email_task runs in immediate mode. Together that means: anything your code sends ends up in app.mailer.outbox, synchronously, by the time the call returns. No waiting, no worker, no SMTP server.
A typical assertion:
def test_reset_request_sends_email(client, user):
response = client.post("/password-reset", data={"login": user.email})
assert response.status_code == 302
assert len(app.mailer.outbox) == 1
email = app.mailer.outbox[0]
assert email["Subject"] == "Reset your password"
assert email["To"] == user.email
assert "Reset" in email.get_body(("plain", "html")).get_content()
A few things worth knowing:
- Outbox entries are stdlib
email.message.EmailMessageobjects. Headers are accessed by name (email["Subject"],email["To"]) and are case-insensitive. Bodies come back throughget_body()andget_content(). Bccis not in the headers. The SMTP backend treatsbccas a routing instruction - it includes the address inRCPT TObut strips it from the visible message, exactly as a mail server would. Assert againstmessage["bcc"]on the unrendered dict if you need to check it. (For pure-stdlibEmailMessageoutbox entries,BccisNone.)- The outbox accumulates across the test. Reset it between tests if you assert lengths - either with a fixture that clears it, or by asserting on the new messages since a saved index.
For testing an email class in isolation - render its template, check the output, without going through a controller - construct and send() it directly:
def test_welcome_email_includes_dashboard_link(user):
WelcomeEmail(user).send(to=user.email)
email = app.mailer.outbox[-1]
assert "/dashboard" in email.get_content()
If you specifically want to exercise the queued path - serialization round-trip, the worker - that's an integration test, not a unit test. Point QUEUE at SqliteHuey and run a real consumer. For the everyday question of "did my email get sent and did it say the right thing," immediate mode plus ToMemoryMailer is the answer.
9. Internationalized addresses
Email addresses with non-ASCII domain names get encoded to punycode automatically. user@münchen.de becomes user@xn--mnchen-3ya.de on the way out. You don't need to do anything; addresses you store as Unicode in your database are fine.
For servers that accept Unicode addresses natively (SMTPUTF8), set the policy on the mailer:
import email.policy
from proper.emails import SMTPMailer
class UTF8SMTPMailer(SMTPMailer):
policy = email.policy.SMTPUTF8
Non-ASCII local-parts (the part before @) are rejected by default because most SMTP servers can't handle them. To accept them under SMTPUTF8, call prep_address(..., force_ascii=False) in your subclass.
10. Where this could grow
Email in Proper covers the basics well: a class-per-message convention, template auto-discovery, background sending, the three standard backends, working tests. Several features common in mature email systems aren't here yet. None of them are blockers - workarounds exist - but they're the obvious places the framework will grow.
- Browser previews. A dev-only route where you can preview your email templates with fixture data.This is probably the highest-ROI feature on the list.
- First-party HTTP backends. Most production traffic in 2026 goes through Resend, Postmark, SendGrid, or SES rather than SMTP - they handle deliverability, bounces, and metrics that SMTP doesn't. The custom-backend recipe above works, but a curated set of provider mailers (and addons that ship them) would save every app re-implementing the same thing.
- CSS inlining at render time. Today the email layout has an empty
<style>block with a comment noting that styles must be inline. A premailer-style pass would let users write normal CSS and have it inlined intostyle="..."attributes automatically. - Inline images (CIDs).
attach_file(path, inline=True)returning acid:reference for<img src="cid:...">. Requiresmultipart/relatedhandling that the current code doesn't expose. - Mail interceptors. A
MAILER_INTERCEPT_TO = "staging@example.com"config that rewrites all outgoingto/cc/bccto a single address on non-production environments. This is the safest cure for the "we accidentally emailed 50,000 production users from staging" class of incident. - Bounce and complaint webhooks. A small interface for receiving provider webhooks and flagging the corresponding
User.emailas bounced or complained, so the app stops trying to send to dead addresses.
If you build any of these against the current code, the framework would love a PR.
11. What's next
Email touches several other parts of Proper. A few places to go from here:
- Background Tasks -
send_later()is built on the task queue; that guide covers the worker process, retries, scheduling, and the immediate-mode behavior the testing section relies on. - Jx Components - email templates are Jx components. The
{#def ... #}declaration and<Layout>import work the same way they do anywhere else in your views. - Authentication - the auth addon's password-reset flow is the canonical example threaded through this guide; the auth guide covers the surrounding token model and controller setup.
- Deployment - running the worker process in production, configuring environment variables for your SMTP or provider credentials, and the operational side of email sending.