Email Sending — Transactional Emails with FastAPI-Mail

Transactional emails — welcome messages, password reset links, email verification codes, notification digests — are a core part of most web applications. FastAPI-Mail provides a clean async interface to SMTP and HTML email templates using Jinja2. Since sending an email involves a network call to an SMTP server (typically 100–500ms), email sending is always run as a background task rather than blocking the response. A fake SMTP backend for development and testing allows you to verify email content without actually sending messages.

FastAPI-Mail Setup

pip install fastapi-mail jinja2
# app/email/config.py
from fastapi_mail import FastMail, MessageSchema, ConnectionConfig, MessageType
from pydantic import EmailStr
from app.config import settings

# Connection configuration (reads from settings)
mail_conf = ConnectionConfig(
    MAIL_USERNAME   = settings.mail_username,
    MAIL_PASSWORD   = settings.mail_password,
    MAIL_FROM       = settings.mail_from,
    MAIL_FROM_NAME  = settings.app_name,
    MAIL_PORT       = settings.mail_port,     # 587 for TLS, 465 for SSL
    MAIL_SERVER     = settings.mail_server,   # smtp.gmail.com or SendGrid
    MAIL_STARTTLS   = True,
    MAIL_SSL_TLS    = False,
    USE_CREDENTIALS = True,
    TEMPLATE_FOLDER = "app/email/templates",  # Jinja2 templates directory
)

# Suppress actual sending in development
mail_conf.SUPPRESS_SEND = settings.environment != "production"

fast_mail = FastMail(mail_conf)
Note: Setting SUPPRESS_SEND = True in development means FastAPI-Mail goes through all the templating and validation steps but does not actually connect to the SMTP server. This is the cleanest way to test email rendering without sending real messages. In tests, set SUPPRESS_SEND = True universally and use a pytest fixture that captures what emails would have been sent by inspecting the fast_mail.outbox list (available when suppression is enabled).
Tip: For production email delivery, use a transactional email service (SendGrid, Mailgun, AWS SES, Resend) rather than a raw SMTP server. These services provide deliverability infrastructure, bounce handling, unsubscribe management, and analytics. The SMTP credentials in FastAPI-Mail’s configuration can point to any of these services — the API is the same regardless of the underlying provider.
Warning: Never include password reset tokens or sensitive one-time codes directly in the email body as plain text URL parameters if you cannot use HTTPS everywhere. Emails pass through multiple servers and can be logged. For password reset, generate a signed token (using HMAC with a secret key and the user’s current password hash) that is only valid once and expires in 15–60 minutes. Store reset tokens in the database and invalidate them after use.

Email Templates with Jinja2

<!-- app/email/templates/welcome.html -->
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"></head>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
  <h1 style="color: #333;">Welcome to {{ app_name }}!</h1>
  <p>Hi {{ user_name }},</p>
  <p>Your account has been created successfully.</p>
  <p>
    <a href="{{ login_url }}" style="background:#0070f3; color:#fff; padding:12px 24px;
       text-decoration:none; border-radius:6px;">
      Sign In
    </a>
  </p>
  <p style="color:#666; font-size:12px;">
    If you did not create this account, please ignore this email.
  </p>
</body>
</html>

Sending Emails as Background Tasks

from fastapi import BackgroundTasks, Depends
from fastapi_mail import MessageSchema, MessageType
from pydantic import EmailStr
import logging

logger = logging.getLogger(__name__)

async def send_welcome_email_task(user_email: str, user_name: str) -> None:
    """Background task: send welcome email with HTML template."""
    try:
        message = MessageSchema(
            subject    = f"Welcome to {settings.app_name}!",
            recipients = [user_email],
            template_body = {
                "app_name":  settings.app_name,
                "user_name": user_name,
                "login_url": f"{settings.frontend_url}/login",
            },
            subtype = MessageType.html,
        )
        await fast_mail.send_message(message, template_name="welcome.html")
        logger.info(f"Welcome email sent to {user_email}")
    except Exception as e:
        logger.error(f"Failed to send welcome email to {user_email}: {e}")

async def send_password_reset_email(
    user_email: str,
    user_name:  str,
    reset_token: str,
) -> None:
    """Background task: send password reset link."""
    reset_url = f"{settings.frontend_url}/reset-password?token={reset_token}"
    try:
        message = MessageSchema(
            subject    = "Password Reset Request",
            recipients = [user_email],
            template_body = {
                "user_name": user_name,
                "reset_url": reset_url,
                "expires_in": "15 minutes",
            },
            subtype = MessageType.html,
        )
        await fast_mail.send_message(message, template_name="password_reset.html")
    except Exception as e:
        logger.error(f"Password reset email failed for {user_email}: {e}")

# ── Route handler using background email ─────────────────────────────────────
@router.post("/auth/register", response_model=UserResponse, status_code=201)
async def register(
    data:       RegisterRequest,
    background: BackgroundTasks,
    db:         Session = Depends(get_db),
):
    user = create_user(db, data)

    # Email runs after response — does not slow down registration
    background.add_task(send_welcome_email_task, user.email, user.name)

    return user

@router.post("/auth/forgot-password", status_code=202)
async def forgot_password(
    email:      str,
    background: BackgroundTasks,
    db:         Session = Depends(get_db),
):
    user = db.scalars(select(User).where(User.email == email.lower())).first()
    # Always return 202 regardless — prevents user enumeration
    if user:
        token = generate_password_reset_token(user.id, user.password_hash)
        background.add_task(send_password_reset_email, user.email, user.name, token)
    return {"message": "If an account exists, a reset email has been sent"}

Common Mistakes

Mistake 1 — Sending email synchronously (blocking the response)

❌ Wrong — 500ms SMTP call blocks every registration response:

await fast_mail.send_message(message)   # runs before returning to client!

✅ Correct — add as BackgroundTask or enqueue in ARQ.

Mistake 2 — Revealing whether an email is registered in forgot-password

❌ Wrong — confirms email existence:

if not user:
    raise HTTPException(404, "No account with this email")   # user enumeration!

✅ Correct — always return 202 with a generic message:

return {"message": "If an account exists, a reset email has been sent"}   # ✓

Mistake 3 — Password reset tokens that never expire

❌ Wrong — valid for years, anyone who finds old link can reset:

token = secrets.token_urlsafe(32)   # no expiry!

✅ Correct — use HMAC-signed tokens with expiry or store in DB with expires_at.

Quick Reference

Task Code
Install pip install fastapi-mail jinja2
Configure ConnectionConfig(MAIL_USERNAME=..., MAIL_SERVER=...)
Suppress in dev mail_conf.SUPPRESS_SEND = True
Template email MessageSchema(template_body={...}, subtype=MessageType.html)
Send await fast_mail.send_message(msg, template_name="...")
As background background.add_task(send_email_fn, arg1, arg2)
Prevent enumeration Always return 202 for forgot-password regardless

🧠 Test Yourself

Your forgot-password endpoint returns 404 when no user with that email is found, and 200 with a reset link when the user exists. What security problem does this create?