Introduction
Sending an email seems simple until an application needs to handle slow providers, temporary network failures, duplicate requests, and delivery errors. If email delivery happens directly inside an HTTP request, a temporary problem with the email provider can affect the user’s experience even when the main operation succeeds.
Background processing separates the main application workflow from email delivery. This makes the application more responsive and gives developers better control over retries, failures, and monitoring.
Why Send Emails in the Background?
Imagine a registration endpoint that creates an account and sends a welcome email. If the email provider takes several seconds to respond, the user must wait unnecessarily.
A better workflow is:
- Validate the registration request.
- Save the account.
- Record or enqueue the email task.
- Return a response to the client.
- Let a background worker deliver the email.
This separation prevents email delivery latency from unnecessarily delaying account creation. It also allows delivery attempts to continue when the provider experiences a temporary outage.
However, simply putting a function into a background task does not guarantee that the task will never be lost. Reliability depends on the queue, persistence strategy, and failure-handling design.
Separate Email Composition from Delivery
Email content should be independent of the provider-specific delivery mechanism.
from dataclasses import dataclass@dataclassclass WelcomeEmail: recipient: str display_name: strdef render_welcome_email( message: WelcomeEmail,) -> tuple[str, str]: subject = "Welcome to our application" body = ( f"Hello {message.display_name},\n\n" "Thanks for creating an account." ) return subject, body
The function prepares the subject and body without contacting an external service. This makes message content straightforward to test.
A separate adapter can handle delivery:
class EmailDeliveryError(Exception): passdef send_email( recipient: str, subject: str, body: str,) -> None: # Call the configured email provider here. # Raise EmailDeliveryError for retryable failures. ...
In a real implementation, the adapter should distinguish temporary errors from permanent ones, such as invalid recipient addresses.
Queue the Email Task
A task queue such as Celery allows a worker to deliver emails independently of the web process.
from celery import Celerycelery_app = Celery( "email_tasks", broker="redis://localhost:6379/0",)@celery_app.task( autoretry_for=(EmailDeliveryError,), retry_backoff=True, retry_kwargs={"max_retries": 5},)def deliver_welcome_email( recipient: str, display_name: str,) -> None: message = WelcomeEmail( recipient=recipient, display_name=display_name, ) subject, body = render_welcome_email(message) send_email(recipient, subject, body)
This example illustrates the architecture rather than a complete production configuration. A real system also needs provider timeouts, task serialization settings, failure reporting, and a retry policy that matches the provider’s behavior.
The task’s exception types must correspond to the errors the delivery adapter actually raises.
Handle Retries and Duplicate Delivery
Retries help recover from temporary failures, but they can also result in duplicate emails.
For example, a provider might accept an email and then lose the connection before returning a confirmation. The worker sees a failure and retries, even though the original email may already be on its way.
Reduce this risk by:
- Assigning a stable identifier to each logical email event.
- Recording delivery attempts and outcomes.
- Using provider-supported idempotency features where available.
- Storing provider message IDs when possible.
- Making repeated processing safe wherever practical.
A queue does not automatically provide exactly-once email delivery. Design the system with the possibility of duplicate attempts in mind.
Avoid Losing Work Between Database and Queue
Another problem occurs when the application saves a database record but fails to publish the corresponding email task.
For important workflows, consider the transactional outbox pattern. The application writes the business record and an event describing the email task in the same database transaction. A separate worker reads pending events and processes them.
This reduces the chance that an application change succeeds while the corresponding background work is never recorded.
Monitor Email Delivery
Useful operational metrics include:
- Number of pending email tasks.
- Delivery success and failure rates.
- Retry counts.
- Age of the oldest pending task.
- Permanent failures requiring intervention.
Logs should contain enough context to diagnose failures without exposing email bodies, credentials, or unnecessary personal information.
Conclusion
Reliable email workflows require more than a background function. They need appropriate retry policies, duplicate-delivery awareness, durable job handling, and monitoring.
Start with a simple task queue when requirements are modest. Introduce an outbox and detailed delivery tracking when losing an email event would create a significant business problem.
The goal is to make failures visible, recoverable, and safe to handle.