Docs / FastAPI

Send email in FastAPI over SMTP

FastAPI is async, and the standard library's smtplib is not — calling it from a route blocks the event loop for every other request in that worker. This guide sends through Postwing with aiosmtplib, returns the response before the mail goes out, and renders HTML templates without blocking either.

You can get them on the token management page. For security reasons, a token is shown only once — at the moment it is created.

SMTP connection settings

SettingValue
SMTP hostsmtp.postwing.app
Port587
EncryptionSTARTTLS (the connection is upgraded to TLS before login)
UsernameThe login of an SMTP token for your domain
PasswordThe password of that token — shown once, when the token is created
Every mode is also available on a high port: 8465 (SSL/TLS), 8587 (STARTTLS) and 8025 (plain). Many hosting providers and clouds block outbound 25, 465 and 587 — if the connection times out, switch to the matching high port.

Install the packages

bash
pip install aiosmtplib jinja2 pydantic-settings

Configure credentials

settings.py
# settings.py
from pydantic_settings import BaseSettings

class MailSettings(BaseSettings):
    smtp_host: str = "smtp.postwing.app"
    smtp_port: int = 587
    smtp_user: str
    smtp_pass: str
    mail_from: str = "Acme <noreply@your-domain.com>"

    class Config:
        env_file = ".env"

mail_settings = MailSettings()

Write an async send helper

mailer.py
# mailer.py
import aiosmtplib
from email.message import EmailMessage
from settings import mail_settings

async def send_email(
    to: str,
    subject: str,
    text: str,
    html: str | None = None,
) -> None:
    msg = EmailMessage()
    msg["From"] = mail_settings.mail_from
    msg["To"] = to
    msg["Subject"] = subject
    msg.set_content(text)
    if html:
        msg.add_alternative(html, subtype="html")

    await aiosmtplib.send(
        msg,
        hostname=mail_settings.smtp_host,
        port=mail_settings.smtp_port,
        start_tls=True,                    # STARTTLS on 587
        username=mail_settings.smtp_user,
        password=mail_settings.smtp_pass,
        timeout=10,
    )

For implicit TLS on port 465, the flag is different:

python
    # Implicit TLS on port 465 instead
    await aiosmtplib.send(
        msg,
        hostname=mail_settings.smtp_host,
        port=465,
        use_tls=True,          # not start_tls
        username=mail_settings.smtp_user,
        password=mail_settings.smtp_pass,
    )
A synchronous send holds the event loop for the entire SMTP round trip, so every concurrent request in that worker stalls behind it. This is the most common performance bug in FastAPI applications that send email.

Send without making the client wait

BackgroundTasks runs the coroutine after the response has been returned:

main.py
# main.py
from fastapi import BackgroundTasks, FastAPI
from mailer import send_email

app = FastAPI()

@app.post("/orders", status_code=201)
async def create_order(payload: OrderIn, background: BackgroundTasks):
    order = await save_order(payload)

    # Runs after the response is sent — the client never waits for SMTP.
    background.add_task(
        send_email,
        to=order.customer_email,
        subject=f"Your order #{order.id} is confirmed",
        text="Thanks! Your order ships tomorrow.",
        html="<h1>Order confirmed</h1><p>Your order ships tomorrow.</p>",
    )

    return {"id": order.id}
Tasks live in the process. A restart, a crash or a rolling deploy drops whatever has not run yet, and there are no retries. For password resets and receipts, use a real queue — Celery, ARQ or Dramatiq.

Render HTML templates

templates.py
# templates.py
from jinja2 import Environment, FileSystemLoader, select_autoescape

env = Environment(
    loader=FileSystemLoader("templates/email"),
    autoescape=select_autoescape(["html"]),   # never render user data unescaped
    enable_async=True,
)

async def render(name: str, **context) -> str:
    return await env.get_template(name).render_async(**context)
python
html = await render("order_confirmed.html", order=order)
text = await render("order_confirmed.txt", order=order)

await send_email(order.customer_email, f"Order #{order.id}", text, html)

Add an attachment

python
from pathlib import Path

path = Path("/srv/invoices/2026-03.pdf")
msg.add_attachment(
    path.read_bytes(),
    maintype="application",
    subtype="pdf",
    filename="invoice.pdf",
)

Handle errors

A background task's exception has nowhere to go — the response is already on the wire. Log it explicitly, or failures are invisible:

python
import logging
import aiosmtplib

log = logging.getLogger(__name__)

async def send_email_safe(**kwargs) -> bool:
    """Background tasks swallow exceptions — log them or they vanish."""
    try:
        await send_email(**kwargs)
        return True
    except aiosmtplib.SMTPAuthenticationError:
        log.exception("SMTP credentials rejected")
    except aiosmtplib.SMTPRecipientsRefused:
        log.warning("Recipient refused: %s", kwargs.get("to"))
    except (aiosmtplib.SMTPException, OSError):
        log.exception("Transient SMTP failure")
    return False

Troubleshooting

SymptomCause and fix
Requests slow down when email is sent Synchronous smtplib in an async route. Switch to aiosmtplib.
No email, no error in the logs A BackgroundTasks exception was swallowed. Wrap the send in try/except.
SMTPAuthenticationErrorWrong token login or password.
SMTPConnectError / timeout Outbound port blocked. Use 8587 or 8465.
TLS handshake failsuse_tls on 587 or start_tls on 465. Match the flag to the port.
Mail lost after a deploy Background tasks are in-process. Move to a durable queue.

Frequently asked questions

Why should I not use smtplib in FastAPI?

smtplib is synchronous, so every send blocks the event loop for the whole SMTP round trip — connection, TLS handshake, authentication, delivery. Under concurrency that stalls every other request in the same worker. Use aiosmtplib, which is the same message API with awaitable I/O, or push the send to a thread pool.

Is BackgroundTasks enough for sending email in FastAPI?

For non-critical mail, yes — it runs after the response is returned, in the same process. But it is not durable: a restart, a crash or a deploy loses anything still queued, and there are no retries. For password resets, receipts and anything a user is waiting on, use Celery, ARQ or Dramatiq.

What is the difference between start_tls and use_tls in aiosmtplib?

start_tls=True opens a plain connection on port 587 and upgrades it with STARTTLS before authenticating. use_tls=True opens an already-encrypted connection, which is port 465. Setting both, or setting the wrong one for the port, causes a handshake error.

Why does my FastAPI background email fail silently?

BackgroundTasks does not propagate exceptions — the response has already been sent, so a failure has nowhere to surface. Wrap the send in try/except and log it, otherwise a broken SMTP configuration looks identical to a working one.

How do I render HTML email templates in FastAPI?

FastAPI has no template layer of its own for email, so use Jinja2 directly. Build an Environment with enable_async=True and render_async so template rendering does not block, and always pass select_autoescape so user-supplied values cannot inject markup.

Should I open a new SMTP connection for each email?

For occasional sends, yes — aiosmtplib.send() handles connect, authenticate and disconnect. For batches, create an aiosmtplib.SMTP client, connect once and send in a loop, which avoids repeating the TLS handshake for every message.

Next steps