Skip to content

Transactional email

Send email over SMTP with EmailUtils — plain-text body + HTML alternative, attachments, and Jinja2 template rendering. Requires the [email] extra (aiosmtplib + jinja2 + email-validator).

Configuration

EmailSettings ships the SMTP fields ready to use (SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD, SMTP_FROM_ADDR, SMTP_USE_TLS, SMTP_USE_SSL, SMTP_TIMEOUT_SECONDS). Compose it into your Settings and use email_kwargs() to build the utility without mapping field by field:

# src/core/mailer.py
from tempest_fastapi_sdk import EmailUtils

from src.core.settings import settings


mailer = EmailUtils(**settings.email_kwargs())

email_kwargs() bridges the SMTP names to the EmailUtils ones: SMTP_USE_TLS (STARTTLS, port 587) → use_starttls; SMTP_USE_SSL (implicit TLS from connect, port 465) → use_tls.

STARTTLS vs SSL/TLS — opportunistic by default

Port 587SMTP_USE_TLS=true (default): connect in clear text and upgrade via STARTTLS. Port 465SMTP_USE_SSL=true: connect already encrypted. STARTTLS is opportunistic: EmailUtils only upgrades when the server advertises STARTTLS, so a plain server (MailHog on :1025, or :25) just works — no more SMTPException: SMTP STARTTLS extension not supported by server. (since v0.38.1). To force plain text without even attempting the upgrade, set SMTP_USE_TLS=false; the .env.example generated by tempest new with [email] already ships that way for MailHog.

Production: real SMTP and credentials

In production SMTP is not optional — you send through a real provider (Gmail/Workspace, AWS SES, SendGrid, Mailgun, ...) and that needs a host, port, username, and password. The golden rule: those credentials always come from the environment (.env / secret manager / container variables) and are never in the code or committed to Git.

An SMTP credential is a secret — treat it like a DB password

  • SMTP_PASSWORD never lands in the repo. Keep .env in .gitignore and commit only a .env.example with fake values.
  • For Gmail/Workspace do not use the account password — generate an App Password (https://myaccount.google.com/apppasswords) with 2FA enabled. The normal password fails with 535 Authentication failed.
  • SMTP_FROM_ADDR must be an address on a domain you control and have authenticated (SPF/DKIM/DMARC), otherwise the email is marked spam or rejected.

The production services already follow this pattern — declare the SMTP fields on your Settings and read everything from the environment:

# src/core/settings.py
from pydantic import Field
from tempest_fastapi_sdk import BaseAppSettings, EmailSettings


class Settings(EmailSettings, BaseAppSettings):
    """Application settings. SMTP inherited from EmailSettings."""

    FRONTEND_URL: str = Field(
        default="http://localhost:3000",
        description="Frontend base URL (used in email links).",
    )


settings: Settings = Settings()
# .env.example  (committed — fake values)
# the real .env (NOT committed) holds the actual credentials
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USERNAME=your_email@example.com
SMTP_PASSWORD="your_app_password"
SMTP_FROM_ADDR=your_email@example.com
SMTP_USE_TLS=true        # STARTTLS on 587
SMTP_USE_SSL=false       # use true (and PORT=465) for implicit TLS

Common providers and their port/TLS combination:

Provider SMTP_HOST SMTP_PORT SMTP_USE_TLS (STARTTLS) SMTP_USE_SSL (implicit TLS)
Gmail / Workspace smtp.gmail.com 587 true false
Gmail (direct TLS) smtp.gmail.com 465 false true
AWS SES email-smtp.<region>.amazonaws.com 587 true false
SendGrid smtp.sendgrid.net 587 true false
MailHog (local dev) localhost 1025 false false

How alofans-api and transport-backend do it

These are the two production patterns: alofans-api uses Gmail on port 587 (STARTTLS) with an App PasswordSMTP_USE_TLS=true. transport-backend uses port 465 (implicit TLS)SMTP_USE_SSL=true, SMTP_PORT=465. Both read SMTP_* from the environment and never hardcode the password. Pick STARTTLS (587) or SSL (465) per your provider; do not enable both at once.

Send an email

Each send() opens a fresh SMTP connection. to accepts a string or an iterable; the body (plain text) is always sent and html becomes the multipart alternative when present.

import asyncio

from tempest_fastapi_sdk import EmailUtils

from src.core.settings import settings

mailer = EmailUtils(**settings.email_kwargs())


async def main() -> None:
    """Run this example."""
    await mailer.send(
        to="ana@example.com",
        subject="Welcome!",
        body="Your account was created.",
        html="<p>Your account was <strong>created</strong>.</p>",
    )


asyncio.run(main())

Optional per-message parameters: cc, bcc, attachments (Iterable[Path]), reply_to, and from_addr (overrides the default sender). Any SMTP error is re-raised as aiosmtplib.errors.SMTPException for the caller to handle.

Sending to many: send_many

send() is for the one-recipient transactional message. Telling the whole base something — maintenance, an announcement, a campaign — is where the naive loop gets expensive:

import asyncio

from tempest_fastapi_sdk import BulkEmailReport, EmailUtils

from src.core.settings import settings

mailer = EmailUtils(**settings.email_kwargs())

recipients = ["ana@example.com", "bruno@example.com", "cadu@example.com"]


async def main() -> None:
    """Tell the base, then deal with what did not land."""
    report: BulkEmailReport = await mailer.send_many(
        recipients,
        subject="Scheduled maintenance",
        body="We are down from 02:00 to 03:00.",
        batch_size=500,
        max_concurrency=32,
    )

    print(report.delivered, "delivered,", report.failed, "not")

    for dead in report.permanent:
        print(f"prune {dead.email}: {dead.code} {dead.message}")

    for later in report.transient:
        print(f"requeue {later.email}: {later.code}")


asyncio.run(main())

What it does that for user in users: await mailer.send(...) does not:

Loop over send() send_many()
SMTP connections one per message one per batch (batch_size)
Parallelism none, or unbounded gather max_concurrency open connections
Bad address raises, ending the run lands in the report
5xx vs 4xx indistinguishable permanent vs transient

Concurrency is per connection, not per message

SMTP is serial on one socket: a batch is sent sequentially over its connection, and parallelism comes from running several batches at once. That is why max_concurrency counts open connections — the number a provider actually throttles.

Only an operation failure raises

Host that will not resolve, connection refused, authentication rejected: those raise, because nothing was sent. A refusal of one recipient never raises — it is the report. 5xx means the mailbox does not exist (prune it from your base); 4xx means full or greylisted (requeue). A refusal with no code is filed as transient: the cheap mistake is retrying, not deleting a good address.

Jinja2 templates

Pass template_dir= at construction and render with render_template() — the Jinja2 environment is built lazily on first call and memoized. Autoescaping is on for .html / .htm / .xml.

# src/core/mailer.py

import asyncio

from tempest_fastapi_sdk import EmailUtils

from src.core.settings import settings


mailer = EmailUtils(
    host=settings.SMTP_HOST,
    port=settings.SMTP_PORT,
    from_addr=settings.SMTP_FROM_ADDR,
    template_dir="src/templates/emails",
)

html: str = mailer.render_template(
    "welcome.html",
    {"user_name": "Ana", "app_url": "https://app.example.com"},
)


async def main() -> None:
    """Run this example."""
    await mailer.send(
        to="ana@example.com",
        subject="Welcome!",
        body="Welcome, Ana!",
        html=html,
    )


asyncio.run(main())

Bundled auth-flow templates

The bundled auth flow (make_auth_router) already sends activation and password-reset emails using built-in templates (activation.html, password_reset.html). Drop same-named files in your template_dir to override them. See Auth flow.

Example: password reset

A service that sends a reset link with a short-lived JWT. Note that request_reset returns silently for an unregistered email — this avoids account enumeration.

# src/services/password_reset.py
from datetime import timedelta

from tempest_fastapi_sdk import EmailUtils, JWTUtils

from src.db.repositories import UserRepository


class PasswordResetService:
    def __init__(
        self,
        repo: UserRepository,
        tokens: JWTUtils,
        mailer: EmailUtils,
    ) -> None:
        self.repo: UserRepository = repo
        self.tokens: JWTUtils = tokens
        self.mailer: EmailUtils = mailer

    async def request_reset(self, email: str) -> None:
        """Send a reset link to ``email`` (silent if it does not exist)."""
        user = await self.repo.get_or_none({"email": email})
        if user is None:
            return
        token: str = self.tokens.encode(
            {"sub": str(user.id), "purpose": "password_reset"},
            ttl=timedelta(minutes=15),
        )
        reset_url: str = f"https://app.example.com/reset-password?token={token}"
        await self.mailer.send(
            to=user.email,
            subject="Reset your password",
            body=f"Open to reset: {reset_url}",
            html=f'<p>Click <a href="{reset_url}">here</a> to reset.</p>',
        )

Recap

  • Install [email] and compose EmailSettings into your Settings.
  • One EmailUtils instance per app; send() is async and opens a connection per call.
  • The text body is required; html is the optional multipart alternative.
  • render_template() (with template_dir) builds the HTML from Jinja2.
  • The bundled auth flow already sends activation/reset with overridable templates.