green snake
Photo by Pixabay on Pexels.com
Table of Contents

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 BackgroundTasks for lightweight post-processing and job queues such as Celery for processing that requires reliability or retries. FastAPI’s official documentation explains that BackgroundTasks can 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 the send() 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_verification
  • user.password_reset
  • ticket.created
  • ticket.replied
  • billing.payment_failed
  • subscription.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=sent after successful delivery
  • retry_count increases 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

  1. Create an EmailClient class
  2. Create a NotificationService
  3. Use BackgroundTasks to move lightweight notifications after the response
  4. Introduce template keys
  5. Test with a fake client

Engineers in Small Teams

  1. Inventory notification types
  2. Separate transactional and marketing emails
  3. Introduce an Outbox table
  4. Send important notifications to a job queue
  5. Design delivery history and audit logs separately

SaaS Development Teams and Startups

  1. Organize notification infrastructure as a business event platform
  2. Manage templates, locales, and versions
  3. Prevent duplicate delivery with idempotency keys
  4. Monitor Outbox backlog and delivery failure rate
  5. Integrate with customer support, billing, SLA, and webhook workflows

Reference Links


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 BackgroundTasks for 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.”

By greeden

Leave a Reply

Your email address will not be published. Required fields are marked *

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)