Prompt
How do I architect email delivery for a web app?
Latest observation
A good email architecture for a web app usually separates your app logic from email sending, and makes sending asynchronous, reliable, observable, and provider-agnostic.
1) Core principle: don’t send emails inline
When a user signs up or triggers an event, your web request should:
- validate the action
- write the business data to your database
- enqueue an email job/event
- return a response immediately
A background worker then handles the actual send.
Why:
- avoids slowing down user requests
- prevents timeouts
- lets you retry failures
- makes spikes manageable
- keeps email logic out of request handlers
2) Typical architecture
In your web app
- Event producer: creates “send email” jobs when something happens
- DB: stores app state and optionally an outbox/email log
- Queue: SQS, RabbitMQ, Redis queue, Sidekiq, Celery, BullMQ, etc.
In your worker/service
- Email renderer: fills templates with data
- Provider client: sends via SES, SendGrid, Postmark, Mailgun, etc.
- Retry logic: handles transient failures
- Metrics/logging: records outcomes
Optional but recommended
- Outbox pattern: store email events in the same DB transaction as the business change, then a worker reads and dispatches them.
- Webhook receiver: handles bounces, complaints, unsubscribes, and delivery events from your email provider.
- Template service/repository: centralize templates and localization.
3) Use the outbox pattern for reliability
If email is important, don’t just “enqueue after commit” and hope it worked.
Instead:
- within the same DB transaction as your app change, insert:
- the domain record
- an
email_outboxrecord
Then a worker:
- polls or subscribes to outbox entries
- sends the email
- marks the outbox record as sent/failed
This avoids the classic failure mode:
- app record saved
- queue enqueue fails
- email never gets sent
4) Model emails as events, not ad hoc function calls
Define email types like:
user_welcomepassword_resetemail_verificationinvoice_paidweekly_digest
Each event should include:
- recipient
- template key
- variables/data
- locale
- idempotency key / dedupe key
- metadata for tracing
This makes email behavior predictable and easier to test.
5) Make sending idempotent
Retries are essential, but retries can accidentally send duplicates.
Use:
- a unique job ID or idempotency key
- a sent-status table or outbox status
- provider idempotency if supported
Example:
password_reset: user_123: token_abc- if the worker retries, it checks whether that exact message was already sent
6) Separate transactional vs marketing email
These should not share the same pipeline.
Transactional email
Examples:
- password resets
- receipts
- verification
- account alerts
Requirements:
- high deliverability
- fast
- minimal compliance complexity
- sent immediately
Marketing email
Examples:
- newsletters
- promotions
- lifecycle campaigns
Requirements:
- unsubscribe management
- segmentation
- throttling
- suppression lists
- compliance tracking
Often best to use:
- one provider or subdomain for transactional
- another for marketing
7) Choose a provider and design for replaceability
Common options:
- AWS SES: cheap, scalable, infrastructure-friendly
- SendGrid: convenient, feature-rich
- Postmark: excellent for transactional email
- Mailgun: flexible APIs and routing
Design your app with:
- a thin provider adapter interface
- template rendering independent from provider
- no provider-specific logic scattered across the app
This makes switching providers much easier.
8) Deliverability matters as much as code
Set up:
- SPF
- DKIM
- DMARC
- correct From domains/subdomains
Best practices:
- use a dedicated sending subdomain like
mail.example.com - keep transactional and marketing mail on separate streams/subdomains
- warm up new domains/IPs gradually
- monitor bounce and complaint rates
- keep lists clean
9) Handle bounces, complaints, and unsubscribes
Your provider will send webhooks for:
- hard bounce
- soft bounce
- spam complaint
- unsubscribe
- delivery/open/click events, if you track them
You should:
- suppress bad addresses
- stop sending to complainers
- honor unsubscribes immediately
- record events for audits/debugging
10) Log and trace every email
For each send, store:
- internal message ID
- recipient
- template
- timestamp
- status
- provider message ID
- error details
- retry count
This helps answer:
- Did we try to send it?
- Was it accepted by the provider?
- Was it delivered?
- Why did it fail?
11) Rate limiting and batching
If you may send many emails at once:
- throttle per user to avoid duplicate spam
- batch similar notifications if appropriate
- respect provider limits
- use backpressure in the queue
For digests or bulk notifications:
- generate in batches
- use pagination
- consider precomputing content
12) Security considerations
- Never trust user-supplied HTML in email content without sanitization
- Use signed, expiring links for password reset / verification
- Don’t leak sensitive info in email subject lines
- Protect webhook endpoints with signatures
- Avoid including secrets in email content
13) A practical minimal architecture
If you want something simple and solid:
- Web app writes an
email_jobsrow in DB - Background worker polls
email_jobs - Worker renders template and sends through SES/Postmark
- Worker updates status
- Provider webhooks update bounce/complaint state
- Separate suppression table prevents sending to bad addresses
This is easy to understand and scales reasonably well.
14) Example flow: password reset
- User requests reset
- App creates reset token and stores it
- App inserts email outbox record
- Worker picks it up
- Worker renders template with reset link
- Worker sends email
- Worker marks job sent
- If provider returns bounce/complaint later, suppress address if needed
15) If you’re starting from scratch, recommended default
For most web apps:
- use a queue + worker
- use an outbox table if email correctness matters
- use a transactional provider like Postmark or SES
- implement provider webhooks
- store email send logs
- keep transactional and marketing flows separate
If you want, I can also give you:
- a reference architecture diagram
- a database schema for email jobs/outbox
- an example in Node.js, Python, Ruby, or Go