Design a bulk email campaign sender
The interviewer asks
“Design a bulk email campaign platform like Mailchimp or Zoho Campaigns. Thousands of business customers upload contact lists, design emails with personalisation, and schedule campaigns to up to 50 million recipients. Emails must actually reach the inbox, every recipient gets exactly one copy, and unsubscribes must be honoured immediately.”
What a strong answer looks like
A strong answer focuses less on 'sending fast' and more on deliverability and correctness. A campaign is launched by freezing its audience into a snapshot, filtering it against suppression lists (unsubscribed, bounced, complained), and splitting it into batches of ~1,000 recipients on a queue. Render workers personalise templates; sender workers hand messages to an MTA fleet (own KumoMTA/Postfix/PowerMTA or a provider such as SES) through per-receiving-domain and per-IP throttles, because Gmail, Yahoo and Outlook rate-limit and judge each sending IP and domain. Each (campaign, recipient) send is recorded idempotently so crashes do not cause duplicates. Bounces, complaints, opens and clicks flow back as events that update suppression lists and analytics. A senior answer covers SPF/DKIM/DMARC alignment, one-click unsubscribe and the 2024 Gmail/Yahoo bulk-sender rules, IP warm-up and pools, isolating bad tenants so one spammy customer does not ruin everyone's reputation, and a kill switch.
Concepts to mention
1 Requirements
Functional
- Tenants import contacts (CSV, API), define segments (city = Pune AND last_order < 30 days) and design templates with merge fields
- Schedule a campaign for a time, optionally per recipient timezone; A/B test subject lines on a sample, then send the winner
- Personalised rendering per recipient (name, recommendations, language)
- One-click unsubscribe and preference centre; suppression of unsubscribed, bounced and complained addresses across all campaigns
- Tracking: delivered, bounced, opened, clicked, unsubscribed, complained, per campaign and per link
- Pause, resume and cancel a running campaign within seconds
Non-functional
- Scale: 20k tenants, 300M emails/day, single campaigns up to 50M recipients
- Send a 10M campaign within 1 hour when reputation and receiver limits allow (~3k/s for one campaign; platform peak ~20k/s)
- No recipient gets the same campaign twice; no email to a suppressed address, ever
- Unsubscribe effective within seconds for future sends (legal requirement: within days, we aim much faster)
- Inbox placement: keep spam complaint rate under 0.1% (Gmail's hard limit is 0.3%) and hard bounces under 2%
- Transactional email (password resets) isolated from campaign traffic
Out of scope (say it out loud)
- Drag-and-drop template editor UI
- Receiving email (inbound)
- SMS and push campaigns
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
POST/v1/campaigns | {segment_id, template_id, from, subject, schedule, ab_test?}; creates a draft |
POST/v1/campaigns/{id}/launch | Freeze audience and start sending at the scheduled time; idempotent |
POST/v1/campaigns/{id}/pause | Stop dispatching new batches within seconds; resume or cancel later |
GET/v1/campaigns/{id}/stats | Live counts: queued, sent, delivered, bounced, opened, clicked, complaints |
POST/u/{token} (List-Unsubscribe-Post) | One-click unsubscribe per RFC 8058; token signed with recipient + tenant + list |
GET/t/c/{token} | Click tracking redirect to the original URL |
POST/v1/webhooks/feedback/{source} | Bounces and complaints from MTAs, ESPs and mailbox provider feedback loops |
4 Data model
contactsPostgres (sharded by tenant) or Cassandratenant_idcontact_idemailemail_hashattributes jsonbstatuscreated_atPartitioned by tenant_id. Segment queries run on a columnar copy (ClickHouse) for large tenants.
audience snapshotS3 (Parquet) + batch manifest in Postgrescampaign_idbatch_nocontact_ids[]status (pending|sending|done)Frozen at launch so contacts added mid-send do not change the audience; batches of ~1,000 are the unit of work and checkpointing.
sendsCassandra / ScyllaDBPK (campaign_id, contact_id)message_idip_poolsent_atstatusInsert-if-not-exists before handing to the MTA gives per-recipient idempotency. Message-ID links feedback events back.
suppressionsRedis / DynamoDB + Bloom filterscope (global | tenant)email_hashreason (unsub, hard_bounce, complaint)created_atChecked for every recipient at send time, not only at launch, so an unsubscribe during a long campaign is honoured.
eventsKafka → ClickHousetenant_idcampaign_idcontact_idtypelink_idtsuser_agentip_countryAnalytics and reputation dashboards; also feeds per-tenant complaint and bounce rates.
reputationPostgres + Redistenant_idipdomain → complaint ratebounce ratewarm-up stagedaily caps per receiving domainDrives throttles and pool assignment.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
Launch a 10M-recipient Diwali campaign
- 1Tenant clicks Launch; the API marks the campaign launching (idempotent) and the launcher runs the segment query on the analytics copy
- 2Launcher writes the audience snapshot (10M contact ids) to S3 and 10,000 batch records to the manifest, then enqueues batches in a rate set by the tenant's plan and reputation
- 3A render worker takes a batch, loads contacts, drops suppressed addresses (global bounces/complaints and the tenant's unsubscribes)
- 4For each remaining recipient it inserts (campaign_id, contact_id) into the sends ledger if absent; a conflict means already sent, so skip
- 5It renders the personalised HTML, adds List-Unsubscribe and List-Unsubscribe-Post headers, tracking links and a DKIM signature, and passes it to the shaper
- 6The shaper groups messages by receiving domain (gmail.com, yahoo.com, outlook.com, corporate domains) and releases them under per-domain, per-IP rate limits
- 7MTAs deliver over SMTP; 4xx deferrals are retried with backoff for hours, 5xx hard bounces are final
- 8The batch is marked done; campaign progress updates in the dashboard
Recipient unsubscribes during the send
- 1Gmail shows an 'Unsubscribe' button; the user clicks it and Gmail POSTs to our List-Unsubscribe-Post URL
- 2The tracking edge verifies the signed token and writes the suppression for that tenant and list within milliseconds
- 3Render workers check suppression for every recipient right before sending, so later campaigns and remaining batches skip this address
Complaint spike triggers auto-pause
- 1Feedback loops (Yahoo, Microsoft) and bounce events stream into the reputation service
- 2A tenant's complaint rate on this campaign reaches 0.3% after the first 200k sends (normal is under 0.05%)
- 3Reputation service pauses the campaign automatically, moves the tenant to a quarantine IP pool and alerts the tenant and our compliance team
7 Deep dives
Deliverability: authentication and bulk-sender rules
Sending is easy; reaching the inbox is the product. Mailbox providers decide based on authentication and reputation. Every message must pass SPF (the sending IP is authorised for the envelope domain) and DKIM (a signature with a key published in DNS), and the visible From domain must align with them so DMARC passes. In a multi-tenant platform, tenants should authenticate their own domain (CNAME records pointing to our DKIM keys and a custom return-path), otherwise all tenants share our domain's reputation. Since February 2024, Gmail and Yahoo require bulk senders (over ~5,000 messages a day to Gmail) to have SPF, DKIM and a DMARC policy, support one-click unsubscribe via the List-Unsubscribe-Post header (RFC 8058) and honour it within two days, and keep spam complaint rates below 0.3%. Practical targets are tighter: complaints under 0.1%, hard bounces under 2%. Monitor Google Postmaster Tools and Microsoft SNDS for domain and IP reputation, and process feedback-loop complaints so complainers are never mailed again. India adds no extra law here beyond DPDP consent rules, but good list hygiene matters everywhere.
Throttling by receiving domain and IP warm-up
Receivers rate-limit each sending IP. Push 500k messages to Gmail from a new IP in an hour and you get 421 deferrals, then spam-foldering. So the bottleneck is not our workers but per-destination limits. The shaper keeps queues keyed by (ip_pool, receiving domain) with token buckets per IP and domain, plus a cap on concurrent SMTP connections per destination. When a receiver returns 4xx deferrals, the bucket rate drops automatically (AIMD: back off fast, increase slowly). New IPs and domains need warm-up: start with a few thousand messages a day to the most engaged recipients, then roughly double every day or two over several weeks, watching bounces and complaints. Pools separate traffic types: transactional mail on its own IPs and domains so a campaign problem never delays password resets; dedicated IPs for large senders with steady volume; shared pools for small tenants, where volume is too low to build reputation alone. A campaign's speed is the minimum of the tenant's plan limit, its reputation-based limit and receiver capacity, and the dashboard should show why a campaign is slow.
Exactly one email per recipient
With 10M recipients, workers will crash, batches will be retried and queues will redeliver. Duplicates annoy users and raise complaints, so per-recipient idempotency is a must. The audience is frozen at launch, so a retry processes the same recipients. Before handing a message to the MTA, the worker does an insert-if-not-exists of (campaign_id, contact_id) in the sends ledger (a lightweight transaction in Cassandra/Scylla or a conditional put in DynamoDB); if the row exists, the recipient was already handled and is skipped. There is still a small window: the row is written, then the worker dies before the MTA accepted the message. Record status pending first and sent after the MTA's 250 OK; a sweeper re-sends only rows stuck in pending beyond a timeout, accepting a tiny duplicate risk rather than silently dropping someone. Once the MTA has accepted a message, it owns retries for deferrals. Pause works naturally: the launcher stops releasing batches and workers check the campaign's state before each batch, so a pause takes effect within seconds.
Multi-tenant reputation isolation
On a shared platform, one tenant with a purchased list can generate thousands of complaints and hard bounces, and receivers will penalise the shared IPs that every other tenant uses. Defend at several layers. At import, validate lists: syntax and MX checks, catch role addresses and known spam traps, and flag lists with high unknown-user rates. Score each tenant continuously on bounce rate, complaint rate and engagement; new tenants start in a probation pool with low limits and graduate as their numbers stay clean. Run large campaigns as a canary: send the first 1-2% to engaged recipients, and if bounce or complaint rates cross thresholds, pause automatically before the remaining 98% go out. Move tenants with poor scores to a quarantine pool so the damage stays there, and suspend repeat offenders. Global suppression of hard bounces and complaints protects everyone: an address that bounced for tenant A is not mailed for tenant B. Expose these metrics to tenants so they understand and fix their own list quality.
8 Trade-offs
Own MTA fleet vs ESP like SES or SendGrid
SES is cheap per message and removes MTA operations, but gives less control over IP pools, per-domain shaping and warm-up. Large platforms run their own MTAs (KumoMTA, PowerMTA, Postfix) for control and cost at billions of emails. A hybrid is common: own MTAs for campaigns, a provider as failover and for transactional mail.
Snapshot audience at launch vs live segment
A snapshot makes retries deterministic and progress measurable; contacts added after launch miss the campaign. A live segment is fresher but makes idempotency and counts harder. Snapshot, with suppression checked live at send time.
Speed vs reputation
Sending 10M emails in 10 minutes looks great but triggers receiver throttling. Spreading over an hour or more, or per timezone, improves inbox placement and engagement. Let tenants choose within limits set by reputation.
Open tracking accuracy
Tracking pixels are blocked or pre-fetched (Apple Mail Privacy Protection loads all images), inflating opens. Report opens as approximate, and prefer clicks and conversions for A/B winners and engagement scoring.
9 Bottlenecks
Receiver throttling at peak hours
Everyone sends at 10 am. Spread campaigns with send-time optimisation per recipient, and keep enough warm IPs per major receiver.
Segment queries for huge tenants
Computing a 50M-row segment on the OLTP store is slow. Run segments on a columnar copy (ClickHouse) and stream results to the snapshot.
Tracking traffic bursts
Millions of opens and clicks within minutes of a send. The tracking edge is stateless, returns the pixel or redirect immediately and writes events to Kafka asynchronously.
10 Failure handling
Render worker crash mid-batch
The batch lease expires and another worker retries it; the sends ledger skips recipients already sent, so no duplicates.
IP or domain gets blocklisted
Bounce messages and deferral patterns show it; the shaper drains that IP and moves traffic to other IPs in the pool while the cause (usually one tenant) is found and delisting requested.
Wrong campaign launched
Kill switch: pause stops new batches within seconds. Messages already accepted by MTAs may still go out; the canary phase limits how many that can be.
Tracking edge down
Links must still work: click redirects run on a highly available edge (CDN workers) with the destination encoded in the signed token, so the redirect works even if the backend is down; only the event is lost.
11 What impresses
- Saying deliverability, not throughput, is the real constraint
- Knowing the Gmail/Yahoo 2024 requirements and the 0.3% complaint threshold
- Per-domain shaping with automatic back-off on deferrals
- Isolating bad tenants with probation and quarantine pools
- Canary the first 1-2% and auto-pause on complaint spikes
- Treating open rates as unreliable because of Apple Mail Privacy Protection
12 Mistakes
- Blasting all messages as fast as possible from a few IPs
- Checking suppression only at launch, so mid-campaign unsubscribes still get mail
- No per-recipient dedup, causing duplicate sends after retries
- Sharing IPs and domains between transactional and marketing mail
- Ignoring bounces and complaints, letting reputation collapse
- Sending from the platform's domain for all tenants without DKIM alignment to theirs
13 Follow-ups
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.