DEV Community

patrykkomow12
patrykkomow12

Posted on Originally published at getpylaunch.com

Sending email from FastAPI: SMTP, Resend, templates and testing

Every SaaS sends the same handful of emails: confirm your address, reset your password, your payment failed. Here is a small module that sends them over SMTP or an HTTP API, in HTML and plain text, in the user's language, without blocking the event loop, and without an email outage taking your sign-up form down with it.

One function, and it never raises

The rest of the app calls one function, with the name of an email and its parameters:

await send_template(user.email, "verify", user.locale, link=link, hours=48)
Enter fullscreen mode Exit fullscreen mode

Underneath, a small dataclass and a dispatcher:

# app/email/__init__.py
@dataclass
class Email:
    to: str
    subject: str
    text: str
    html: str | None = None


# Filled by the "memory" backend. Tests read and clear it.
outbox: list[Email] = []


async def send_email(to: str, subject: str, text: str, html: str | None = None) -> bool:
    """Sends one email. Returns False instead of raising when delivery fails, so a mail
    outage doesn't break sign-ups or payments; the failure is logged."""
    email = Email(to=to, subject=subject, text=text, html=html)
    backend = get_settings().email_backend
    try:
        if backend == "memory":
            outbox.append(email)
        elif backend == "console":
            await backends.send_console(email)
        elif backend == "resend":
            await backends.send_resend(email)
        else:
            await backends.send_smtp(email)
    except Exception:
        logger.exception("Could not send %r to %s", subject, to)
        return False
    return True
Enter fullscreen mode Exit fullscreen mode

Returning False instead of raising is the important decision here. Suppose your SMTP provider has a bad hour. If sending raises, every sign-up during that hour gets a 500 after the account was already created, and a Stripe webhook that tries to send "your payment failed" fails too and gets retried for days. If sending logs and carries on, the user is signed up and can press "send the link again" later. The broad except Exception is deliberate: at this boundary, any failure to deliver means the same thing.

One honest limitation: this sends during the request. With a working provider that adds a fraction of a second to a sign-up, and the 15-second timeout below is the worst case. If your app sends a lot of email, or you want retries, move this behind a job queue. For a handful of transactional messages, a queue is more infrastructure than the problem needs.

Backends you pick in one setting

# app/config.py
# "smtp" sends through SMTP_*, "resend" through the Resend API, "console"
# prints emails to the log, "memory" keeps them in a list (used by the tests).
email_backend: Literal["smtp", "resend", "console", "memory"] = "console"
email_from: str = "MySaaS <hello@example.com>"
# Where replies go, if not to EMAIL_FROM (e.g. your support inbox).
email_reply_to: str = ""
resend_api_key: str = ""
smtp_host: str = "localhost"
smtp_port: int = 587
smtp_username: str = ""
smtp_password: str = ""
# "starttls" for port 587, "ssl" for port 465, "none" for a local catcher like Mailpit.
smtp_security: Literal["starttls", "ssl", "none"] = "starttls"
Enter fullscreen mode Exit fullscreen mode

Literal means a typo in EMAIL_BACKEND stops the app at startup, rather than silently sending nothing. console as the default means a fresh checkout works before anyone has configured anything.

SMTP without blocking the event loop

smtplib is synchronous, and calling it directly in an async route freezes every other request until the mail server answers. You don't need an async SMTP library for this. Run the standard one in a worker thread:

# app/email/backends.py
def build_message(email) -> EmailMessage:
    """A multipart email: plain text first, HTML as the preferred alternative."""
    settings = get_settings()
    message = EmailMessage()
    message["From"] = settings.email_from
    message["To"] = email.to
    message["Subject"] = email.subject
    if settings.email_reply_to:
        message["Reply-To"] = settings.email_reply_to
    message.set_content(email.text)
    if email.html:
        message.add_alternative(email.html, subtype="html")
    return message


def _send_smtp_blocking(email) -> None:
    settings = get_settings()
    context = ssl.create_default_context()
    if settings.smtp_security == "ssl":
        smtp = smtplib.SMTP_SSL(settings.smtp_host, settings.smtp_port, context=context, timeout=15)
    else:
        smtp = smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=15)
    with smtp:
        if settings.smtp_security == "starttls":
            smtp.starttls(context=context)
        if settings.smtp_username:
            smtp.login(settings.smtp_username, settings.smtp_password)
        smtp.send_message(build_message(email))


async def send_smtp(email) -> None:
    # smtplib blocks, so it runs in a worker thread to keep the event loop free.
    await asyncio.to_thread(_send_smtp_blocking, email)
Enter fullscreen mode Exit fullscreen mode

A few details that matter:

  • Set a timeout. smtplib's default is to wait forever, and a hung connection would hold a worker thread and a user's request with it.
  • Order the parts text first, HTML second. In multipart/alternative, clients show the last part they understand, so HTML wins where it can and text is the fallback. set_content then add_alternative does this for you.
  • EmailMessage, not string formatting. It handles encoding, so Polish characters in a subject work, and it rejects line breaks in headers, which closes off header injection through an address.

Some hosts block outgoing mail ports on new servers (Hetzner blocks 25 and 465), so use port 587 with STARTTLS, or an HTTP API.

An HTTP API instead of SMTP

Providers with an HTTP API work over port 443, which is never blocked, and httpx is already async. This one is for Resend; others look nearly the same:

async def send_resend(email) -> None:
    """Resend (resend.com): an HTTP API instead of SMTP. Set RESEND_API_KEY."""
    settings = get_settings()
    payload = {
        "from": settings.email_from,
        "to": [email.to],
        "subject": email.subject,
        "text": email.text,
    }
    if email.html:
        payload["html"] = email.html
    if settings.email_reply_to:
        payload["reply_to"] = settings.email_reply_to
    async with httpx.AsyncClient(timeout=15) as client:
        response = await client.post(
            RESEND_URL,
            json=payload,
            headers={"Authorization": f"Bearer {settings.resend_api_key}"},
        )
        response.raise_for_status()
Enter fullscreen mode Exit fullscreen mode

raise_for_status() turns a rejected key or a bounced request into an exception, which send_email catches and logs. Without it, a 401 from the provider would look like a success, and you'd find out from users who never got their email.

Templates: words in translation files, one shared layout

Writing a separate HTML file for each email means ten copies of the same layout, times the number of languages. Transactional emails all have the same shape anyway: a heading, a few paragraphs, maybe a button, maybe small print. So each email is just its words, stored with the rest of the UI texts:

"email.verify.subject": "Confirm your email for {app_name}",
"email.verify.heading": "Confirm your email address",
"email.verify.body": "First paragraph.\n\nSecond paragraph.",
"email.verify.button": "Confirm email",
"email.verify.note": "Small print under the button (optional)."
Enter fullscreen mode Exit fullscreen mode

One function turns those keys into a subject and both bodies:

# app/email/render.py
_env = Environment(
    loader=FileSystemLoader(Path(__file__).parent.parent / "templates" / "emails"),
    autoescape=select_autoescape(["html"]),
    trim_blocks=True,
    lstrip_blocks=True,
)


def render_email(name: str, locale: str, **params: object) -> tuple[str, str, str]:
    """Returns (subject, text, html)."""
    settings = get_settings()
    params = {"app_name": settings.app_name, **params}

    def text(part: str) -> str:
        return translate(locale, f"email.{name}.{part}", **params)

    def optional(part: str) -> str | None:
        key = f"email.{name}.{part}"
        return translate(locale, key, **params) if has_translation(locale, key) else None

    link = params.get("link")
    context = {
        "lang": locale,
        "app_name": settings.app_name,
        "subject": text("subject"),
        "heading": text("heading"),
        "paragraphs": text("body").split("\n\n"),
        "link": link,
        "button": text("button") if link else None,
        "note": optional("note"),
    }
    html = _env.get_template("layout.html").render(context)
    plain = _env.get_template("layout.txt").render(context)
    return context["subject"], plain, html
Enter fullscreen mode Exit fullscreen mode

The email environment is separate from the one that renders pages, because it has no request and needs no CSRF helpers. select_autoescape(["html"]) escapes values in layout.html and leaves layout.txt alone, so an email address containing <script> is harmless in the HTML and still readable in the text version.

Adding an email is now three lines of JSON per language and one function call. Nobody has to touch the layout.

HTML that survives email clients

Email clients ignore most of what you know about modern CSS: Outlook renders with Word's engine, and several webmail clients strip <style> blocks. So the layout is old-fashioned on purpose:

  • tables for structure, and inline style attributes on every element;
  • one column under 600 pixels wide, which also reads well on a phone;
  • the link printed as text under the button, for clients that block or mangle buttons;
  • a plain-text version of every email, which helps with spam filters and with people who prefer it.

Don't run Tailwind over it. Keep this one template hand-written and leave it alone.

Sending in the right language

While a user is on the site, their language comes from the request. But many emails go out when they're not there: a webhook arrives at night saying a renewal failed, and there is no request to ask. So the language is stored on the account, and every caller passes it in:

# app/billing/service.py
async def _notify(session: AsyncSession, customer_id: str | None, kind: str) -> None:
    """Emails the user about a billing problem, in their language."""
    user_id = await _user_id_for_customer(session, customer_id) if customer_id else None
    user = await session.get(User, user_id) if user_id else None
    if user is None:
        return
    page = "/pricing" if kind == "subscription_ended" else "/billing"
    await send_template(user.email, kind, user.locale, link=f"{get_settings().base_url}{page}")
Enter fullscreen mode Exit fullscreen mode

Note the base_url setting too. Emails always build links from configuration: webhooks have no useful request to take a host from, and on normal requests the Host header comes from the client, which makes it a poor thing to put in a password reset link.

Reading emails in development

Run Mailpit next to the app. It accepts SMTP, delivers nothing, and shows every message in a web inbox with its HTML and text versions, so you can click verification links and check your layout:

# docker-compose.yml
  mailpit:
    image: axllent/mailpit:v1.27
    ports:
      - "8025:8025"
Enter fullscreen mode Exit fullscreen mode
EMAIL_BACKEND=smtp
SMTP_HOST=mailpit
SMTP_PORT=1025
SMTP_SECURITY=none
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:8025. Because development goes through the real SMTP backend, that code path gets exercised every day.

A preview page in your admin panel is worth the few lines as well. Keep example parameters for every email in one dict, and render any of them in any language:

PREVIEWS: dict[str, dict] = {
    "verify": {"link": "/verify-email?token=example", "hours": 48},
    "reset": {"link": "/reset-password?token=example", "minutes": 60},
    "payment_failed": {"link": "/billing"},
    "account_deleted": {},
}
Enter fullscreen mode Exit fullscreen mode

Testing emails

The tests use the memory backend, so nothing leaves the process, and a test can read what was sent and even follow the link in it. That same PREVIEWS dict makes one parametrized test cover every email in every language, including ones added next year:

LOCALES = ("en", "pl")
MISSING_KEY = re.compile(r"email\.[a-z_]+\.[a-z_]+")


@pytest.mark.parametrize("locale", LOCALES)
@pytest.mark.parametrize("name", list(PREVIEWS))
def test_every_email_renders_in_every_language(name, locale):
    params = {k: f"https://app.test{v}" if k == "link" else v for k, v in PREVIEWS[name].items()}

    subject, text, html = render_email(name, locale, **params)

    # A missing translation shows up as its key, e.g. "email.verify.button".
    for output in (subject, text, html):
        assert not MISSING_KEY.search(output), MISSING_KEY.search(output)
    assert f'<html lang="{locale}">' in html
    if "link" in params:
        assert params["link"] in text
        assert f'href="{params["link"]}"' in html
Enter fullscreen mode Exit fullscreen mode

Test the escaping and the message structure directly:

def test_html_escapes_params():
    _, text, html = render_email("email_changed", "en", email="<script>x</script>@a.b")

    assert "<script>" not in html
    assert "&lt;script&gt;" in html
    assert "<script>x</script>@a.b" in text  # plain text needs no escaping


def test_smtp_message_is_multipart_with_reply_to(monkeypatch):
    monkeypatch.setattr(get_settings(), "email_reply_to", "support@example.com")
    message = backends.build_message(
        email.Email(to="ada@example.com", subject="Hi", text="Plain", html="<p>Rich</p>")
    )

    assert message["Reply-To"] == "support@example.com"
    assert [part.get_content_type() for part in message.iter_parts()] == [
        "text/plain",
        "text/html",
    ]
Enter fullscreen mode Exit fullscreen mode

And the HTTP backend without touching the network, using httpx.MockTransport:

async def test_resend_backend(monkeypatch):
    requests = []

    def handler(request: httpx.Request) -> httpx.Response:
        requests.append(request)
        return httpx.Response(200, json={"id": "email_123"})

    real_client = httpx.AsyncClient
    monkeypatch.setattr(
        backends.httpx,
        "AsyncClient",
        lambda **kw: real_client(transport=httpx.MockTransport(handler)),
    )
    settings = get_settings()
    monkeypatch.setattr(settings, "email_backend", "resend")
    monkeypatch.setattr(settings, "resend_api_key", "re_test_key")

    assert await send_email("ada@example.com", "Hi", "Plain", "<p>Rich</p>")

    assert requests[0].headers["authorization"] == "Bearer re_test_key"
Enter fullscreen mode Exit fullscreen mode

Getting delivered

The code is the easy half. The first thing that goes wrong in production is usually mail landing in spam, and no code fixes that:

  • Send from your own domain, through a provider, not directly from your server's IP address.
  • Add SPF and DKIM records to your DNS. Your provider gives you the exact values. Add a DMARC record too; large mailbox providers now expect it from anyone sending at volume.
  • Keep transactional mail separate from marketing, ideally on a different subdomain, so a newsletter complaint doesn't hurt your password resets.
  • Send yourself the real thing after you deploy: sign up with a Gmail and an Outlook address and read the results.

Checklist

  • One send_template() that logs failures and returns False instead of raising.
  • The backend chosen by one validated setting, with memory for tests.
  • smtplib in asyncio.to_thread, always with a timeout.
  • raise_for_status() on API backends.
  • Words in translation files; one HTML and one text layout; autoescape on HTML only.
  • The user's language stored on the account; links built from BASE_URL.
  • Mailpit in development; a test that renders every email in every language.
  • SPF, DKIM and DMARC set up before launch.

This email module is part of getpylaunch, a FastAPI SaaS starter I sell, with its account and billing emails already written in English and Polish.

Top comments (0)