Send email from FastAPI
FastAPI is async, so a blocking send stalls the event loop and every other request on that worker. This page uses httpx.AsyncClient held on the app lifespan, plus BackgroundTasks so the response returns before the send finishes.
Setup
- 01
Create a key
https://emails.sh/dashboard issues a key starting with esh_.
- 02
Read it with pydantic-settings
A Settings class gives you a startup error naming the missing variable instead of a KeyError on the first send.
- 03
Create the client in lifespan
One httpx.AsyncClient for the process, created on startup and closed on shutdown, so connections are reused rather than opened per send.
- 04
Send with BackgroundTasks
background_tasks.add_task queues the coroutine to run after the response is sent, so the caller does not wait on the API round trip.
pip install emailssh httpxfrom pydantic_settings import BaseSettings
class Settings(BaseSettings):
emailssh_api_key: str
emailssh_from: str = "Acme <onboarding@emails.sh>"
class Config:
env_file = ".env"
settings = Settings()The app
main.py (FastAPI 0.11x, httpx 0.27+)
import logging
from contextlib import asynccontextmanager
import httpx
from fastapi import BackgroundTasks, FastAPI, HTTPException
from pydantic import BaseModel, EmailStr
from settings import settings
log = logging.getLogger("uvicorn.error")
@asynccontextmanager
async def lifespan(app: FastAPI):
# One client for the whole process. Creating an AsyncClient per request
# throws away connection pooling and the TLS handshake every time.
app.state.http = httpx.AsyncClient(
base_url="https://emails.sh/v1",
headers={"Authorization": f"Bearer {settings.emailssh_api_key}"},
timeout=15.0,
)
try:
yield
finally:
await app.state.http.aclose()
app = FastAPI(lifespan=lifespan)
class SignupRequest(BaseModel):
email: EmailStr
name: str = "there"
async def send_welcome(client: httpx.AsyncClient, email: str, name: str) -> None:
response = await client.post(
"/emails",
json={
"from": settings.emailssh_from,
"to": [email],
"subject": "Welcome to Acme",
"html": f"<p>Hi {name}, your Acme account is ready.</p>",
"text": f"Hi {name}, your Acme account is ready.",
"idempotency_key": f"welcome:{email.lower()}",
},
)
if response.is_error:
# The body is prose and says what to do next, so log all of it.
log.error("emails.sh %s: %s", response.status_code, response.text)
return
log.info("emails.sh queued %s", response.json()["id"])
@app.post("/signup")
async def signup(payload: SignupRequest, background_tasks: BackgroundTasks):
if not settings.emailssh_api_key:
raise HTTPException(status_code=500, detail="Email is not configured")
background_tasks.add_task(send_welcome, app.state.http, payload.email, payload.name)
return {"ok": True}The webhook receiver
webhooks.py, included with app.include_router(router)
import hashlib
import hmac
import os
from fastapi import APIRouter, Header, HTTPException, Request
router = APIRouter()
SECRET = os.environ["EMAILSSH_WEBHOOK_SECRET"].encode()
@router.post("/webhooks/emailssh", status_code=204)
async def emailssh_events(request: Request, x_emailssh_signature: str = Header(default="")):
# Read the raw body, not the parsed JSON. Re-serialising changes the bytes
# and the signature will never match.
raw = await request.body()
expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, x_emailssh_signature):
raise HTTPException(status_code=401, detail="bad signature")
event = await request.json()
if event["type"] in ("email.bounced", "email.complained"):
# Mark the address unsendable here.
pass
return NoneWorth knowing
requests inside an async endpoint blocks the event loop
requests is synchronous, so calling it from an async def freezes every concurrent request on that worker for the duration. Use httpx.AsyncClient, or define the endpoint as def and let FastAPI run it in a threadpool.
Do not build a new AsyncClient per request
Each one starts a fresh connection pool and repeats the TLS handshake. Put it on app.state in lifespan and reuse it, as above.
BackgroundTasks are in-process
They run in the same worker after the response, so a crash or a deploy loses them, and they do not retry. Anything that must arrive belongs in Celery, arq, or another queue.
Sign verification needs the raw body
await request.body() before parsing. The HMAC covers the exact bytes sent, and json.dumps of the parsed object almost never reproduces them.
Questions
How do I send an email in FastAPI?
POST to https://emails.sh/v1/emails with httpx.AsyncClient and a Bearer token, from a BackgroundTask so the response is not held up.
Should I use fastapi-mail?
It is an SMTP wrapper. On the HTTP API you do not need a mail library, an async HTTP client is the whole dependency.
How do I know whether the email arrived?
Keep the id from the response and GET https://emails.sh/v1/emails/{id}, or subscribe to email.delivered and email.bounced on the webhook.
The rest of the API
POST /v1/emails sends one and POST /v1/emails/batch sends up to 100 in a call. GET /v1/emails/:id returns the delivery events for a message, which is how you answer "did it arrive" without a support ticket. Webhooks push the same events to you as they happen.