Design an Email Service

Receive, store, search and send mail for 100 million users: the SMTP receive path, mailboxes partitioned by user, attachments, bounces and device sync.

Case study32 minLesson 8 of 10
On this page
  1. Clarify the problem
  2. Estimate the scale
  3. Define the API
  4. Model the data
  5. High-level design
  6. Deep dives
  7. Bottlenecks and failures
  8. What a strong answer sounds like
  9. Follow-up questions

Someone in another company presses Send. Their mail server looks up your domain, connects, and hands over a message. A few seconds later it appears on your phone and your laptop, filed in the inbox, with spam already moved aside, and you can find it next year by typing one word. A web mail service with labels and search is the kind of product this question means.

Interviewers like it because a familiar screen hides several classic problems: a receive path that must never lose an accepted message, storage growing by petabytes, a search index per person, retries against servers you do not control, and devices that must agree. The steps follow A Framework for Any Design. Try each "Your turn" box before you read on.

Clarify the problem

The first five minutes
Interviewer

Design an email service like a big web mail provider.

You

I'll assume the core is receiving and sending mail, labels or folders, search and attachments. Do we also serve desktop clients over IMAP?

Interviewer

Yes to the core. Our own web and mobile apps only; IMAP is a stretch goal.

You

How many users, and how much mail per user?

Interviewer

100 million users. Each receives about 40 emails a day after spam is rejected, and sends about 5.

You

Then my priorities: once we tell a sender's server "accepted", that message is never lost; new mail reaches every device within seconds; a user's own actions show up immediately for them. Search can lag a few seconds.

  • Functional: receive mail over SMTP; filter spam and viruses; list mail by label; open messages and attachments; send, with retries and bounces; label, mark read, archive, delete; search your own mail; sync devices.
  • Non-functional: no accepted message is ever lost (durability above everything); new mail visible within 5 seconds; inbox list in under 200 ms; search in under 500 ms; receiving stays up even when reading is degraded.
  • Out of scope: calendar, contacts, chat, ads, end-to-end encryption, IMAP (discussed as a follow-up).

Estimate the scale

Assume an average email is 30 KB stored: 10 KB of headers and body, and one in 20 emails carries attachments averaging 400 KB, which adds 400 ÷ 20 = 20 KB on average. Peak is twice the average, since mail is spread over the world's time zones.

  • Received: 100 million × 40 = 4 billion emails a day. 4,000,000,000 ÷ 86,400 ≈ 46,000 a second, about 90,000 at peak.
  • Sent: 100 million × 5 = 500 million a day, about 5,800 a second.
  • Reads: if each user opens a list or a message 20 times a day, that is 2 billion reads a day, about 23,000 a second.
  • Inbound bandwidth: 4 billion × 30 KB = 120 TB a day, and 120 TB ÷ 86,400 s ≈ 1.4 GB/s.
  • Per user: 40 × 365 × 30 KB ≈ 440 MB a year.
Estimate: Inbound message rate

How many emails per second does the receive path handle on average?

You know
  • 100 million users
  • 40 emails received per user per day
  • A day is 86,400 seconds

Show the working
  1. 100,000,000 × 40 = 4 billion emails a day
  2. 4 × 109 ÷ 86,400 ≈ 46,000 a second

Estimate 46 thousand/s (anything from 15 thousand/s to 150 thousand/s is a good answer)

Writes outnumber reads (46,000 against 23,000 a second), which is unusual for a consumer product.

Estimate: New storage per day

How much new mail data arrives each day?

You know
  • 4 billion emails a day
  • About 30 KB stored per email, attachments included

Show the working
  1. 4 × 109 × 30 × 103 B = 1.2 × 1014 B = 120 TB a day
  2. × 365 ≈ 44 PB a year, before replication

Estimate 120 TB (anything from 40 TB to 400 TB is a good answer)

Bodies and attachments go to object storage. Only metadata belongs in a database: 4 billion × 500 B = 2 TB a day.

Your turn: Define the API

Write the app-facing endpoints: list a label, open a message, send, change labels, search, and fetch what changed since the device last looked. What does "send" return, given that delivery to another provider can take days?

Compare with a model answer

Reads are GETs by label with a cursor; label changes are small operations. Send returns 202 Accepted: queued, not delivered. Sync is one endpoint, "changes since N".

Define the API

Other mail servers reach you over SMTP on port 25. Your apps use your own API:

App API
GET  /messages?label=INBOX&limit=50&cursor=...
  200 { "messages": [ { "id": "m_9f2", "from": "...", "subject": "...",
        "snippet": "...", "labels": ["INBOX", "UNREAD"] }, ... ], "next_cursor": "..." }
 
GET  /messages/{id}              headers, body, attachment list
GET  /attachments/{id}           302 to a short-lived signed URL in object storage
 
POST /attachments                upload first, get an id back
POST /messages/send    Idempotency-Key: draft_771
  body: { "to": [...], "subject": "...", "body": "...", "attachment_ids": [...] }
  202 Accepted   { "id": "m_a01", "status": "queued" }
  400 Bad Request         an address is malformed
  413 Payload Too Large   over the size limit
  429 Too Many Requests   over the daily sending limit
 
POST /messages/{id}/labels   { "add": ["Receipts"], "remove": ["INBOX"] }
GET  /search?q=from:bob invoice has:attachment&cursor=...
 
GET  /changes?since=81237
  200 { "changes": [ ... ], "latest": 81240 }
  410 Gone        too far behind; do a full resync

The API decision worth arguing is sync. Re-downloading the inbox on every wake fetches 50 rows when maybe one changed, and misses label changes on older mail. Instead, a device asks a per-user change log for "everything after 81,237". It catches every kind of change, and a device offline for a week just gets more.

Label changes are operations ("add Receipts, remove INBOX"), not a new label set, so two devices editing different labels both win.

Your turn: Model the data

Sketch the records. Where do bodies and attachments live? What is the partition key, and how do you list "Inbox, newest first" quickly when a message can have many labels?

Compare with a model answer

Small metadata rows in a database partitioned by user_id; bodies and attachments in object storage. A separate per-label index row (user, label, time, message) makes label listings a single range scan, and a per-user change log feeds sync.

Model the data

Per-user mailbox tables (all keyed by user_id first)
messages
  user_id       BIGINT
  message_id    BIGINT
  thread_id     BIGINT
  dedupe_key    BINARY(32)     -- hash of Message-ID header + content
  from_addr     VARCHAR(320)
  subject       VARCHAR(1000)
  snippet       VARCHAR(200)
  received_at   TIMESTAMP
  labels        BIGINT[]       -- INBOX, UNREAD, SPAM, user labels
  body_key      VARCHAR(64)    -- object storage key
  attachments   JSON           -- [{ name, size, blob_key }]
  PRIMARY KEY (user_id, message_id),  UNIQUE (user_id, dedupe_key)
 
label_index                      -- one row per (message, label)
  user_id, label_id, received_at, message_id
  PRIMARY KEY (user_id, label_id, received_at DESC, message_id)
 
labels    (user_id, label_id, name, total, unread)
changes   (user_id, seq, message_id, kind)   PRIMARY KEY (user_id, seq)

A label is a tag, and a message can carry many. A folder is just a label with the rule "one per message", and Inbox, Sent, Spam and Trash are system labels. Archiving removes INBOX; nothing moves.

Almost every query starts with "for this user", so partition everything by user_id. A sharded relational database fits: on one shard, a delivery writes the message, label rows, counters and change-log entry in one transaction, so counts and the sync log never disagree with the mailbox. Bodies and attachments never change once written, which suits object storage.

Your turn: High-level design

Sketch the boxes for receiving a message from the internet, filing it, and showing it on a phone. When exactly do you tell the sending server "accepted"?

Compare with a model answer

SMTP servers write the raw message to object storage and a reference to a durable queue, and only then reply "accepted". Workers filter and deliver into the user's shard. Apps read through a mail API; a change stream feeds search.

High-level design

A durable queue splits the receive path: the edge only stores the message safely, and everything slow happens after. The send path is in the deep dives.

Mail is stored and queued before the sender is told it was accepted. Workers then filter and file it into the user's shard, which feeds search.Other mail servers to SMTP in (SMTP); SMTP in to Blob store (raw message); SMTP in to Inbound queue (reference); Inbound queue to Filter + deliver, asynchronous; Filter + deliver to Mailbox DB shards (file it); Filter + deliver to Blob store; Mail apps to Mail API (read, sync); Mail API to Mailbox DB shards; Mail API to Blob store (bodies); Mail API to Search index (query); Mailbox DB shards to Search index (changes), asynchronous.Other mailserversSMTP inInbound queueFilter + deliverBlob storeMailbox DBshardsMail appsMail APISearch indexSMTPraw messagereferencefile itread, syncbodiesquerychanges

request and replyasynchronous message

Mail is stored and queued before the sender is told it was accepted. Workers then filter and file it into the user's shard, which feeds search.
Receive, file, sync, and two crashesUse the arrows or ← → keys
  1. Receive, file, sync, and two crashes, step 1Other mail servers to SMTP in; SMTP in to Blob store; SMTP in to Inbound queue; Inbound queue to Filter + deliver, asynchronous; Filter + deliver to Mailbox DB shards; Filter + deliver to Blob store; Mail apps to Mail API; Mail API to Mailbox DB shards; Mail API to Blob store; Mail API to Search index; Mailbox DB shards to Search index, asynchronous.Other mailserversSMTP inInbound queueFilter + deliverBlob storeMailbox DBshardsMail appsMail APISearch index
    recipient
    ana@
    size
    45 KB

    1Another company's server looks up our MX record, connects and names the recipient, ana@. SMTP in checks the address exists and the sender passes reputation and SPF checks.

  2. Receive, file, sync, and two crashes, step 2Other mail servers to SMTP in; SMTP in to Blob store; SMTP in to Inbound queue; Inbound queue to Filter + deliver, asynchronous; Filter + deliver to Mailbox DB shards; Filter + deliver to Blob store; Mail apps to Mail API; Mail API to Mailbox DB shards; Mail API to Blob store; Mail API to Search index; Mailbox DB shards to Search index, asynchronous.Other mailserversSMTP in250 OKInbound queueFilter + deliverBlob storeMailbox DBshardsMail appsMail APISearch index
    recipient
    ana@
    copies
    3

    2The 45 KB message arrives. SMTP in writes it to the blob store (3 copies) and a reference to the replicated queue. Only then does it reply 250 OK: the message is now ours to keep.

  3. Receive, file, sync, and two crashes, step 3Other mail servers to SMTP in; SMTP in to Blob store; SMTP in to Inbound queue; Inbound queue to Filter + deliver, asynchronous; Filter + deliver to Mailbox DB shards; Filter + deliver to Blob store; Mail apps to Mail API; Mail API to Mailbox DB shards; Mail API to Blob store; Mail API to Search index; Mailbox DB shards to Search index, asynchronous.Other mailserversSMTP inInbound queueFilter + deliverinboxBlob storeMailbox DBshardsMail appsMail APISearch index
    spam score
    0.02
    virus
    none

    3A worker takes the reference, reads the message from the blob store, scores it for spam (low) and scans the attachment (clean), so it goes to the inbox.

  4. Receive, file, sync, and two crashes, step 4Other mail servers to SMTP in; SMTP in to Blob store; SMTP in to Inbound queue; Inbound queue to Filter + deliver, asynchronous; Filter + deliver to Mailbox DB shards; Filter + deliver to Blob store; Mail apps to Mail API; Mail API to Mailbox DB shards; Mail API to Blob store; Mail API to Search index; Mailbox DB shards to Search index, asynchronous.Other mailserversSMTP inInbound queueFilter + deliverBlob storeMailbox DBshardsseq 81,240Mail appsMail APISearch index
    inbox unread
    4 → 5
    latest seq
    81,240

    4One transaction on Ana's shard: insert the message, add INBOX and UNREAD label rows, raise the inbox unread count, and append change 81,240 to her log.

  5. Receive, file, sync, and two crashes, step 5Other mail servers to SMTP in; SMTP in to Blob store; SMTP in to Inbound queue; Inbound queue to Filter + deliver, asynchronous; Filter + deliver to Mailbox DB shards; Filter + deliver to Blob store; Mail apps to Mail API; Mail API to Mailbox DB shards; Mail API to Blob store; Mail API to Search index; Mailbox DB shards to Search index, asynchronous.Other mailserversSMTP inInbound queueFilter + deliverBlob storeMailbox DBshardsMail appsMail APISearch index
    phone seq
    81,237 → 81,240

    5The Mail API sends Ana's phone a tiny push, "mailbox changed", and the phone asks for changes since 81,237. Three changes come back: a read flag set on her laptop, an archive, and the new message.

  6. Receive, file, sync, and two crashes, step 6Other mail servers to SMTP in; SMTP in to Blob store; SMTP in to Inbound queue; Inbound queue to Filter + deliver, asynchronous; Filter + deliver to Mailbox DB shards; Filter + deliver to Blob store; Mail apps to Mail API; Mail API to Mailbox DB shards; Mail API to Blob store; Mail API to Search index; Mailbox DB shards to Search index, asynchronous.Other mailserversSMTP inInbound queueFilter + deliverBlob storeMailbox DBshardsMail appsMail APISearch index

    6She opens it. The snippet and headers came from the database; the body comes from the blob store through the API, about 45 KB.

  7. Receive, file, sync, and two crashes, step 7Other mail servers to SMTP in; SMTP in to Blob store; SMTP in to Inbound queue; Inbound queue to Filter + deliver, asynchronous; Filter + deliver to Mailbox DB shards; Filter + deliver to Blob store; Mail apps to Mail API; Mail API to Mailbox DB shards; Mail API to Blob store; Mail API to Search index; Mailbox DB shards to Search index, asynchronous.Other mailserversSMTP inInbound queueFilter + deliverBlob storeMailbox DBshardsMail appsMail APISearch indexlag about 2 s

    7The change also feeds the search index, which indexes the body in Ana's part of the index. About 2 seconds after delivery, search finds it.

  8. Receive, file, sync, and two crashes, step 8Other mail servers to SMTP in; SMTP in to Blob store; SMTP in to Inbound queue; Inbound queue to Filter + deliver, asynchronous; Filter + deliver to Mailbox DB shards; Filter + deliver to Blob store; Mail apps to Mail API; Mail API to Mailbox DB shards; Mail API to Blob store; Mail API to Search index; Mailbox DB shards to Search index, asynchronous.Other mailserversSMTP inInbound queueFilter + delivercrashedBlob storeMailbox DBshardsMail appsMail APISearch index

    8Crash one: a worker commits the delivery, then dies before acknowledging the queue. The queue hands the same reference to another worker.

  9. Receive, file, sync, and two crashes, step 9Other mail servers to SMTP in; SMTP in to Blob store; SMTP in to Inbound queue; Inbound queue to Filter + deliver, asynchronous; Filter + deliver to Mailbox DB shards; Filter + deliver to Blob store; Mail apps to Mail API; Mail API to Mailbox DB shards; Mail API to Blob store; Mail API to Search index; Mailbox DB shards to Search index, asynchronous.Other mailserversSMTP inInbound queueFilter + deliverBlob storeMailbox DBshardsduplicate: no-opMail appsMail APISearch index
    copies in inbox
    1

    9The second worker's insert hits the unique (user_id, dedupe_key), so it changes nothing and acknowledges. Ana sees one copy.

  10. Receive, file, sync, and two crashes, step 10Other mail servers to SMTP in; SMTP in to Blob store; SMTP in to Inbound queue; Inbound queue to Filter + deliver, asynchronous; Filter + deliver to Mailbox DB shards; Filter + deliver to Blob store; Mail apps to Mail API; Mail API to Mailbox DB shards; Mail API to Blob store; Mail API to Search index; Mailbox DB shards to Search index, asynchronous.Other mailserversretry in 15 minSMTP indownInbound queueFilter + deliverBlob storeMailbox DBshardsMail appsMail APISearch index
    copies in inbox
    1

    10Crash two: SMTP in stores and queues a message, then dies before replying. The sender retries later, and the dedupe key makes the second copy a no-op.

1 / 10

Deep dives

The receive path: when to say 250

SMTP has a clear contract. 4xx means "try again later", and the sender will. 5xx means "permanently refused", and the sender's server tells its user. 250 after the body means you now own the message. So store it durably before replying, and decide where each check runs.

Where to stop bad mail
OptionGood atBad atPick it when
Reject during SMTPThe sender learns at once and you store nothing.Runs while the connection is open, so checks must be quick.Cheap, sure checks: unknown recipient, bad sender reputation, a domain policy that says reject.
Accept, then file as spam Usual defaultWorkers have time for content classifiers and virus scans. A false positive sits in Spam, where the user can rescue it.You store the spam, and the sender is never told.Content-based decisions. The usual answer for most filtering.
Accept, then bounceTells the sender, in theory.Spam forges its sender, so your bounce goes to an innocent address. That is backscatter, and it gets your servers blocklisted.Never for spam. Bounces are only for mail you cannot deliver, sent from the send path.

The pipeline is at-least-once on purpose: a sender may retry after a lost 250, and the queue may hand a message to two workers. Instead of chasing exactly-once, make the mailbox write idempotent with a dedupe key per recipient (see idempotency and retries). A message to three of your users is stored once and queued as three deliveries.

Back-pressure comes free: if the queue nears its limit, SMTP in answers 451, a temporary failure, and senders retry later. Mail is delayed, never dropped.

Mailbox storage, partitioned by user

Storing mailbox metadata
OptionGood atBad atPick it when
Sharded relational, by user Usual defaultOne user lives on one shard, so a delivery updates message, labels, counts and change log in one transaction.You run the sharding: a directory from user to shard, and moving heavy users between shards.Per-user consistency matters, as here. The usual answer.
Wide-column store, partition by userSpreads 46,000 writes a second with little effort and grows by adding nodes.Writes are atomic only within one partition, so label index rows, counters and the change log can drift apart and need repair jobs.Write scale matters more than exact counts and a perfect sync log.

Map users to shards with a directory, not user_id modulo the shard count, so you can move one user without rehashing everyone (see sharding and partitioning). Metadata grows 2 TB a day, so new shards keep arriving.

Attachments are parsed out at delivery and stored by the SHA-256 hash of their bytes, so a 5 MB file sent to 200 of your users takes 5 MB, not 1,000 MB. Access always goes through a message the user owns, never by hash. Since many messages can share one object, a background sweep deletes objects nothing has referenced for a few days; the grace period protects a delivery that is reusing the object right now.

Search, one user at a time

Every search is "my mail only". That decides how to split the index.

How to split the search index
OptionGood atBad atPick it when
Shard by user Usual defaultAll of a user's documents sit on one shard, so a query touches one machine and never sees anyone else's mail.A user with millions of messages makes a hot shard; you move them like mailbox users.Every query is scoped to one person. The usual answer.
One global index, any shardingStandard setup for a search engine.Every query fans out to every shard just to filter down to one user, and a bug in that filter shows people each other's mail.Searching across users, which mail never does.
Full-text inside the mailbox DBNo extra system; always in step with the mailbox.Indexing 46,000 messages a second competes with deliveries, and ranking is basic.Early on, while the user base is small.

Index headers, body text and attachment names; a slower job adds text pulled out of documents. from:, has:attachment and label: become filters. Results are checked against the mailbox before showing, so a message deleted a second ago never appears. Search systems covers the inverted index.

The send path: retries and bounces

Outgoing mail waits in a queue. Temporary failures go back for a later try; permanent ones become a bounce in the sender's own inbox.Mail API to Outbound queue (send); Outbound queue to SMTP out, asynchronous; SMTP out to Other mail servers (SMTP + DKIM); SMTP out to Retry schedule (4xx: later), asynchronous; Retry schedule to Outbound queue (when due), asynchronous; SMTP out to Sender's mailbox (5xx: bounce).Mail APIOutbound queueSMTP outOther mailserversRetry scheduleSender's mailboxsendSMTP + DKIM4xx: laterwhen due5xx: bounce

request and replyasynchronous message

Outgoing mail waits in a queue. Temporary failures go back for a later try; permanent ones become a bounce in the sender's own inbox.

Before returning 202, one transaction on the user's shard writes the message to Sent and an outbox row; a relay moves outbox rows to the queue, so a crash cannot leave mail in Sent that never leaves. The draft id is the idempotency key, so a double tap sends once. Recipients on your own service skip SMTP and go straight to the delivery workers.

  • Delivery: group recipients by domain, look up its MX record, connect over TLS, and sign with DKIM so the receiver can verify the message came from you.
  • Temporary failure (4xx, timeout, no answer): retry with growing gaps, say 5 minutes, then 15, then hourly. The SMTP standard suggests senders keep trying for at least four to five days before giving up.
  • Permanent failure (5xx, such as "no such user"), or retries used up: put a bounce naming the address and reason into the sender's inbox. Only this path makes bounces.
  • Reputation: receivers judge your sending IP addresses, so rate-limit accounts and send bulk and personal mail from separate address pools.

Sync across devices

Each user has a counter. Every mailbox change, in its own transaction, takes the next number and writes a row to changes. Taking the number locks the counter row until commit, so changes commit in number order and a reader never skips one that commits late. A device remembers the last number it saw. IMAP has the same idea, a per-mailbox modification sequence.

  • Telling devices: an open connection for web, a mobile push for phones (see realtime communication). The push only says "something changed" and the device pulls, so a lost push only delays sync.
  • Offline edits: the device queues operations such as "mark m_9f2 read" and replays them on reconnect. Operations on different labels do not conflict; if two devices set the same flag differently, the last write wins.
  • Far behind: keep 30 days of change log. A device that asks for something older gets 410 and resyncs the recent window, such as the newest 500 messages per label.

Because a user lives on one shard, the counter is a plain column, not a distributed sequence. Design File Sync and Design a Chat App use similar cursors.

Bottlenecks and failures

At 10x, about 460,000 messages a second and 1.2 PB a day, storage cost is the first wall. Compress bodies, store attachments once, tier old mail and enforce quotas. SMTP in and the workers are stateless and scale out behind several MX records; mailbox and search shards grow by adding more.

When this diesWhat happensWhat you do
An SMTP in serverOpen connections drop before 250.Senders retry another MX host. Nothing accepted is lost.
Filter workers fall behindThe inbound queue grows; mail is late.Add workers. If the queue nears its limit, reply 451 so senders hold mail for you.
A mailbox shard primaryIts users cannot read; their deliveries wait in the queue.Fail over to a synchronous replica; the queue drains after.
The search clusterSearch fails; mail works.The indexer resumes from the change log and catches up.
The blob store, in one zoneReads use the copies in other zones.Store each object in at least 3 zones before acknowledging.
A remote mail serverOutbound mail to that domain waits.Retry for days, then bounce to the sender.

What a strong answer sounds like

LevelWhat the answer does
JuniorDraws a mail server writing into a database with an inbox query and a search box; adds object storage when prompted.
MidPuts a queue between the SMTP edge and processing, partitions by user, stores bodies in object storage, retries and bounces outbound mail.
SeniorPins durability to the moment of 250, makes delivery idempotent instead of chasing exactly-once, avoids backscatter, keeps labels, counts and the sync log in one per-user transaction, and leads the 10x talk with storage cost.
When is a message safe?Strong answer
Interviewer

A sender's server hands you a message. When do you say "accepted"?

You

After the raw message is in object storage with three copies and a reference is on a replicated queue. Until then I can still fail with a 4xx and the sender keeps its copy. After that, losing it is on us.

Interviewer

And if you crash right after writing, before replying?

You

The sender retries, so I'll get it twice. The mailbox insert is unique on a dedupe key built from the Message-ID and a content hash, so the second copy is a no-op. At-least-once plus an idempotent write is simpler than trying for exactly-once.

Follow-up questions

How does "undo send" work?

Put the message on the outbound queue with a delay, say 10 seconds. Undo deletes it from the queue before it is released. Once SMTP out has handed it to another provider, it cannot be recalled.

A mailing list sends one message to 1 million of your users. What happens?

Store the body once and queue 1 million deliveries: about 22 seconds of the system's average 46,000 a second. Give bulk senders a lower-priority queue so personal mail is not stuck behind it.

How do you stop your own users from sending spam?

Per-account rate limits, stricter for new accounts, and a spam score on outgoing mail. A hijacked account shows up as a sudden burst to strangers; suspend sending and ask the owner to verify.

Could you add end-to-end encryption?

Yes, but the server can no longer read bodies, so server-side search and content filtering stop working. Search moves to the device and filtering relies on sender reputation.

How would you support IMAP clients?

Add an IMAP front end that maps labels to folders and the change log to IMAP's modification sequences. The catch: a message with two labels appears in two folders, and deleting it from one should only remove that label.

Self-review: email service0 of 14

After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".

Before drawing

Numbers and interfaces

Design and depth

Failure