Complete DevOps Bootcamp: Master DevOps in 12 Weeks
FastAPIFiles, Forms and Background Tasks

Sending Emails and Notifications

Email is one of those features every project eventually needs and nobody loves implementing. The good news is that for a FastAPI app, the actual sending is a handful of lines. The interesting decisions are around how to send - should the request wait for the email to leave, or should it fire-and-forget? The next two docs in this section answer that more carefully with background tasks and async workflows. This one is about the sending itself.

Don't send from inside the request, if you can help it

A bad pattern that's surprisingly common:

@app.post("/signup")
def signup(user: UserCreate):
    save_user(user)
    smtp.send(welcome_email(user))   # blocks the request
    return {"ok": True}

The user clicks "sign up", and then their browser sits there spinning for 2-5 seconds while your server connects to an SMTP server, negotiates TLS, ships the email, and waits for an OK. If your SMTP provider has a slow day, the signup endpoint has a slow day.

The right shape, in order of how much engineering you want to invest:

ApproachLatency on the requestReliability if it fails
Sync send inside the requestBadThe whole request fails
FastAPI BackgroundTasksGoodBest-effort; lost if the worker restarts
External queue (Celery, Arq, RQ)GoodRetried, durable, observable
Transactional email API (SendGrid, SES, Postmark) called from a background taskGoodProvider handles retries

The middle two are the realistic choices for most apps. We'll go through the actual sending here and the queueing in the next two pages.

Sending with the standard library

For a one-off, the standard library is enough. No external dependency.

import smtplib
from email.message import EmailMessage

def send_email(*, to: str, subject: str, body: str) -> None:
    msg = EmailMessage()
    msg["From"] = "no-reply@example.com"
    msg["To"] = to
    msg["Subject"] = subject
    msg.set_content(body)

    with smtplib.SMTP("smtp.example.com", 587) as smtp:
        smtp.starttls()
        smtp.login("user", "password")
        smtp.send_message(msg)

That works. It's also synchronous and brittle. It's a fine starting point and a poor finishing point.

Sending with fastapi-mail

For a more comfortable API, async support, HTML templates, and attachments, fastapi-mail is the popular pick.

pip install fastapi-mail
from fastapi_mail import ConnectionConfig, FastMail, MessageSchema, MessageType

mail_config = ConnectionConfig(
    MAIL_USERNAME="user",
    MAIL_PASSWORD="password",
    MAIL_FROM="no-reply@example.com",
    MAIL_PORT=587,
    MAIL_SERVER="smtp.example.com",
    MAIL_STARTTLS=True,
    MAIL_SSL_TLS=False,
)

fast_mail = FastMail(mail_config)

async def send_welcome(to: str, name: str) -> None:
    message = MessageSchema(
        subject="Welcome aboard!",
        recipients=[to],
        body=f"<p>Hi {name}, glad to have you.</p>",
        subtype=MessageType.html,
    )
    await fast_mail.send_message(message)

The await matters - under the hood it uses aiosmtplib, so it actually releases the event loop while talking to the SMTP server. That's a real benefit in an async app.

SMTP vs. transactional email APIs

For anything beyond a side project, a transactional email API beats SMTP for a few honest reasons:

Raw SMTPTransactional API (SendGrid, SES, Postmark, etc.)
SetupServer credentialsAPI key
DeliverabilityUp to you (SPF, DKIM, DMARC, IP reputation)Provider takes care of most of it
RetriesYou implementProvider handles
Bounce trackingYou implementWebhook from the provider
Template managementFiles in your repoUI in the provider
Per-message costFree if you run your own serverCents per thousand

If your app actually depends on emails landing - password resets, two-factor codes, receipts - use a transactional API. The deliverability difference alone is worth it. SMTP from a random VPS regularly gets routed to spam.

Calling SendGrid from a route looks like this:

import os
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail

client = SendGridAPIClient(os.environ["SENDGRID_API_KEY"])

def send_welcome(to: str, name: str) -> None:
    message = Mail(
        from_email="no-reply@example.com",
        to_emails=to,
        subject="Welcome!",
        html_content=f"<p>Hi {name}.</p>",
    )
    client.send(message)

Synchronous, but quick - it's an HTTPS POST, not an SMTP handshake.

Templating bodies

Hardcoded HTML strings get ugly fast. Jinja2 templates fit the job:

from jinja2 import Environment, FileSystemLoader, select_autoescape

env = Environment(
    loader=FileSystemLoader("templates/email"),
    autoescape=select_autoescape(["html"]),
)

def render(template: str, **context) -> str:
    return env.get_template(template).render(**context)


body = render("welcome.html", name="Alice", verify_url="https://...")

autoescape=True is not optional - without it, a user with the name <script>... becomes a stored XSS in every email they're mentioned in.

A small SignalingNote about plain-text fallbacks

A well-formed email has both HTML and plain-text versions in the same envelope. Some clients (a tiny minority, but they exist) won't render HTML. Spam filters also tend to view HTML-only emails with more suspicion.

message = MessageSchema(
    subject="Welcome",
    recipients=[to],
    body="<p>Welcome!</p>",
    template_body={"name": name},
    subtype=MessageType.html,
)
# fastapi-mail handles alternative bodies if you provide a template;
# for hand-built messages, set both body and subtype carefully.

For raw SMTP with the stdlib:

msg = EmailMessage()
msg["From"] = ...
msg["To"] = ...
msg["Subject"] = ...
msg.set_content("Plain text version.")          # fallback
msg.add_alternative("<p>HTML version.</p>", subtype="html")

Other notification channels, briefly

The patterns are the same as email - call an SDK, ideally not in the request thread:

ChannelLibraryNotes
Slackslack_sdkIncoming webhooks are dead simple; Bot tokens for more
Discordaiohttp + webhook URLOne HTTP POST per message
SMStwilioCosts real money per send - rate-limit yourself
Push (web)pywebpushNeeds VAPID keys and a subscription per device
Push (mobile)Firebase Cloud MessagingServer-side via firebase-admin

The architectural pattern across all of them: receive request → save the source-of-truth state → enqueue a job to send the notification → return success to the user. The notification leaving the system is a side effect, not a precondition for the request succeeding.

Local dev: don't send to real inboxes

Two safety habits that pay back many times over:

  • In dev, point SMTP at MailHog or Mailpit. Both catch outgoing mail and show it in a web UI without sending it onward.
  • In a staging environment, allowlist a small set of internal email addresses. Anything outside the allowlist gets silently dropped or routed to a test inbox. This stops the classic "we ran a bulk job against production data on staging and emailed 50,000 customers" disaster.
ALLOWED = {"qa@example.com", "dev@example.com"}

def safe_send(to: str, *args, **kw):
    if os.getenv("ENV") == "staging" and to not in ALLOWED:
        log.info("staging email suppressed", extra={"to": to})
        return
    return send_email(to=to, *args, **kw)

A small habit. A large amount of dignity preserved.

Where this connects

We've now got the how of sending. The next page covers BackgroundTasks, which is FastAPI's built-in answer to "don't make the request wait for this." After that, async workflows pull the pieces together for slightly more complex orchestration.

How is this guide?

Last updated on