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.
| Setting | Value |
|---|---|
| SMTP host | smtp.postwing.app |
| Port | 587 |
| Encryption | STARTTLS (the connection is upgraded to TLS before login) |
| Username | The login of an SMTP token for your domain |
| Password | The password of that token — shown once, when the token is created |
pip install aiosmtplib jinja2 pydantic-settings# 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()# 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:
# 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,
)BackgroundTasks runs the coroutine after the response has been returned:
# 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}# 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)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)from pathlib import Path
path = Path("/srv/invoices/2026-03.pdf")
msg.add_attachment(
path.read_bytes(),
maintype="application",
subtype="pdf",
filename="invoice.pdf",
)A background task's exception has nowhere to go — the response is already on the wire. Log it explicitly, or failures are invisible:
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| Symptom | Cause 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. |
SMTPAuthenticationError | Wrong token login or password. |
SMTPConnectError / timeout | Outbound port blocked. Use 8587 or 8465. |
| TLS handshake fails | use_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. |
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.
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.
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.
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.
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.
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.