Introduction to Practical Notification and Email Delivery Infrastructure Design with FastAPI: Real-World Patterns for BackgroundTasks, Job Queues, Templates, Retries, and Auditing
Summary
- Notification and email delivery are important foundations involved in many business workflows, such as user registration, inquiry replies, billing, and SLA alerts. In FastAPI, a stable approach is to use
BackgroundTasksfor lightweight post-processing and job queues such as Celery for processing that requires reliability or retries. FastAPI’s official documentation explains thatBackgroundTaskscan be used for tasks that should run after a response is returned. - Email can be sent directly via SMTP or through an external email delivery API.
aiosmtplib, an asynchronous SMTP client for Python, presents thesend()coroutine as the main entry point for sending email. - In practice, it is not enough to build an email body on the spot and send it immediately. It is important to design the system as a “notification platform,” including templates, delivery history, retries, duplicate prevention, delivery failures, unsubscribe handling, audit logs, and metrics.
- For important notifications, it is safer not to wait for email delivery to complete during the API response. Instead, create a notification job first and send it as a subsequent process. Celery is officially described as a distributed task queue focused on real-time processing and scheduling.
- This article organizes the approach to building notification and email delivery infrastructure with FastAPI, covering use-case classification, template design, synchronous/asynchronous choices, the Outbox pattern, retries, auditing, and testing.
Who Will Benefit from This Article?
Individual Developers and Learners
This is for people who want to send confirmation emails after user registration, inquiry receipt emails, password reset emails, and similar messages with FastAPI. At first, simply calling send_email() directly may work. But once operations begin, questions quickly arise: “What should we do if sending fails?”, “How do we avoid sending the same email twice?”, and “How should templates be managed?”
This article introduces a step-by-step design that starts with BackgroundTasks and moves to a job queue as needed. FastAPI’s dependency system also makes it easy to share email clients and settings.
Backend Engineers in Small Teams
This is useful for teams where inquiry replies, billing notifications, bulk emails from admin screens, and internal notifications are increasing, and email-sending logic has become scattered across routers and service layers.
By centralizing notification infrastructure, templates, delivery history, retries, audit logs, and failure handling can be managed in one place. It also becomes easier for the team to share rules such as “important notifications must always be recorded,” “only lightweight notifications use BackgroundTasks,” and “bulk delivery goes through a job queue.”
SaaS Development Teams and Startups
This is for teams handling multiple types of notifications, such as customer emails, in-product notifications, webhooks, Slack notifications, billing notices, and SLA alerts.
At this stage, email delivery is not a small auxiliary feature; it is central to customer experience and operations. You need to design for notification delays, duplicate delivery, retries after failure, unsubscribe handling, and audit trails. Distributed task queues such as Celery pair well with notification platforms that involve large-volume message processing and scheduling.
Accessibility Evaluation
- A summary is placed at the beginning so readers can first grasp the overall picture of notification infrastructure.
- Terms such as “email,” “notification,” “job,” “template,” and “Outbox” are briefly explained when first introduced.
- Code examples are split into short blocks, with each block showing only one role.
- The article separates adoption stages for individual developers, small teams, and SaaS teams, so readers can start from the section closest to their situation.
- The target level is roughly equivalent to AA.
1. Notification Infrastructure Is Not Just an “Email-Sending Function”
When implementing email sending in FastAPI, it is tempting at first to write a function like this:
def send_email(to: str, subject: str, body: str) -> None:
...
Of course, this is enough as a first step.
However, in real-world operations, the following issues soon appear:
- Where should email bodies be managed?
- How do we avoid sending the same notification twice?
- Should failed deliveries be retried?
- What should happen if the user has unsubscribed?
- How should different levels of importance, such as billing notices and password resets, be handled?
- How should delivery history be stored?
- Can we investigate when a customer says, “I didn’t receive it”?
In other words, email delivery is better designed as infrastructure, not merely as a function.
2. Classify Notification Types First
Before building notification infrastructure, first classify the types of notifications.
If everything is handled the same way, important notifications and lightweight notifications become mixed together.
2.1 Transactional Email
These are emails directly tied to user actions or business events.
Examples:
- Email address verification
- Password reset
- Inquiry receipt
- Customer support reply
- Invoice issuance
- Payment failure notice
- Contract change notice
These tend to be highly important and often require delivery history and retries.
2.2 Marketing Email
These include campaigns, announcements, and newsletters.
Examples:
- New feature announcements
- Campaign information
- Monthly newsletters
Unsubscribe handling, segmentation, and send-rate control are important.
2.3 Internal Notifications
These are notifications for internal members or operations teams.
Examples:
- New inquiry notifications
- Urgent ticket notifications
- Billing failure alerts
- External API outage notifications
- SLA violation notifications
These may be sent not only by email, but also to Slack or webhooks.
3. Basic FastAPI Policy: Do Not Write Delivery Logic Directly in Routers
If routers directly call SMTP or external APIs, the code gradually becomes hard to understand.
This is the kind of structure to avoid:
@router.post("/signup")
def signup(payload: SignupRequest):
# Create user
# Build email body here
# Send via SMTP here
return {"ok": True}
A recommended separation of responsibilities is as follows:
- Router
- Receives requests
- Service layer
- Performs business processing such as user creation or inquiry replies
- Notification service
- Creates notification events
- Email client
- Actually sends via SMTP or external APIs
- Job queue
- Handles heavy delivery and retries
FastAPI’s dependency system is well suited for injecting these components. The official documentation also describes dependencies as a mechanism that makes it easier to integrate external components.
4. Create a Minimal Email Client
First, isolate email delivery inside a dedicated class.
Here is only an interface assuming SMTP delivery.
from dataclasses import dataclass
@dataclass
class EmailMessage:
to: str
subject: str
text_body: str
html_body: str | None = None
class EmailClient:
async def send(self, message: EmailMessage) -> None:
...
By hiding the implementation outside the interface, it becomes easier to switch later from SMTP to an external email delivery API.
5. Example of Asynchronous SMTP Sending with aiosmtplib
When combining with FastAPI asynchronous endpoints, one option is to use an asynchronous SMTP client. The aiosmtplib documentation recommends the send() coroutine as the main entry point for sending email.
from email.message import EmailMessage as PyEmailMessage
import aiosmtplib
class SMTPEmailClient:
def __init__(
self,
hostname: str,
port: int,
username: str | None = None,
password: str | None = None,
use_tls: bool = True,
):
self.hostname = hostname
self.port = port
self.username = username
self.password = password
self.use_tls = use_tls
async def send(self, message: EmailMessage) -> None:
email = PyEmailMessage()
email["From"] = "no-reply@example.com"
email["To"] = message.to
email["Subject"] = message.subject
email.set_content(message.text_body)
if message.html_body:
email.add_alternative(message.html_body, subtype="html")
await aiosmtplib.send(
email,
hostname=self.hostname,
port=self.port,
username=self.username,
password=self.password,
use_tls=self.use_tls,
)
Keeping this implementation out of the router and calling it from the notification service makes the system easier to maintain.
6. Template Design: Do Not Embed Email Bodies in Code
If you write email bodies directly in Python code, every wording change requires touching the code.
As notifications increase, separating templates becomes easier to manage.
6.1 Decide Template Keys
Examples:
user.email_verificationuser.password_resetticket.createdticket.repliedbilling.payment_failedsubscription.canceled
Using the format <domain>.<event> makes organization easier.
6.2 Decide Template Input Models
from pydantic import BaseModel, EmailStr
class TicketReplyEmailContext(BaseModel):
customer_email: EmailStr
ticket_id: int
subject: str
reply_body: str
Modeling the values passed to templates makes required variables clear.
It reduces mistakes such as “the template needs reply_body, but the caller forgot to pass it.”
7. Create a Notification Service
Instead of calling the email client directly, place a notification service in between.
class NotificationService:
def __init__(self, email_client: EmailClient):
self.email_client = email_client
async def send_ticket_reply_email(self, context: TicketReplyEmailContext) -> None:
message = EmailMessage(
to=context.customer_email,
subject=f"Re: {context.subject}",
text_body=context.reply_body,
html_body=f"<p>{context.reply_body}</p>",
)
await self.email_client.send(message)
With this structure, it becomes easier to centralize:
- Template selection
- Unsubscribe checks
- Delivery history persistence
- Metrics recording
- Audit logs
inside the notification service.
8. Inject the Notification Service with FastAPI Dependency Functions
In FastAPI, it is convenient to assemble the notification service using dependency functions.
from fastapi import Depends
def get_email_client() -> EmailClient:
return SMTPEmailClient(
hostname="smtp.example.com",
port=465,
username="smtp-user",
password="smtp-password",
)
def get_notification_service(
email_client: EmailClient = Depends(get_email_client),
) -> NotificationService:
return NotificationService(email_client=email_client)
Routers can use it like this:
from fastapi import APIRouter, Depends
router = APIRouter(prefix="/admin/tickets", tags=["admin-tickets"])
@router.post("/{ticket_id}/reply")
async def reply_ticket(
ticket_id: int,
notification_service: NotificationService = Depends(get_notification_service),
):
context = TicketReplyEmailContext(
customer_email="customer@example.com",
ticket_id=ticket_id,
subject="Regarding your inquiry",
reply_body="Thank you for contacting us.",
)
await notification_service.send_ticket_reply_email(context)
return {"status": "sent"}
However, in this form, the API waits until delivery is complete before returning a response.
Even for lightweight notifications, it is often better for user experience to move delivery into follow-up processing.
9. Use BackgroundTasks to Move Lightweight Notifications After the Response
FastAPI’s official documentation explains that BackgroundTasks lets you define processing to run after a response has been returned. It is suitable for tasks such as email notifications or log sending where the client does not need to wait for completion.
from fastapi import BackgroundTasks
def send_ticket_reply_email_sync(ticket_id: int, email: str) -> None:
# Actual email-sending process
pass
@router.post("/{ticket_id}/reply")
def reply_ticket(
ticket_id: int,
background_tasks: BackgroundTasks,
):
# Save reply message to DB
background_tasks.add_task(
send_ticket_reply_email_sync,
ticket_id,
"customer@example.com",
)
return {"status": "reply_saved"}
BackgroundTasks is very convenient, but caution is needed for notifications that require reliability.
Because it runs inside the same process, it is weak against process shutdowns and bulk delivery. For important notifications or notifications requiring retries, consider moving to a job queue.
10. Handle Important Notifications with the Outbox Pattern
The Outbox pattern is a design where notifications to be sent are first stored in the database, and then a worker sends them later.
This makes it easier to maintain consistency between business processing and email delivery.
10.1 Example Outbox Table Shape
from datetime import datetime
from pydantic import BaseModel
from typing import Literal
NotificationStatus = Literal["pending", "sending", "sent", "failed"]
class NotificationOutboxItem(BaseModel):
id: int
type: str
recipient: str
payload: dict
status: NotificationStatus
retry_count: int = 0
created_at: datetime
sent_at: datetime | None = None
10.2 In Business Logic, “Enqueue” Instead of “Send”
def enqueue_ticket_reply_notification(ticket_id: int, customer_email: str, reply_body: str) -> None:
# In practice, INSERT into DB
outbox_item = {
"type": "ticket.replied",
"recipient": customer_email,
"payload": {
"ticket_id": ticket_id,
"reply_body": reply_body,
},
"status": "pending",
}
With this design, even if saving the reply succeeds but email delivery fails, you can later inspect the Outbox and resend.
11. Send to a Job Queue Such as Celery
If there are many notifications, retries are needed, or failures must be tracked, a job queue is suitable. Celery is officially described as a distributed system for message processing and as a task queue focused on real-time processing and scheduling.
11.1 Good Candidates for Jobs
- Payment failure notifications
- Password reset emails
- CSV generation completion notifications
- Bulk email delivery
- SLA violation notifications
- Customer support reply notifications
- Reminder notifications
11.2 Example Celery Task
from celery import Celery
celery_app = Celery(
"notifications",
broker="redis://redis:6379/0",
backend="redis://redis:6379/1",
)
@celery_app.task(
name="notifications.send_email",
autoretry_for=(Exception,),
retry_kwargs={"max_retries": 3},
)
def send_email_task(outbox_id: int) -> None:
# Fetch DB record by outbox_id and send email
# If successful, status=sent
# If failed, let Celery retry
pass
Combining Outbox with Celery makes both delivery history and retry handling easier.
12. Prevent Duplicate Delivery: Use Idempotency Keys
One of the scariest problems in notifications is sending the same email multiple times.
This is especially harmful for billing, password resets, and contract change notifications because duplicate delivery can make customers anxious.
For this reason, notifications should have idempotency keys.
Examples:
ticket_reply:{ticket_id}:{message_id}
password_reset:{user_id}:{token_id}
payment_failed:{invoice_id}
It is safer to add dedup_key to the Outbox and place a unique constraint on it.
class NotificationOutboxItem(BaseModel):
id: int
dedup_key: str
type: str
recipient: str
payload: dict
status: NotificationStatus
If a notification with the same dedup_key already exists, do not create a new one.
This makes it easier to prevent duplicate registration even when retries or resend processing are involved.
13. Always Consider Unsubscribe and Notification Settings
In notification infrastructure, designing when “not to send” is just as important as sending.
Users or tenants may have settings such as:
- Receive product notifications
- Receive billing notifications
- Receive marketing emails
- Receive SLA alerts
- Receive weekly summaries
However, not every notification can be disabled.
For example, transactional emails required to maintain an account, such as password resets and payment failure notices, need to be handled separately from marketing emails.
13.1 Separate Notification Categories
NotificationCategory = Literal[
"transactional",
"billing",
"security",
"marketing",
"internal",
]
Unsubscribe handling differs by category.
def can_send_notification(category: str, user_settings: dict) -> bool:
if category in {"transactional", "security"}:
return True
if category == "marketing":
return bool(user_settings.get("marketing_opt_in"))
return True
Classifying notifications this way makes it easier to implement operational rules.
14. Separate Delivery History and Audit Logs
In notification infrastructure, it is better not to confuse delivery history with audit logs.
Delivery History
- Which notification was attempted
- Who it was sent to
- Whether it succeeded or failed
- How many retries occurred
- Message ID from the external email service
Audit Logs
- Whose action triggered the notification
- Which business event the notification was based on
- Whether it should be retained as an important operation
For example, for a customer support reply email:
- Delivery history: record of sending the email to the customer
- Audit log: record that the support agent replied to the ticket
Keeping both makes inquiry handling and incident investigation much easier.
15. Do Not Put Too Much Personal or Confidential Information in Email Bodies
Once email is sent, it cannot be taken back.
It can also be easily forwarded or screenshotted.
For this reason, it is safer not to put too much confidential information in the email body.
Avoid:
- Passwords themselves
- Long-lived tokens
- Full detailed billing information
- Excessive personal information
- Internal notes
For password reset emails, it is safer to send a URL valid only for a short period and perform detailed processing after the user opens the logged-in screen or secure page.
16. Version Control for Notification Templates
Templates change over time.
- Subject lines change
- Wording improves
- Footers are added
- Legal wording is inserted
- Multiple languages are supported
For important notifications, it is useful to record which template version was used.
class NotificationOutboxItem(BaseModel):
id: int
type: str
template_key: str
template_version: str
recipient: str
payload: dict
status: NotificationStatus
This makes it easier to trace “what wording was included in emails during this period” later.
17. Template Design with Multilingual Support in Mind
In SaaS products, notifications may eventually be needed in multiple languages such as Japanese and English.
In that case, it is easier to keep template keys and locales separate rather than mixing the language into the template key.
template_key = "ticket.replied"
locale = "ja"
The implementation can resolve the file like this:
def resolve_template(template_key: str, locale: str) -> str:
return f"templates/{locale}/{template_key}.html"
18. Collect Notification Metrics
For notifications, whether they were actually sent is extremely important.
At minimum, it is reassuring to be able to view metrics such as:
- Number of notifications created
- Number of successful deliveries
- Number of failed deliveries
- Number of retries
- Number of pending Outbox items
- Average delivery delay
- Number of deliveries by category
For example, if the Outbox has created_at and sent_at, delivery delay can be calculated.
delay_seconds = (sent_at - created_at).total_seconds()
Notifications directly affect user experience.
Make sure you can detect quickly when they are not being sent.
19. Testing Policy: Verify Without Actually Sending Email
In tests, it is safer not to call a real SMTP server or external email API.
19.1 Use a Fake EmailClient
class FakeEmailClient:
def __init__(self):
self.sent: list[EmailMessage] = []
async def send(self, message: EmailMessage) -> None:
self.sent.append(message)
By replacing the dependency with this fake client, you can confirm “what would have been sent” without actually sending anything.
async def test_send_ticket_reply_email():
fake = FakeEmailClient()
service = NotificationService(email_client=fake)
context = TicketReplyEmailContext(
customer_email="customer@example.com",
ticket_id=1,
subject="Inquiry",
reply_body="Reply body",
)
await service.send_ticket_reply_email(context)
assert len(fake.sent) == 1
assert fake.sent[0].to == "customer@example.com"
19.2 Outbox Tests
When using an Outbox, the following tests are important:
- One Outbox record is created by a notification event
- Duplicate records are not created with the same
dedup_key status=sentafter successful deliveryretry_countincreases after delivery failure- If unsubscribe settings apply, the notification is not created or becomes
skipped
20. Common Failure Patterns
20.1 Sending Email Directly from Routers
This is easy at first, but once templates, unsubscribe handling, history, and retries are introduced, it tends to break down. Moving logic into a notification service is safer.
20.2 Using Only BackgroundTasks for Important Notifications
BackgroundTasks is convenient because it can process after the response, but it is weak against process shutdowns and large-scale processing. For important notifications, consider Outbox or a job queue.
20.3 Embedding Template Text in Code
Every wording change requires a code change. Separating template keys and contexts is recommended.
20.4 Ignoring Unsubscribe Handling
If marketing emails and transactional emails are not separated, legal, operational, and customer experience problems are likely to occur.
20.5 Not Keeping Delivery History
You cannot investigate when someone says, “It didn’t arrive.” Important notifications should have delivery history and status.
21. Roadmap by Reader Type
Individual Developers and Learners
- Create an
EmailClientclass - Create a
NotificationService - Use
BackgroundTasksto move lightweight notifications after the response - Introduce template keys
- Test with a fake client
Engineers in Small Teams
- Inventory notification types
- Separate transactional and marketing emails
- Introduce an Outbox table
- Send important notifications to a job queue
- Design delivery history and audit logs separately
SaaS Development Teams and Startups
- Organize notification infrastructure as a business event platform
- Manage templates, locales, and versions
- Prevent duplicate delivery with idempotency keys
- Monitor Outbox backlog and delivery failure rate
- Integrate with customer support, billing, SLA, and webhook workflows
Reference Links
-
FastAPI
-
aiosmtplib
-
Celery
Conclusion
- Notification and email delivery are often treated lightly inside FastAPI applications, but in practice they are important infrastructure deeply connected to customer experience, billing, customer support, auditing, and security.
- It is fine to start by using
BackgroundTasksfor lightweight notifications after responses. However, for important notifications or notifications that require retries, it is safer to move toward an Outbox pattern or a job queue such as Celery. - Email bodies should be templated, and notification type, category, template version, and locale should be managed separately to make future changes easier.
- Idempotency keys are effective for preventing duplicate delivery. Adding a unique constraint to the Outbox makes the system more resilient to retries and duplicate events.
- By organizing delivery history, audit logs, and metrics, you can build notification infrastructure that can answer: “Was it sent?”, “Why did it fail?”, and “Whose action triggered it?”
Natural follow-up articles would be “Designing SLA and Escalation Management APIs with FastAPI” or “Introduction to Event-Driven Architecture with FastAPI.”

