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(BaseAppSettings, EmailSettings):
    """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.

await mailer.send(
    to="ana@example.com",
    subject="Welcome!",
    body="Your account was created.",
    html="<p>Your account was <strong>created</strong>.</p>",
)

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.

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
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"},
)
await mailer.send(
    to="ana@example.com",
    subject="Welcome!",
    body="Welcome, Ana!",
    html=html,
)

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.