17.9. Email Jobs
Table of Contents
Why Email Belongs in Background Jobs
Backend applications send many types of emails: verifications, password resets, receipts, notifications, and more. If you send emails directly inside a request handler, you create several problems:
- The user has to wait while the email is sent.
- If the email provider is slow or fails, the whole request can fail.
- Spikes in email volume can overload your app.
This is why email is a perfect example of a background job. The API accepts the request quickly, schedules an email job, and returns a response. A separate worker sends the email in the background.
Rule: Never block a user-facing request on a slow external service like an email provider. Offload it to a background job whenever possible.
In this chapter, you will see practical patterns for implementing email jobs using queues and workers, and how to make them reliable and safe.
Synchronous vs Background Email Sending
Synchronous Email Example
Imagine a typical registration handler written in a synchronous style:
def register_user(form_data):
user = create_user_in_db(form_data)
# Synchronous email sending
send_verification_email(user.email)
return {"message": "Registration complete, check your email"}
The request is blocked until send_verification_email finishes:
- DNS lookup for SMTP or provider API
- TLS handshake
- Sending the email
- Waiting for response
If this takes 2 seconds, your registration endpoint always takes at least 2 seconds. If the email provider times out, the whole request might fail and the user thinks they did not register at all.
Background Email Example
With a background job, the handler becomes:
def register_user(form_data):
user = create_user_in_db(form_data)
# Schedule email job
enqueue_email_job(
"send_verification_email",
to=user.email,
token=user.verification_token
)
return {"message": "Registration complete, check your email soon"}Then a worker process consumes the job:
def worker_loop():
while True:
job = queue.get()
if job.name == "send_verification_email":
send_verification_email(job.args["to"], job.args["token"])The user response is fast, and email delivery is decoupled from the HTTP request.
Types of Email Jobs
Different emails have different timing and reliability requirements. It helps to think of them in categories.
Transactional Emails
Transactional emails are directly triggered by a user action.
Common examples:
- Registration confirmation
- Email verification
- Password reset
- Order confirmation
- Payment receipt
- Two-factor authentication codes
Characteristics:
- Must be sent quickly.
- Must be reliable.
- Usually one email per user action.
These are often placed on a high-priority queue with retries.
Batch or Digest Emails
Batch or digest emails are periodic summaries or grouped notifications.
Examples:
- Daily activity digest
- Weekly newsletter
- Monthly account summary
Characteristics:
- Not time critical.
- Often sent in large batches.
- May have unsubscribe / preferences.
These are well suited to scheduled jobs that generate a list of recipients and enqueue individual email jobs.
System and Admin Notifications
Admin or system-related emails:
- Error alerts
- Low inventory warnings
- System health checks notifications
Characteristics:
- Could be time sensitive.
- Usually low volume.
- Often go to internal team members.
These might use a separate queue or channel so they are not delayed by user traffic.
Basic Email Job Flow
A common flow for an email job is:
- Trigger
An event occurs, for example, user registers, user requests password reset, order is placed. - Job creation
The backend creates a job description, often a small JSON-like payload:
{
"job_type": "send_verification_email",
"to": "alice@example.com",
"user_id": 123,
"verification_token": "abcd1234",
"created_at": "2026-08-28T12:34:56Z"
}- Queueing
The job is pushed to a message queue (for example Redis list, RabbitMQ, SQS, etc.). - Worker processing
A worker process reads jobs from the queue and: - Builds the email content (subject, body, HTML).
- Sends the email through SMTP or an email provider API.
- Logs success or failure.
- Optionally updates the database (for example, stores "email_sent_at").
- Retries
If sending fails, the worker retries a few times with backoff.
Email Job Payload Design
Your job payload should include enough data to send the email, but not too much.
Two Options
- Store most data in the job
Example payload:
{
"job_type": "send_order_confirmation",
"to": "alice@example.com",
"order_id": 101,
"order_total": 49.90,
"items": [
{"name": "Book A", "qty": 1, "price": 29.90},
{"name": "Book B", "qty": 1, "price": 20.00}
]
}Pros:
- Worker does not need database access.
- Email content is deterministic even if the database later changes.
Cons:
- Larger messages.
- Harder to change email templates if you later want different data.
- Store references, load data in worker
Example payload:
{
"job_type": "send_order_confirmation",
"to": "alice@example.com",
"order_id": 101
}The worker fetches order details from the database.
Pros:
- Smaller jobs.
- Easy to evolve email templates using the same db data.
Cons:
- Worker needs database credentials.
- If data changes or order is deleted, email content may not match what user saw.
Practical Guideline
- For short-lived, critical emails like password reset or verification, store minimal data plus a token or ID:
{
"job_type": "send_password_reset",
"to": "alice@example.com",
"user_id": 123,
"reset_token": "xyz"
}- For order or invoice emails, referencing the order ID and loading from the database is usually fine, as long as you decide how to handle changes (for example, only email after order is finalized).
Using Redis Queues for Email Jobs
Redis is frequently used as a lightweight job queue. Many Python libraries build on Redis to provide higher-level job abstractions.
Although the exact implementation details belong in other chapters, here is a conceptual example using a Redis list as a simple queue.
Enqueuing an Email Job
import json
import redis
from datetime import datetime, timezone
redis_client = redis.Redis(host="localhost", port=6379, db=0)
def enqueue_email_job(job_type, to, payload):
job = {
"job_type": job_type,
"to": to,
"payload": payload,
"created_at": datetime.now(timezone.utc).isoformat()
}
redis_client.rpush("email_jobs", json.dumps(job))
# Example usage
enqueue_email_job(
job_type="send_verification_email",
to="alice@example.com",
payload={"token": "abcd1234"}
)
This code pushes a JSON-encoded job onto the email_jobs list.
Worker Consuming Email Jobs
import json
import time
import redis
redis_client = redis.Redis(host="localhost", port=6379, db=0)
def send_verification_email(to, token):
# Pseudocode: integrate your real email sending here
print(f"Sending verification email to {to} with token {token}")
def process_job(job):
job_type = job["job_type"]
to = job["to"]
payload = job["payload"]
if job_type == "send_verification_email":
send_verification_email(to, payload["token"])
else:
# Unknown job type, log or discard
print(f"Unknown job type: {job_type}")
def worker_loop():
while True:
# BLPOP blocks until there is a job
_, job_data = redis_client.blpop("email_jobs")
job = json.loads(job_data)
try:
process_job(job)
except Exception as exc:
print(f"Failed to process job: {exc}")
# Optionally move to a dead-letter queue or retry
redis_client.rpush("email_jobs_failed", job_data)
time.sleep(1)
if __name__ == "__main__":
worker_loop()
In a real system you would replace the print with SMTP or provider API calls and logging.
Email Templates in Jobs
Building email content inside the worker allows you to reuse templates and keep your HTML out of business logic.
Simple Template Example
from string import Template
VERIFICATION_TEMPLATE = Template("""
Hello,
Please verify your email address by clicking this link:
$verification_link
If you did not create an account, you can ignore this message.
Thanks,
The Example App Team
""".strip())
def build_verification_email(to, token):
verification_link = f"https://example.com/verify?token={token}"
subject = "Verify your email"
body = VERIFICATION_TEMPLATE.substitute(verification_link=verification_link)
return subject, bodyWorker usage:
def send_verification_email(to, token):
subject, body = build_verification_email(to, token)
send_email_smtp(to=to, subject=subject, body=body)You can expand this to handle HTML + text versions, localization, or different brands.
Retrying Failed Email Jobs
External email providers can fail due to network issues or temporary problems. A background job system can retry automatically.
Retry Strategy
You can define a policy like:
- Try up to 5 times.
- Use exponential backoff.
- After final failure, move job to a dead-letter queue and notify an admin.
Exponential backoff example formula:
$$
t_n = t_0 \times 2^{(n-1)}
$$
Where:
- $t_0$ is initial delay (for example 5 seconds).
- $n$ is retry attempt number (1, 2, 3, ...).
Rule: Always limit the maximum number of retries and implement backoff. Infinite rapid retries can overload your system and the email provider.
Simple Retry Example with Metadata
Add retry metadata to the job:
{
"job_type": "send_verification_email",
"to": "alice@example.com",
"payload": {"token": "abcd1234"},
"retries": 0
}Worker logic:
import time
import json
import redis
MAX_RETRIES = 5
BASE_DELAY = 5 # seconds
redis_client = redis.Redis(host="localhost", port=6379, db=0)
def schedule_retry(job):
job["retries"] += 1
delay = BASE_DELAY * (2 ** (job["retries"] - 1))
print(f"Retrying in {delay} seconds")
time.sleep(delay) # Simple approach; many systems use separate delay queues
redis_client.rpush("email_jobs", json.dumps(job))
def worker_loop():
while True:
_, job_data = redis_client.blpop("email_jobs")
job = json.loads(job_data)
try:
process_job(job)
except Exception as exc:
print(f"Error processing job: {exc}")
if job.get("retries", 0) < MAX_RETRIES:
schedule_retry(job)
else:
print("Max retries reached. Moving to failed queue.")
redis_client.rpush("email_jobs_failed", json.dumps(job))
In production you would not use time.sleep inside the worker loop for delays at scale, but the concept is the same: next attempt is scheduled for later.
Idempotency in Email Jobs
Sometimes the same job might get processed twice, for example:
- Worker crashes after sending the email but before acknowledging success.
- Network glitches cause duplicate messages in the queue.
If your job sends an email twice, the user might receive two identical messages.
For some emails this is acceptable, but for others (like password reset codes) it can be confusing.
Idempotency Concept
A job is idempotent if running it multiple times has the same effect as running it once.
For email jobs, full idempotency is hard because sending the same email twice still sends twice. But you can limit duplicates per event.
Practical Patterns
- Store a unique email event ID
- Generate an
email_event_idwhen enqueuing the job. - Before sending, the worker checks a table like
email_eventsto see if that event ID was already processed. - If yes, skip.
- If no, send and record the event ID.
- Store “last sent” timestamps
For frequent notifications:
- Before sending, check if a similar email was sent in the last X minutes.
- If yes, skip.
- Reasonable acceptance of duplicates
For some emails, it is simpler to accept that occasional duplicates are not critical, and rely on clear content like:
If you already used a more recent reset link, you can ignore this email.
Using Background Jobs from an API (Example)
Here is a simple illustration of how an HTTP endpoint might schedule an email job. This is conceptual and focuses on the job aspect, not on full framework details.
Password Reset Request Endpoint
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, EmailStr
app = FastAPI()
class PasswordResetRequest(BaseModel):
email: EmailStr
def create_reset_token_for_email(email: str) -> str:
# Pseudocode: check user exists, create token, save in DB
return "generated-reset-token"
@app.post("/password-reset")
def request_password_reset(data: PasswordResetRequest):
token = create_reset_token_for_email(data.email)
enqueue_email_job(
job_type="send_password_reset",
to=data.email,
payload={"token": token}
)
# Response does not wait for email sending
return {"message": "If an account exists for this email, a reset link has been sent."}Worker:
def send_password_reset_email(to, token):
reset_link = f"https://example.com/reset-password?token={token}"
subject = "Reset your password"
body = f"Click the link to reset your password: {reset_link}"
send_email_smtp(to=to, subject=subject, body=body)
def process_job(job):
job_type = job["job_type"]
to = job["to"]
payload = job["payload"]
if job_type == "send_password_reset":
send_password_reset_email(to, payload["token"])
# handle other job types hereThis separates:
- API responsibility: validate request, generate token, enqueue job.
- Worker responsibility: build email content and send.
Scheduling Email Jobs
Some emails are not triggered by an immediate HTTP request, but by time-based schedules.
Typical scheduled email jobs:
- Daily summary of new notifications.
- Weekly digest of new posts.
- Monthly billing statements.
Simple Conceptual Flow
- A scheduler (for example, cron, Celery beat, a custom scheduler) runs a function every day at 09:00.
- This function:
- Queries the database for users who should receive that email.
- For each user, enqueues an email job.
- Existing workers process the queued email jobs.
Example scheduler function:
def schedule_daily_digests():
users = get_users_to_receive_digest()
for user in users:
enqueue_email_job(
job_type="send_daily_digest",
to=user.email,
payload={"user_id": user.id}
)Worker implementation:
def send_daily_digest_email(to, user_id):
items = get_daily_items_for_user(user_id)
subject = "Your daily summary"
body = build_daily_digest_body(items)
send_email_smtp(to=to, subject=subject, body=body)Security and Privacy Considerations
Emails contain sensitive information and are often used for authentication flows.
Important Rules
Rule: Never include secrets like raw passwords or full credit card numbers in an email.
Rule: For actions like password reset or email verification, use short-lived, single-use tokens instead of embedding user IDs or credentials directly.
Rule: Avoid logging full email contents or tokens in plain text logs.
Common Mistakes to Avoid
- Sending passwords in plain text emails.
- Including permanent tokens or API keys in emails.
- Leaking internal IDs and details in debug logs when sending fails.
- Exposing email addresses in logs or unprotected admin panels.
Observability for Email Jobs
To operate email jobs in production, you need visibility.
Key metrics to track:
| Metric | Description |
|---|---|
| Enqueued jobs count | How many email jobs are created |
| Processed jobs count | How many were processed successfully |
| Failed jobs count | How many ended up in the dead-letter queue |
| Average send time | Time from enqueue to successful send |
| Queue length | How many jobs are waiting |
| Provider error rate | Percentage of email API errors |
Simple logging idea in worker:
def process_job(job):
start = time.time()
try:
# send email
...
duration = time.time() - start
print(f"Email job {job['job_type']} to {job['to']} succeeded in {duration:.2f}s")
except Exception as exc:
print(f"Email job failed: {exc}")
# retry or move to dead-letter queueIn more advanced systems you would send these metrics to dedicated monitoring tools.
Summary
In this chapter you saw how email fits naturally into background processing:
- Synchronous email sending blocks HTTP requests and is fragile.
- Background email jobs use queues and workers to make sending reliable and fast for users.
- Job payloads should be carefully designed, with a balance between storing full data and storing references.
- Templates inside workers generate consistent email content.
- Retries with backoff and basic idempotency patterns help handle failures and duplicates.
- Some emails are event-based, others are time-based and scheduled.
- Security, privacy, and observability are essential for email jobs in production.
You now have a clear picture of how backend applications typically handle email as background work, and how it connects with message queues, workers, and reliability patterns.
Views: 16
KAHIBARO