Email Delivery System Design Template
Transactional email platform: message classes, per-destination queues, RFC 5321 retries, SPF/DKIM/DMARC, IP warm-up, bounces, one-click unsubscribe
Cara menggunakan: Use it when designing or reviewing a platform that sends transactional email for many tenants or products. Fill the message classes and the recipient provider mix in section 3 first, because provider limits and warm-up time, not servers, set the ceiling. The main pitfall is one shared queue and IP pool, where one tenant's bad list or one provider's deferrals delay every password reset.
Pratinjau
System Design: [Email delivery platform name]
Reference design for a multi-tenant transactional email platform: an API that accepts messages, per-destination queues and SMTP delivery (RFC 5321), sender authentication with SPF (RFC 7208), DKIM (RFC 6376) and DMARC (RFC 9989, May 2026, which obsoletes RFC 7489), IP and domain reputation, bounce and complaint processing, suppression lists and one-click unsubscribe (RFC 8058). It follows the structure of the System Design Document template, adds a 45-minute interview path, access-pattern and failure-mode tables and a cited "How real systems do it" box, and is pre-filled with a worked design to adapt. Replace every
[bracketed]value and every row marked Example; values marked Example are assumptions, not benchmarks. Section 2.4 names email marketing laws as dated examples; this is a design format, not legal advice.
Document control
| Field | Value |
|---|---|
| Document ID | [SDD-EM-001] |
| System / feature | [Transactional email delivery] |
| Version | [0.1] |
| Status | [Draft / In review / Approved / Implemented / Superseded] |
| Owner | [@owner] |
| Author(s) | [@author] |
| Reviewers | [@reviewer-1], [@reviewer-2] |
| Last updated | YYYY-MM-DD |
| Classification | [Internal] |
| Difficulty | Advanced |
| Standards implemented | [RFC 5321, RFC 5322, RFC 6376, RFC 7208, RFC 9989, RFC 8058, RFC 3463, RFC 3464, RFC 8461] |
| Related | [Notification service design], [Tenant onboarding policy], [ADR: shared versus dedicated IP pools] |
Approvals
| Role | Name | Decision | Date |
|---|---|---|---|
| Tech lead | [@name] | [Approved / Changes requested] | YYYY-MM-DD |
| Staff / principal engineer | [@name] | [Decision] | YYYY-MM-DD |
| Deliverability lead | [@name] | [Decision] | YYYY-MM-DD |
| Security | [@name] | [Decision] | YYYY-MM-DD |
| Privacy / legal reviewer | [@name] | [Decision] | YYYY-MM-DD |
Revision history
| Version | Date | Author | Change summary |
|---|---|---|---|
| 0.1 | YYYY-MM-DD | [@name] | First draft for design review |
| 0.2 | YYYY-MM-DD | [@name] | Example: Split one-time codes into their own queue class and IP pool |
| 0.3 | YYYY-MM-DD | [@name] | Example: Updated DMARC references from RFC 7489 to RFC 9989 |
| 1.0 | YYYY-MM-DD | [@name] | Example: Approved |
Contents
1. Overview
1.1 Problem statement
[Example: Product teams send password resets, receipts and account notices through a shared SMTP relay. Last quarter one tenant imported an old address list, the bounce rate on the shared IPs passed 9% in an hour, and a large mailbox provider deferred all our mail for six hours. Password reset codes arrived after they had expired, support tickets tripled, and nobody could tell which messages had been delivered, bounced or were still queued.]
1.2 Proposed solution
[Example: Tenants send through an HTTPS API that validates, deduplicates and stores each message, then places it on a queue by message class. A scheduler groups messages by destination (mailbox provider and MX host) and releases them at the rate each destination accepts for each sending IP. Delivery agents sign with the tenant's DKIM key and speak SMTP with TLS. Replies are classified into delivered, deferred (retry) and bounced (suppress). Feedback loops and one-click unsubscribe requests update a suppression list that every send checks first. One-time codes use a separate high-priority queue and IP pool with short expiry. Reputation is measured per tenant, per IP and per sending domain, and a tenant that crosses bounce or complaint thresholds is paused automatically before it harms others.]
1.3 Decisions requested from reviewers
| ID | Question | Recommended option | Section |
|---|---|---|---|
| Q-01 | Example: Build our own MTA fleet or relay through a provider? | Own delivery agents on an open-source MTA core, with a provider as overflow | 6, 10 |
| Q-02 | Example: Shared or dedicated IPs? | Shared pools by class and reputation tier; dedicated pool only above a steady volume | 7.5 |
| Q-03 | Example: Retry policy? | Per class: codes expire in 15 min, receipts and notices retry for 5 days, subscribed mail for 2 days | 7.3 |
| Q-04 | Example: Who owns the From domain? | Tenant domain with delegated DKIM selector and aligned return path | 7.4 |
| Q-05 | Example: Automatic tenant pause thresholds? | Bounce 5% or complaints 0.3% over a 24-hour window | 7.5 |
| [Q-nn] | [Question] | [Option] | [Section] |
1.4 45-minute interview path
Use this order when the document serves an interview or a short design review. The written sections below are the long form of each step.
| Minutes | Cover | Output on the whiteboard |
|---|---|---|
| 0 to 5 | Scope: transactional versus bulk, message classes, tenants, below-the-line items (2.3) | Class table with priority and expiry |
| 5 to 10 | Estimates: messages per second at peak, concurrent SMTP sessions, storage, IPs needed per provider (section 3) | Four numbers, "provider limits, not CPU, set the ceiling" consequence |
| 10 to 15 | Interface: POST /v1/messages with idempotency key, delivery events (section 4) | One call, one event stream |
| 15 to 25 | High-level design: API, store, class queues, destination scheduler, delivery agents, feedback processing (section 6) | One diagram |
| 25 to 40 | One deep dive: per-destination scheduling and retries (default, 7.2 and 7.3), or reputation and warm-up (7.5), or authentication and alignment (7.4) | Options, choice, reason |
| 40 to 45 | Failures (provider defers everything, DKIM key leak), the 10x row in section 10 | Two failure rows |
Start the default deep dive with the naive answer, one FIFO queue and a pool of workers that send as fast as they can. Show what happens when one large provider starts deferring: the workers fill with retries for that provider and a password reset for a different provider waits behind them. Then move to per-destination queues with token-bucket release and per-class priority.
2. Requirements
2.1 Core functional requirements
| ID | Requirement | Priority |
|---|---|---|
| FR-01 | Example: Accept a message by API with an idempotency key; return a message ID | Must |
| FR-02 | Example: Render stored templates with tenant data; store the rendered MIME for retries | Must |
| FR-03 | Example: Sign every message with DKIM for the tenant's domain; align SPF return path where possible | Must |
| FR-04 | Example: Deliver over SMTP with opportunistic TLS, and enforce TLS where the destination publishes MTA-STS | Must |
| FR-05 | Example: Retry temporary failures per class policy; stop on permanent failures | Must |
| FR-06 | Example: Suppress addresses after hard bounce, complaint or unsubscribe; check before every send | Must |
| FR-07 | Example: One-click unsubscribe headers on subscribed and promotional classes | Must |
| FR-08 | Example: Emit delivery events (accepted, sent, delivered, deferred, bounced, complained, unsubscribed) by webhook and API | Must |
| FR-09 | Example: Pause a tenant automatically above bounce or complaint thresholds | Must |
| FR-10 | Example: Scheduled send and per-tenant send rate limits | Should |
| [FR-nn] | [Requirement] | [Must / Should / Could] |
2.2 Non-functional requirements
| ID | Quality | Target | Measured as |
|---|---|---|---|
| NFR-01 | Time to first attempt, codes class | Example: p99 at or below 5 s from API accept | Event timestamps |
| NFR-02 | Time to first attempt, other classes | Example: p99 at or below 60 s | Event timestamps |
| NFR-03 | Durability | Example: No accepted message lost; every message ends in delivered, bounced, expired or suppressed | Daily reconciliation of terminal states |
| NFR-04 | API availability | Example: 99.95% monthly | SLO on POST /v1/messages |
| NFR-05 | Duplicate sends | Example: At most 1 in 100 000 messages delivered twice | Message-ID audit on seed accounts |
| NFR-06 | Reputation | Example: Complaint rate per provider below 0.10%, never reaching 0.30% | Provider postmaster dashboards |
| NFR-07 | Isolation | Example: One tenant's bad list cannot raise another tenant's bounce rate | Per-tenant and per-pool metrics |
| [NFR-nn] | [Quality] | [Target] | [Measurement] |
2.3 Below the line (out of scope)
Name these explicitly so reviewers and interviewers do not pull the design toward them.
- [Example: campaign builder, audience segmentation and A/B testing (marketing product)]
- [Example: inbound mail receiving and parsing, except bounce and feedback mailboxes]
- [Example: SMS and push fallback (see Notification Service System Design (
system-design/design-notification-service))] - [Example: open and click tracking pixels (off by default; separate privacy review)]
- [Item deferred, with the release that will take it]
2.4 Legal and mailbox provider context (dated examples)
The platform carries mail for tenants, and tenants carry the legal duty for their content and consent. The platform still needs the mechanics that make compliance possible. The rules below are named as dated examples only; check the current official text and a qualified professional before relying on any of them. This is not legal advice.
| Source | What the platform must make possible | Checked |
|---|---|---|
| United States: CAN-SPAM Act, FTC compliance guide (August 2023, penalty figure updated January 2024) | A working opt-out in commercial mail, honoured within 10 business days and kept working for at least 30 days after sending; transactional or relationship messages are largely exempt but must not carry false routing information | 2026-10-30 |
| United Kingdom: PECR regulation 22, ICO guidance on electronic mail marketing | Consent or soft opt-in for marketing to individuals, a simple opt-out in every message, sender identity not concealed. The ICO says this guidance is under review after the Data (Use and Access) Act 2025 | 2026-10-30 |
| European Union: Directive 2002/58/EC (ePrivacy), Article 13 on unsolicited communications, EUR-Lex (consolidated version of 19 December 2009) | Prior consent for marketing email, with an existing-customer exception for similar products that requires a free, easy opt-out at collection and in each message; national laws implement the detail | 2026-10-30 (Publications Office consolidated text) |
| Google, Email sender guidelines | All senders: SPF or DKIM, valid forward and reverse DNS, TLS, spam rate below 0.3%. Senders of more than 5 000 messages a day to Gmail accounts: SPF and DKIM and DMARC, From domain aligned, one-click unsubscribe for marketing and subscribed mail. Google advises keeping the spam rate below 0.10% | 2026-10-30 |
| [Other mailbox providers your tenants reach] | [Their published sender rules] | [Date] |
The design consequence is that unsubscribe, suppression, DKIM alignment and complaint tracking are platform features, not tenant options.
3. Capacity estimation
3.1 Assumptions
| Input | Symbol | Value | Source |
|---|---|---|---|
| Messages accepted per day | Example: 30 million | API metrics x growth | |
| Peak-hour factor over the daily average | Example: 3 | Hourly histogram | |
| Short burst rate the API must absorb | Example: 2 000 messages/s | Largest tenant's reset storm | |
| Average SMTP transaction time with connection reuse | Example: 0.5 s | Delivery agent timings | |
| Average rendered MIME size | Example: 40 KB | Template sizes | |
| Events per message (accepted, sent, delivered and later events) | Example: 5, at 300 bytes each | Event schema | |
| Share of mail to the largest provider | Example: 40% | Recipient domain mix | |
| Messages per hour one warm IP may send to that provider | Example: 50 000 | Observed deferral point, not a published limit |
3.2 Throughput
The API and queues are sized for the burst messages/s. Delivery
agents have session headroom for the burst (3.3), but destination limits are
sized for the peak hour, so a burst above about 1 042 messages/s queues. The
reset storm in is code class mail, which is released first, so NFR-01
holds; other classes behind a burst that lasts more than about a minute can
miss NFR-02, which is accepted and tracked as queue age (9.3).
3.3 Concurrent SMTP sessions
By Little's law, sessions in flight equal arrival rate times time in the system:
So a small fleet of delivery agents (for example 8 agents at about 125 sessions each in the burst) is enough; the hard limit is how many sessions each destination accepts per IP, which the scheduler enforces (7.2).
3.4 Storage
| Data | Calculation | Result |
|---|---|---|
| Rendered MIME kept for retries and support (3 days) | 3.6 TB | |
| Delivery events kept online (30 days) | 1.35 TB | |
| Event write rate | about 1 736 events/s average | |
| Backlog if the largest provider defers everything for 1 hour at peak | 1.5 million messages, 60 GB |
The MIME store is object storage with a 3-day lifecycle; the queue holds only message IDs and scheduling state, so a 60 GB backlog is a metadata problem of 1.5 million small rows, not a disk problem.
3.5 IPs and warm-up
Peak-hour mail to the largest provider is messages/h. At the observed messages/h per IP:
Daily, that provider receives messages, or 400 000 per IP per day across 30 IPs. A new IP that starts at 1 000 messages a day and grows by half each day reaches that level on day 16 (table in 7.5). Consequence: IPs are provisioned and warmed weeks before the volume arrives, and the warm-up plan is part of capacity planning.
3.6 Summary
| Quantity | Estimate | Design consequence |
|---|---|---|
| Accept rate | 2 000 messages/s burst, 347/s average | Stateless API, durable queue before any SMTP work |
| Delivery rate | about 1 042 messages/s at peak hour | Sized by destination limits, not by CPU |
| SMTP sessions | about 520 at peak, 1 000 in a burst | Few delivery agents; connection reuse per destination |
| Storage | 3.6 TB MIME, 1.35 TB events | Object store with lifecycle; events in a time-partitioned store |
| IPs | 30 warm IPs for the largest provider | Pools per class; warm-up planned 16 or more days ahead |
4. API design
4.1 Endpoints
| Method and path | Purpose | Notes |
|---|---|---|
POST /v1/messages | Accept one message | Requires Idempotency-Key; returns 202 with message ID |
POST /v1/messages/batch | Accept up to 500 messages sharing a template | One idempotency key per item |
GET /v1/messages/{id} | Current state and event history | For support tools |
GET /v1/suppressions?address= | Check suppression status | Tenant scope only |
DELETE /v1/suppressions/{address} | Remove a manual suppression | Never removes complaint or unsubscribe entries without recipient action |
POST /v1/domains | Register a sending domain; returns DNS records to publish | DKIM selector, return-path, DMARC check |
POST /u/{token} | One-click unsubscribe endpoint (RFC 8058) | No cookies, no login, no redirect |
The Idempotency-Key request header follows the pattern of the IETF draft
draft-ietf-httpapi-idempotency-key-header-08
(22 October 2026, an Internet-Draft, not an RFC). A repeated key with the
same body returns the original message ID; with a different body it returns
422 with a problem details body (RFC 9457).
4.2 Request example
{"from": {"address": "receipts@mail.shop.example.com", "name": "Example Shop"}, "to": [{"address": "ana@example.net"}], "class": "receipt", "template_id": "order-receipt-v3", "data": {"order_id": "ORD-104233", "total": "49.90"}, "tags": ["orders"], "send_at": null}
Response:
{"message_id": "msg_01J9ZK7Q4R", "status": "accepted", "suppressed": false}
4.3 Headers the platform adds
The platform adds the list headers for subscribed classes and signs them with
DKIM, because RFC 8058 requires the DKIM signature to cover both
List-Unsubscribe and List-Unsubscribe-Post:
From: Example Shop <news@mail.shop.example.com>
Message-ID: <msg_01J9ZK7Q4R@mail.shop.example.com>
List-Unsubscribe: <https://u.mail.shop.example.com/u/9f2c7d1e>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
DKIM-Signature: v=1; a=rsa-sha256; d=shop.example.com; s=em2026a;
h=from:to:subject:date:message-id:list-unsubscribe:list-unsubscribe-post;
bh=[base64 body hash]; b=[base64 signature]
4.4 Delivery events
Events go to the tenant's webhook (signed, retried) and to the event API. Each event carries the message ID, tenant ID, recipient hash, type, timestamp (RFC 3339), and for failures the SMTP reply code and enhanced status code (RFC 3463).
{"event_id": "evt_8812", "message_id": "msg_01J9ZK7Q4R", "type": "bounced", "bounce_type": "permanent", "smtp_code": 550, "enhanced_code": "5.1.1", "occurred_at": "2026-10-30T09:14:03Z"}
5. Data model
5.1 Entities
A message belongs to one tenant and has many delivery attempts; suppressions are scoped to a tenant, plus one global list for hard bounces.
5.2 Storage choices
| Data | Store | Key | Why |
|---|---|---|---|
| Message metadata and state | Example: Sharded relational database | message_id; unique (tenant_id, idempotency_key) | Transactions for state changes and dedupe |
| Rendered MIME | Object storage, 3-day lifecycle | mime_ref | Large, immutable, cheap |
| Scheduling queues | Example: Partitioned log plus in-memory destination queues in the scheduler | Class and destination | Ordered release, replay after crash |
| Suppression list | Example: Replicated key-value store | (tenant_id, SHA-256 of normalised address) plus a global hard-bounce list | One read on every send, sub-millisecond |
| Delivery events | Time-partitioned columnar store | (tenant_id, day) | Support search and analytics |
| DKIM private keys | Key management service | Selector | Keys never leave the signing boundary in plain form |
5.3 Access patterns
| Access pattern | Frequency | Key or index that serves it | Partition key rationale |
|---|---|---|---|
| Deduplicate an incoming message | Every accept (up to 2 000/s) | Unique index (tenant_id, idempotency_key) | Partition by tenant keeps the unique check on one shard |
| Check suppression before send | Every send | (tenant_id, address_hash) and global hard-bounce list | Hash of address avoids storing raw addresses in the hot path |
| Release next messages for a destination | Continuous, per destination | In-memory queue per (class, destination group, IP pool) rebuilt from the log | Destination, not tenant, is what is rate-limited |
| Record an attempt and its result | Every attempt | message_id + attempt_no | Same shard as the message row |
| Show a message's history to support | Rare | message_id, events by message_id | No scan |
| Compute tenant bounce and complaint rates | Every minute | Streaming counters by tenant and pool | Pre-aggregated, never a query on raw events |
6. High-level design
| Component | Responsibility | Scales by |
|---|---|---|
| Message API | Authenticate tenant, validate, dedupe, suppress, render, store, enqueue | Stateless instances |
| Class queues | Durable ordered log per message class | Partitions |
| Destination scheduler | Group by destination and IP pool, release at allowed rate, hold deferred mail until its retry time | Shards by destination group |
| Delivery agents | SMTP sessions with connection reuse, TLS policy, DKIM signing call | Sessions per agent |
| Signing service | DKIM signing with keys held in KMS | Stateless instances |
| Result classifier | Map replies and async bounces to delivered, deferred, bounced | Stream consumers |
| Reputation monitor | Rates per tenant, IP, domain and provider; automatic pause | Streaming aggregation |
| Webhook dispatcher | Signed delivery of events to tenants with retries | See Webhook Delivery System Design (system-design/design-webhook-delivery) |
7. Detailed design
The deep dives in this section, each with options, choice and reason:
| Deep dive | Options considered | Choice | Reason | Section |
|---|---|---|---|---|
| Scheduling | One FIFO; per-tenant queues; per-destination queues | Per-destination queues within each class, released by token bucket per IP | Provider limits are per destination and per IP | 7.2 |
| Retries | Fixed interval; exponential; per class and per reply | Per class, per reply code, with RFC 5321 minimums | Codes need fast expiry; receipts need patience | 7.3 |
| Authentication | Platform domain only; tenant domain with delegation | Tenant domain, delegated DKIM selector, aligned return path | DMARC alignment and tenant reputation separation | 7.4 |
| Reputation | One shared pool; dedicated per tenant; tiered pools | Pools by class and tenant reputation tier | Isolation without wasting cold IPs | 7.5 |
7.1 Message classes
| Class | Examples | Priority | Expiry | IP pool | One-click unsubscribe |
|---|---|---|---|---|---|
code | Example: login codes, password reset | Highest | 15 min | pool-codes (warm, low volume) | No |
receipt | Example: order confirmation, invoice | High | 5 days | pool-txn | No |
account | Example: security alerts, policy notices | High | 5 days | pool-txn | No |
subscribed | Example: newsletters, product updates the user signed up for | Normal | 2 days | pool-bulk by tier | Yes |
The class is declared by the tenant and checked: a receipt that is mostly
promotion is a known abuse pattern, so content rules and complaint rates per
class are monitored (9.2).
7.2 Destination scheduling
Mail is grouped by destination: the recipient domain is resolved to MX hosts and then mapped to a destination group (one large provider owns many domains and MX hosts). Each (class, destination group, sending IP) has a token bucket for messages per hour and a cap on concurrent sessions. The scheduler releases the oldest message whose bucket has a token, highest class first.
When a destination replies with a temporary failure that signals rate pressure, the scheduler halves the bucket rate for that IP and destination and raises it again by 10% every 15 minutes without deferrals (additive increase after multiplicative decrease, as in TCP congestion control). Codes never wait behind receipts, and a destination that defers everything slows only its own queue.
7.3 Retries and bounce classification
RFC 5321 (section 4.5.4.1) says a sender must wait before retrying a destination, that the retry interval should generally be at least 30 minutes, that the give-up time generally needs to be at least 4 to 5 days, and that experience favours two attempts in the first hour and then one every two or three hours. The design follows that for long-lived classes and expires short-lived ones instead of retrying.
| Class | Attempts | Give-up | Result after give-up |
|---|---|---|---|
receipt, account | First attempt, then at 30 and 60 minutes, then every 2 hours: 62 attempts, the last at hour 119 | 120 hours (5 days) | expired event, tenant may resend through another channel |
subscribed | Same pattern | 48 hours | expired; stale newsletters are worse than none |
code | First attempt, then one retry after 5 minutes on a different IP only when the reply indicates a connection-level fault | 15 minutes | expired event so the application can offer another factor |
Reply classification uses the basic code first and the enhanced status code (RFC 3463) second:
| Signal | Example | Treatment |
|---|---|---|
| 2yz after DATA | 250 | Delivered (accepted by the receiving server, not necessarily the inbox) |
| 4yz | 421 4.7.0, 451 4.3.0 | Deferred; retry per class; feed rate control |
| 5yz with 5.1.x | 550 5.1.1 (bad mailbox) | Hard bounce; suppress the address for all tenants on the global list |
| 5yz with 5.7.x | 550 5.7.1 (policy or reputation) | Block bounce; do not suppress the address; alert deliverability; check authentication and IP listing |
| Asynchronous bounce | Delivery Status Notification (RFC 3464) to the return path | Parse, match by the variable envelope return path token, treat as above |
Some providers return 5yz for reputation blocks that are temporary in practice. Treating those as hard bounces would wipe valid addresses, which is why 5.7.x is kept separate.
7.4 Authentication and alignment
| Mechanism | Specification | What the platform does |
|---|---|---|
| SPF | RFC 7208 | Return-path domain is a tenant subdomain (bounce.mail.shop.example.com) whose SPF record includes the platform's sending ranges |
| DKIM | RFC 6376, crypto updates RFC 8301 and RFC 8463 | Signs with a 2048-bit RSA key per tenant domain and selector; the tenant publishes a CNAME for the selector so the platform can rotate keys; an Ed25519 signature may be added alongside |
| DMARC | RFC 9989 (May 2026), reports in RFC 9990 and RFC 9991 | Platform checks that the tenant domain publishes a DMARC record and that DKIM d= and the From domain align; aggregate reports are parsed for the tenant's dashboard |
| MTA-STS and TLS reporting | RFC 8461, RFC 8460 | Delivery agents fetch and cache destination MTA-STS policies and refuse plaintext delivery where a policy is in enforce mode |
DMARC moved from RFC 7489 to RFC 9989 in May 2026. Among the changes, the
pct tag is removed (RFC 9989 Appendix A.6), a DNS tree walk replaces the
Public Suffix List for finding the organisational domain (section 4.10), and
the np tag for non-existent subdomains, first defined in RFC 9091, is now
part of the core specification. Review your record
parsing and your tenants' DMARC checks against the new text.
Key rotation: each tenant domain has two selectors (for example em2026a
and em2026b). The platform publishes the new key under the idle selector,
waits for DNS TTL plus a margin, switches signing, and retires the old key
after 7 days so mail in retry still verifies.
7.5 Reputation, IP pools and warm-up
Reputation lives at three levels: sending IP, sending domain, and the tenant that causes both. Pools separate classes and tenant tiers:
| Pool | Tenants | Purpose |
|---|---|---|
pool-codes | All tenants, codes class only | Small, steady, never shares IPs with promotions |
pool-txn | Good-standing tenants | Receipts and account notices |
pool-bulk-good | Tenants with low complaint history | Subscribed mail |
pool-probation | New tenants and tenants recovering from a pause | Contains damage while history builds |
pool-dedicated-[tenant] | Tenants above a steady volume | Their reputation, their consequences |
Automatic controls per tenant over a rolling 24-hour window (Example thresholds; set yours from provider guidance and history):
| Metric | Warn | Pause sending | Example for a tenant sending 200 000/day |
|---|---|---|---|
| Hard bounce rate | 2% | 5% | Warn at 4 000 bounces, pause at 10 000 |
| Complaint rate | 0.08% | 0.3% | Warn at 160 complaints, pause at 600 |
Warm-up of a new IP (Example: start at 1 000 messages a day and grow by half each day until the 400 000/day target from 3.5; the excess goes through already warm IPs in the same pool):
| Day | 1 | 3 | 5 | 7 | 9 | 11 | 13 | 15 | 16 |
|---|---|---|---|---|---|---|---|---|---|
| Messages to the largest provider | 1 000 | 2 250 | 5 062 | 11 391 | 25 629 | 57 665 | 129 746 | 291 929 | 400 000 (cap) |
Send the earliest warm-up volume to the most engaged recipients, watch deferrals per provider, and hold the step (do not grow) on any day with rising 4.7.x deferrals. Complaint feedback loops, where providers offer them, arrive in the Abuse Reporting Format (RFC 5965) and are treated as an unsubscribe for that tenant.
7.6 Suppression and one-click unsubscribe
Every send checks the suppression list before rendering. Entries come from hard bounces (global), complaints, unsubscribes and tenant uploads (tenant scope). Addresses are normalised (lower-case domain; local part kept as is, because RFC 5321 says it may be case-sensitive) and stored as hashes.
One-click unsubscribe follows RFC 8058: the message carries a
List-Unsubscribe header with one HTTPS URL and a
List-Unsubscribe-Post: List-Unsubscribe=One-Click header, both covered by
the DKIM signature. The mail client sends a POST with that key-value body,
without cookies or authorisation, and the endpoint must not redirect.
The token in the URL is an opaque, signed reference to (tenant, list, address hash); it never contains the address in clear text, and a replayed token is harmless because the operation is idempotent.
7.7 Duplicates and delivery semantics
SMTP delivery is at-least-once. If the connection drops after the final
. of DATA but before the 250 reply arrives, the sender cannot know
whether the receiver accepted the message (RFC 1047 describes this window),
and a retry may duplicate it. The design reduces duplicates in three places:
the idempotency key at accept time, a stable Message-ID across retries so
receivers that dedupe can do so, and short SMTP timeouts on the DATA reply
balanced against the risk of resending large messages. NFR-05 accepts a
small residual rate.
7.8 How real systems do it
How real systems do it (paraphrased, not copied). The Amazon SES Developer Guide page "Warming up dedicated IP addresses (standard)" (checked 2026-10-30) explains that mailbox providers trust IPs with a history of good mail, that warm-up can take from about two to six weeks depending on the provider, and that early volume should go to the most active recipients while bounces and throttling are watched. SES warms new dedicated IPs automatically by splitting a customer's mail between the new IPs and IPs that are already warm (a shared pool for a first dedicated IP, the customer's existing dedicated IPs otherwise), shifting the share over 45 days on a time basis. After warm-up it suggests keeping roughly 1 000 messages a day per provider per IP to hold the reputation, and avoiding sudden jumps. The overflow-to-warm-IPs approach in 7.5 is the same idea. Read the original in 12.3; this box keeps only the general idea.
8. Scalability and performance
| Concern | Approach | Limit before redesign |
|---|---|---|
| Accept rate | Stateless API, partitioned queue | Example: about 10 000 messages/s with current DB shards |
| Destination limits | More warm IPs per pool, planned weeks ahead | Warm-up time, not hardware |
| Scheduler state | Shard by destination group; one large provider may need its own shard | About 2 million queued messages per shard in memory |
| Suppression reads | Replicated key-value store with local cache of recent hits | [Limit] |
| Event volume | Columnar, time-partitioned, 30-day hot retention | [Limit] |
Latency budget for the code class (target p99 5 s to first attempt):
| Step | Budget |
|---|---|
| API accept, dedupe, suppression check, render, store | 150 ms |
| Queue to scheduler | 200 ms |
| Scheduler release (codes never wait for tokens below their reserved share) | 500 ms |
| SMTP session reuse or connect, TLS, DKIM signing | 1 500 ms |
| Headroom | 2 650 ms |
9. Reliability, security and operations
9.1 Failure modes
| Component | Failure | Detection | Blast radius | Mitigation | RTO |
|---|---|---|---|---|---|
| Large provider | Defers all mail from one pool | 4yz share per destination | Mail to that provider from that pool; 1.5 million messages per peak hour (3.4) | Per-destination queues contain it; rate halves; codes move to pool-codes; deliverability on call | Example: hours, set by the provider |
| Sending IP | Listed on a blocklist | Blocklist monitor, 5.7.x bounces | One IP's share of a pool | Remove IP from rotation, investigate tenant, request delisting | Example: 1 to 3 days |
| Tenant | Imports a stale list | Bounce rate stream | That tenant and its pool | Automatic pause at 5% (7.5), probation pool on resume | Example: 5 min to pause |
| Signing service or KMS | Unavailable | Signing error rate | All outgoing mail | Hold mail in queue (never send unsigned); cached signing keys sealed in memory for 15 min | Example: 15 min |
| DKIM key | Private key leaked | Unexpected DMARC report sources | One tenant domain | Rotate to the idle selector, revoke the old record | Example: 1 h plus DNS TTL |
| Scheduler shard | Crash | Health check | Destinations on that shard | Rebuild state from the log and message DB | Example: 2 min |
| Unsubscribe endpoint | Down | Synthetic POST | Unsubscribes fail; mail clients may fall back to marking as spam | Multi-region, no dependency on the message DB for the write | Example: 5 min |
| [Component] | [Failure] | [Signal] | [Scope] | [Mitigation] | [Time] |
9.2 Security, abuse and privacy
| Threat | Control |
|---|---|
| Account takeover used to send phishing | Strong tenant authentication, scoped API keys, new-tenant probation, content scanning for credential phishing |
| Sending as a domain the tenant does not own | Domain verification by DNS before any mail; DKIM d= only for verified domains |
| Header injection through template data | Encode all header values; reject CR and LF in data used in headers |
Promotional mail sent as receipt to skip unsubscribe | Content classifier and complaint rate per class; reclassification and warnings |
| Recipient data exposure | Address hashes in the hot path; MIME deleted after 3 days; events hold a recipient hash and domain |
| Tracking without consent | Open and click tracking off by default and outside this design (2.3) |
9.3 Observability
| Signal | Metric or log | Alert threshold |
|---|---|---|
| Time to first attempt | p99 by class | Codes above 5 s for 5 min |
| Deferral share | 4yz per destination and pool | Doubling against the 7-day baseline |
| Bounce and complaint rates | Per tenant, pool, provider | Warn thresholds in 7.5 |
| Queue age | Oldest message per class and destination | Receipts older than 1 h |
| Provider dashboards | Postmaster spam rate and domain reputation | Spam rate at or above 0.10% |
| Terminal-state reconciliation | Accepted minus terminal minus in flight | Any non-zero gap older than 6 days |
9.4 Cost estimate
| Item | Unit cost | Quantity | Monthly cost |
|---|---|---|---|
| Delivery agents and scheduler | [$/instance-month] | Example: 12 | [$] |
| Sending IPs | [$/IP-month] | Example: 60 across pools | [$] |
| MIME object storage | [$/GB-month] | Example: 3.6 TB steady | [$] |
| Event store | [$/GB-month] | Example: 1.35 TB hot | [$] |
| Overflow relay provider | [$/1 000 messages] | [Volume] | [$] |
| Total | [$] |
10. Trade-offs and alternatives
| Decision | Options | Chosen | Why | What we give up |
|---|---|---|---|---|
| Delivery | Relay all mail through a commercial provider; own MTA fleet; hybrid | Own fleet with provider overflow | Control of pools, retries and data; overflow covers warm-up gaps | Deliverability operations become our job |
| Queue model | One queue; per tenant; per destination per class | Per destination per class | Matches how providers limit | More scheduler state |
| IP strategy | All shared; all dedicated; tiered pools | Tiered pools | Isolation where it matters, warm IPs used fully | Pool moves need care |
| From domain | Platform domain; tenant domain | Tenant domain with delegation | DMARC alignment, tenant owns its reputation | Onboarding needs DNS work |
| Retry for codes | Standard 5-day retry; short expiry | Short expiry with event | A late code is useless | Some codes never arrive; app must offer another factor |
| What changes at 10x / 0.1x | 10x (300 million/day): about 300 warm IPs for the largest provider by the 3.5 formula, scheduler sharded per large provider, warm-up becomes a continuous programme. 0.1x (3 million/day): about 3 IPs per large provider, a single scheduler, and a commercial relay may cost less than running a fleet | Review at each doubling of volume | IP reputation work grows first, not compute | [Note] |
| [Decision] | [Options] | [Chosen] | [Why] | [Cost] |
11. Delivery plan and risks
11.1 Milestones
| Milestone | Scope | Exit criterion | Target date |
|---|---|---|---|
| M1 | API, store, suppression, relay through provider | All classes flow end to end; events reconciled | YYYY-MM-DD |
| M2 | Own delivery agents for code class, warm-up of pool-codes | Codes p99 at or below 5 s for 14 days | YYYY-MM-DD |
| M3 | Receipts and account notices on own fleet | Bounce and deferral rates at or below provider baseline | YYYY-MM-DD |
| M4 | Subscribed class, one-click unsubscribe, reputation automation | RFC 8058 test with seed accounts passes; automatic pause tested | YYYY-MM-DD |
11.2 Rollout and rollback
[Example: Move traffic per class and per destination group from the relay to the own fleet in steps of 10%, following the warm-up table. Rollback sends the class back to the relay by configuration within 5 minutes; messages already queued finish on whichever path holds them.]
11.3 Risks
| ID | Risk | Likelihood | Impact | Mitigation | Owner |
|---|---|---|---|---|---|
| R-01 | Example: A tenant's bad list damages a shared pool | Medium | High | Probation pool, automatic pause | [@name] |
| R-02 | Example: Provider sender rules tighten again | High | Medium | Quarterly review of provider pages; authentication and unsubscribe already enforced | [@name] |
| R-03 | Example: DMARC RFC 9989 parsing differences break domain checks | Medium | Medium | Test suite with records in old and new syntax | [@name] |
| R-04 | Example: Warm-up slower than the volume plan | Medium | High | Keep relay overflow contract until pools reach target | [@name] |
| [R-nn] | [Risk] | [L / M / H] | [L / M / H] | [Mitigation] | [@name] |
12. Appendices
12.1 Open questions
| ID | Question | Owner | Due |
|---|---|---|---|
| OQ-01 | Example: Do we offer dedicated IPs below 100 000 messages a day? | [@name] | YYYY-MM-DD |
| OQ-02 | Example: Which providers' feedback loops do we join first? | [@name] | YYYY-MM-DD |
| OQ-03 | Example: When RFC 5321bis is published, which changes affect the delivery agents? | [@name] | YYYY-MM-DD |
| [OQ-nn] | [Question] | [@name] | YYYY-MM-DD |
12.2 Glossary
| Term | Meaning |
|---|---|
| MTA | Mail transfer agent: software that relays mail over SMTP |
| MX | DNS record naming the hosts that accept mail for a domain |
| Return path | Envelope sender address where bounces go; the domain SPF checks |
| Alignment | DMARC check that the authenticated domain matches the visible From domain |
| Deferral | Temporary 4yz refusal; retry later |
| Hard bounce | Permanent failure for a bad address; suppress |
| Feedback loop | Provider report sent when a recipient marks mail as spam |
| Warm-up | Gradual growth of volume on a new IP or domain to build reputation |
| [Term] | [Meaning] |
12.3 References
| Reference | Use |
|---|---|
| RFC 5321 Simple Mail Transfer Protocol (October 2008); a successor (rfc5321bis) was in the RFC Editor queue on 2026-10-30 | Transfer, reply codes, retry guidance in 7.3 |
| RFC 5322 Internet Message Format | Headers, Message-ID |
| RFC 6376 DKIM Signatures (September 2011), with RFC 8301 and RFC 8463 | 7.4 |
| RFC 7208 SPF (April 2014) | 7.4 |
| RFC 9989 DMARC (May 2026), obsoletes RFC 7489 and RFC 9091; RFC 9990 aggregate reporting; RFC 9991 failure reporting | 7.4 |
| RFC 8058 One-click unsubscribe (January 2017) and RFC 2369 list header fields | 4.3, 7.6 |
| RFC 3463 Enhanced Mail System Status Codes; RFC 3464 Delivery Status Notifications; RFC 5965 feedback report format | 7.3, 7.5 |
| RFC 8461 MTA-STS; RFC 8460 SMTP TLS Reporting | 7.4 |
| Google, Email sender guidelines, checked 2026-10-30 | 2.4, NFR-06 |
| Amazon SES Developer Guide, Warming up dedicated IP addresses (standard), checked 2026-10-30 | 7.8 |
Notification Service System Design (system-design/design-notification-service) | Channel choice above this platform |
Webhook Delivery System Design (system-design/design-webhook-delivery) | Event delivery to tenants |
Rate Limiter System Design (system-design/design-rate-limiter) | Token buckets in 7.2 |
Distributed Message Queue System Design (system-design/design-message-queue) | Class queues |
Email Campaign Brief (marketing/email-campaign-brief) | Consent and unsubscribe for the tenant's own campaigns |
Back-of-Envelope Estimation and Capacity Model (system-design/back-of-envelope-estimation) | Method for section 3 |
Standard and licence note
Based on the IETF specifications cited in 12.3 (RFC 5321, RFC 6376, RFC 7208, RFC 8058 and the DMARC RFCs 9989 to 9991, published May 2026) and on common industry practice for transactional email. The legal and mailbox provider rules in 2.4 are dated examples, not legal advice; check the current official text and a qualified professional. The Amazon SES page is paraphrased in 7.8 only; no text was copied, and all volumes, thresholds and warm-up figures are illustrative examples. Original text written for this collection. Template licence: MIT.
Templat terkait
Ad Click Aggregation System Design Template (Reference Design)
45-minute path, click dedupe, event-time windows, exactly-once counting, exact and Count-Min top-K, batch reconciliation for billing, failure modes, 10x row
Desain SistemZanzibar-Style Authorization Service System Design Template
Relationship-based authorization service: relation tuples, namespace config, Check/Expand/List/Watch APIs, consistency tokens, caching, sharding, failure modes
Desain SistemCDC Pipeline System Design Template
CDC pipeline: log-based capture, watermark chunked snapshot, per-key order, schema evolution, outbox, search, cache and warehouse sinks, replay