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 587 → SMTP_USE_TLS=true (default): connect in clear text and
upgrade via STARTTLS. Port 465 → SMTP_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_PASSWORDnever lands in the repo. Keep.envin.gitignoreand commit only a.env.examplewith 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_ADDRmust 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 Password — SMTP_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 composeEmailSettingsinto yourSettings. - One
EmailUtilsinstance per app;send()is async and opens a connection per call. - The text
bodyis required;htmlis the optional multipart alternative. render_template()(withtemplate_dir) builds the HTML from Jinja2.- The bundled auth flow already sends activation/reset with overridable templates.