Design an Email Service
Gmail System Design
Overview
An email service like Gmail does four jobs that look simple and are not: it sends mail (to your coworker and to a stranger on another provider), it receives mail (someone on another provider sends you a message and it has to land in your inbox), it stores and shows a mailbox (folders, labels, and threaded conversations you can read and page through), and it lets you search years of mail in milliseconds — while quietly keeping spam out. The reason this is a real system-design question and not a CRUD app is that email is a federated, unreliable, adversarial medium: you talk to servers you don't control, over a protocol from 1982, that go down and rate-limit you, while spammers actively try to get in.
It is labeled "Medium" because most of the components are well-understood, but the interesting decisions cluster around a few boundaries: sending must never block the user on a flaky external server, so it has to be queued with retries; delivery over the network can happen more than once, so it has to be idempotent; and a mailbox is a lopsided store — tiny bits of metadata you query constantly, wrapped around large message bodies you touch rarely — so you must split metadata from the body. Get those three right and the rest follows.
In this walkthrough you'll scope it, size it, model the data, design the API and the protocol surfaces, draw the send and receive paths, and then go deep on the send/receive pipeline, mailbox storage and reads, and search. Search is the third deep dive here; spam filtering is its natural counterweight, sketched as an extension — swap the two if your interviewer leans that way.
Out of scope (say so): the webmail/mobile UI, calendar and contacts (separate products that happen to share a login), end-to-end encryption (an extension — it fights server-side search and spam scanning), and the anti-abuse machinery around outbound sending reputation beyond a mention.
Functional requirements
- Send mail. A user composes a message to one or more addresses — internal (another user on our service) or external (
someone@othermail.com) — and we deliver it. External delivery goes out over SMTP to the recipient's mail server. - Receive mail. Another server connects to us over SMTP and hands us a message for one of our users; we accept it, check it, and deliver it into that user's mailbox.
- Read and organize a mailbox. List and open messages, grouped into threads/conversations, organized by labels and folders (Inbox, Sent, Starred, custom labels), with read/unread flags and pagination.
- Search. Full-text search over the user's own mailbox — by keyword, sender, subject, date — returning matching messages fast.
- Spam filtering. Keep unwanted and malicious mail out of the inbox, routing it to a Spam folder instead of dropping it silently.
Out of scope (state it): the compose UI, contacts/calendar, mailing-list management, and legal e-discovery/retention holds. We keep send, receive, store/read, and search+spam central — they are the system.
Non-functional requirements
- Sending never blocks on the network. Composing and hitting "send" must return immediately. External SMTP delivery is unreliable and slow, so it happens asynchronously with retries — the user is not made to wait on a remote server that might be down.
- At-least-once delivery, deduplicated. A message that was accepted for delivery must eventually arrive (or bounce with a clear reason). Because retries can deliver the same message twice, the receiving side must dedup so the user never sees a duplicate.
- Durability over everything. Losing a user's email is unacceptable. Accepted mail must be persisted redundantly before we acknowledge it to the sender.
- Read-heavy mailbox, low-latency reads. Users open and scroll their inbox far more than mail arrives. Listing a mailbox and opening a thread must be fast; the read path is the hot path.
- High availability. Reading mail must stay up even if outbound sending is degraded — they're different subsystems and should fail independently.
- Correct, non-destructive spam handling. A false positive (real mail in Spam) is worse than a false negative here, so suspected spam is filed, not deleted.
Estimations
State assumptions; the goal is to justify the metadata/body split and the read-heavy design, not exact arithmetic.
Rounded assumptions
- Users: ~1B accounts, ~300M active daily.
- Emails/day: on the order of ~100B messages/day across the system (most of it, before filtering, is spam and bulk mail). Round the delivered volume to ~10s of billions.
- Delivery rate: ~100B/day ÷ 86,400 ≈ ~1.2M messages/sec average, several times that at peak — dominated by inbound SMTP connections we must accept and scan.
- Average message size: ~75 KB with headers and a small body; attachments dominate storage — a message with a 5 MB attachment is 70× a text-only one. Assume an effective average of a few hundred KB once attachments are blended in.
- Storage: ~10s of billions of messages delivered per day, accumulated over years of retention, is on the order of trillions of stored messages; at ~100 KB effective average that's hundreds of PB into the low exabytes, overwhelmingly in message bodies and attachments, not metadata. (10s of billions/day alone is only tens of PB — it's the multi-year accumulation that pushes it to exabyte scale.)
- Read:write ratio: users read their mailbox far more than new mail arrives — assume ~10:1 reads to writes or higher. The mailbox is a read-heavy store.
How the numbers affect the design
| Signal from the numbers | Design decision |
|---|---|
| Bodies + attachments dominate storage (exabytes) | Store the large body/attachments in a blob store, keep only small metadata in the DB. |
| Metadata is small but queried constantly | Put headers, flags, labels, and thread id in a fast DB indexed for list/read. |
| External SMTP is slow and unreliable | Queue outbound sends; deliver in the background with retries and backoff. |
| Retries can deliver twice | Dedup on Message-ID so a redelivered message doesn't duplicate. |
| Read:write ~10:1 | Optimize and cache the read path; index the mailbox for fast listing. |
| Most inbound is spam | Scan every inbound message; make spam filtering a first-class stage, not an afterthought. |
Core entities / data model
The key modeling decision is the split: a mailbox is a lot of small, hot metadata (who, when, subject, read/unread, which labels, which thread) wrapped around a large, cold body (the full MIME message and its attachments). Those two things have opposite access patterns and belong in different stores.
| Entity | Key fields |
|---|---|
User |
user_id (PK), email_address, display_name, quota |
Message (metadata) |
message_id (PK), owner_user_id, rfc_message_id (the Message-ID header, used for dedup + threading), thread_id, from, to, subject, snippet, size, received_at, flags (read/unread, starred), blob_key → body location |
MessageBody (in blob store) |
stored by blob_key: the full raw MIME message + attachment parts — large, immutable, write-once |
Thread |
thread_id (PK), owner_user_id, subject, participants, last_message_at, message_count |
Label |
label_id (PK), owner_user_id, name, type (system: Inbox/Sent/Spam, or user-defined) |
MessageLabel |
(message_id, label_id) — the many-to-many join; a message can carry several labels |
Messagemetadata is per-user. When one email is sent to three of our users, each recipient gets their ownMessagerow (their own flags, labels, thread membership) — mail is not a shared document. The heavy body, though, can be shared/deduplicated: identical attachments can point at one blob (extension).- The body lives in the blob store, addressed by
blob_key. It's large, immutable, and read only when a message is actually opened — a textbook fit for object storage, not a relational row (deep dive 2). Threadgroups a message with its replies (deep dive 2, threading byReferences/In-Reply-To).- Labels are many-to-many via
MessageLabel. Gmail's "folders" are really labels; "move to folder" is "swap one label for another." Inbox, Sent, and Spam are system labels.
Note
Do not store the raw message body as a big column in the relational mailbox DB. Bodies and attachments are the bulk of the storage and are read rarely; keeping them in the row bloats every table scan, every backup, and every list query. Keep the row small (metadata + a blob_key) and put the body in object storage.
API design
Two surfaces coexist: an internal HTTP/JSON API the webmail and mobile clients use to read and organize the mailbox, and the SMTP protocol on the wire for server-to-server mail exchange. Clients never speak SMTP to us; they call the API, and our servers speak SMTP to the outside world.
Send a message (client → our API)
POST /api/messages/send
Body: {
"to": ["alex@othermail.com", "sam@ourservice.com"],
"subject": "Lunch?",
"body_html": "...",
"attachments": ["blob_key_of_uploaded_file"],
"in_reply_to": "<rfc-message-id-being-replied-to>" // optional, for threading
}
202 Accepted
Returns: { "message_id": "...", "status": "queued" }
Accept the message for sending and return 202 Accepted immediately — the message is queued, not yet delivered. We persist it to the Sent mailbox and enqueue outbound delivery; the actual SMTP handshake with othermail.com happens in the background with retries (deep dive 1). Returning 202 instead of waiting is the whole point: the user must not block on a remote server that might take 30 seconds or be down.
List a mailbox (client → our API)
GET /api/messages?label=INBOX&cursor=<opaque>&limit=50
200 OK
Returns: {
"threads": [
{ "thread_id": "...", "subject": "Lunch?", "snippet": "...", "unread": true,
"message_count": 3, "last_message_at": "...", "labels": ["INBOX"] },
...
],
"next_cursor": "..."
}
The hot read path. List a label/folder as threads (not loose messages), newest-first, with cursor-based pagination so new arrivals don't shift pages under the user. Returns only metadata and a snippet — no bodies — so a page load is a cheap metadata query.
Open a thread (client → our API)
GET /api/threads/{thread_id}
200 OK
Returns: { "thread_id": "...", "messages": [ { "message_id": "...", "from": "...",
"body_url": "<signed URL into blob store>", "attachments": [...] }, ... ] }
Return the thread's messages and, per message, a signed URL the client uses to fetch the body directly from the blob store — the body never streams through the API. Opening a thread also flips its messages to read.
Search (client → our API)
GET /api/search?q=from:alex invoice after:2026/01/01&limit=25
200 OK
Returns: { "results": [ { "message_id": "...", "thread_id": "...", "snippet": "..." }, ... ] }
Full-text search over the caller's own mailbox, backed by a per-user inverted index (deep dive 3). Supports operators (from:, subject:, after:).
Modify labels / flags
POST /api/messages/{message_id}/labels Body: { "add": ["Work"], "remove": ["INBOX"] }
200 OK
Apply/remove labels (archiving is "remove INBOX"; move-to-folder is add-one/remove-another) and toggle read/starred.
Inbound SMTP is not a REST endpoint — it's our MX servers accepting connections on port 25 (deep dive 1). Worth stating explicitly so the interviewer sees you know clients and peer servers use different surfaces.
Tip
Keep the client API push-free and simple: list threads, open a thread, mutate labels, search. All the hard, unreliable work — SMTP, retries, spam scanning — lives behind the API on the server side, not in the request path.
High-level architecture
The system splits cleanly into a sending path (outbound: user → our queue → recipient's server), a receiving path (inbound: peer server → our MX → spam scan → mailbox), and a read path (client → mailbox metadata + blob store). They share the mailbox store but otherwise scale and fail independently.
1. Sending path (outbound)
The client hands us a message and we return fast; a background pipeline does the unreliable network work.
flowchart LR
Client["Webmail / Mobile"] -->|"POST /messages/send"| API["Mail API"]
API -->|"persist to Sent"| Store["Mailbox Store"]
API -->|"enqueue"| OutQ[["Outbound Queue"]]
API -->|"202 Accepted"| Client
OutQ --> SendW["SMTP Sender Workers"]
SendW -->|"lookup MX, then SMTP"| DNS[("DNS / MX records")]
SendW -->|"deliver over SMTP"| Ext["Recipient's Mail Server"]
SendW -.->|"bounce / DSN on hard failure"| Store
- The API validates, writes the message to the user's Sent folder, and enqueues an outbound job. It returns
202without waiting. - Sender workers pull jobs, and for each recipient domain look up the domain's MX record in DNS to find the recipient's inbound server.
- The worker opens an SMTP connection and delivers. On a transient failure it retries with backoff (deep dive 1); on a permanent failure it generates a bounce back to the sender.
- Internal recipients (
sam@ourservice.com) skip SMTP entirely — the send is just a local mailbox write.
2. Receiving path (inbound)
A peer server connects to our MX; we accept, scan, dedup, and file the message.
flowchart LR
Peer["Peer Mail Server"] -->|"SMTP to our MX"| MX["MX / Inbound SMTP"]
MX -->|"accept + persist raw"| RawQ[["Inbound Queue"]]
RawQ --> Proc["Delivery Processor"]
Proc -->|"spam + virus scan"| Spam["Spam / Virus Filter"]
Proc -->|"dedup on Message-ID"| Store["Mailbox Store"]
Proc -->|"store body"| Blob[("Blob Store")]
Proc -->|"assign thread + labels"| Store
Proc -.->|"index for search"| Idx[("Per-user Index")]
- Our MX servers accept the SMTP connection, do cheap up-front checks (is the recipient one of ours? sender reputation), and persist the raw message durably before acknowledging — so a crash after "250 OK" never loses accepted mail.
- The message goes on an inbound queue and a delivery processor takes over asynchronously: run spam + virus scanning, dedup on
Message-ID, write the body to the blob store, create the per-userMessagemetadata, assign it to a thread, and apply labels (Inbox or Spam). - It then indexes the message for that user's search.
3. Read path
flowchart LR
Client["Webmail / Mobile"] -->|"GET /messages?label=INBOX"| API["Mail API"]
API -->|"list metadata (threads, flags, labels)"| Store["Mailbox Store"]
API -->|"signed URL"| Client
Client -->|"fetch body directly"| Blob[("Blob Store")]
Client -->|"GET /search?q=..."| API
API --> Idx[("Per-user Index")]
Listing a mailbox is a metadata-only query against the mailbox store — cheap and cacheable. Opening a message returns a signed URL, and the client pulls the (large) body straight from the blob store, so bodies never clog the API tier. Search hits the per-user index.
4. Combined architecture
Putting the three paths together, the shared mailbox store and blob store sit in the middle; sending, receiving, and reading otherwise scale and fail independently.
flowchart LR
Client["Webmail / Mobile"] -->|"send"| API["Mail API"]
Client -->|"list / open / search"| API
API -->|"enqueue"| OutQ[["Outbound Queue"]]
OutQ --> SendW["SMTP Senders"] -->|"MX + SMTP"| Ext["External Servers"]
Peer["Peer Servers"] -->|"SMTP"| MX["Inbound MX"] --> InQ[["Inbound Queue"]]
InQ --> Proc["Delivery Processor\n(scan, dedup, thread, label)"]
Proc --> Store[("Mailbox Store\n(metadata)")]
Proc --> Blob[("Blob Store\n(bodies)")]
Proc -->|"index event"| Idx[("Per-user Index")]
API --> Store
API --> Idx
Client -.->|"signed URL"| Blob
The read path and the delivery path share only the stores and don't contend: reads hit small cached metadata and pull bodies straight from the blob store, while delivery writes happen off the inbound queue. Outbound sending is its own subsystem — degraded sending never takes down reading.
Warning
Do not deliver external mail synchronously inside the send request. If the API opens an SMTP connection to othermail.com and waits, one slow or dead recipient server hangs the user's "send" for seconds — and a spike of sends exhausts your connection pool. Enqueue and deliver in the background.
Deep dives
1. The send + receive pipeline — reliability, retries, and idempotency
Email delivery is a distributed handoff between servers you don't control. The whole design of this pipeline is about making an unreliable, at-least-once medium behave acceptably.
Outbound: queue, don't block. The send request only persists the message and enqueues it. Sender workers do the slow part: resolve the recipient domain's MX record, open an SMTP connection, and deliver. This has to be asynchronous because external servers are routinely slow, greylisting you, or down.
Retries with exponential backoff. SMTP distinguishes transient failures (a 4xx reply — "try again later," greylisting, temporary overload) from permanent ones (a 5xx — "no such mailbox," "blocked"). On a 4xx, the worker retries with exponential backoff — minutes, then tens of minutes, then hours — over a total window of a couple of days (the classic SMTP retry window) before giving up. Jitter the retries so a fleet of workers doesn't reconnect in lockstep.
Greylisting is why retry is mandatory. Many receiving servers deliberately reject the first delivery attempt from an unknown sender with a 4xx, expecting a legitimate server to come back a few minutes later. If your sender doesn't retry, your mail silently never arrives. Retry isn't just for outages — it's part of normal delivery.
Bounces and DSNs. When delivery permanently fails (a 5xx, or the retry window expires), the sender worker generates a bounce / DSN — an automated "your message to alex@othermail.com couldn't be delivered" mail dropped into the sender's own inbox. Never fail silently.
Inbound: accept durably, then process asynchronously. Our MX accepts a connection, does cheap gatekeeping (recipient exists? sender IP not blocklisted?), and persists the raw message before returning 250 OK. That ordering is the durability contract: once we say 250, the peer server considers the message delivered and forgets it, so we must already have it safely stored. Heavy work — spam scanning, threading, indexing — happens after the ack, off the inbound queue, so a slow scanner can't stall the SMTP handshake and cause the sender to retry.
Idempotency via Message-ID dedup. SMTP is at-least-once: if we crash (or are slow to ack) after accepting the message but before the peer sees our 250 OK, the peer will retry and deliver the same message again. The raw inbound accept is intentionally not deduped — it's cheap and we'd rather accept a duplicate than risk losing mail — so the single-copy guarantee is enforced downstream, at the delivery processor's INSERT on (owner_user_id, rfc_message_id) under a unique constraint: a redelivered message conflicts and is skipped instead of creating a second copy. One caveat: Message-ID is sender-supplied and only best-effort — it can be absent or forged. When it's missing, fall back to a content hash of the raw message as the key; accept the (rare) risk that two genuinely different messages sharing a forged ID could collide, and weigh it against the "never lose mail" contract before choosing to drop on conflict.
Peer delivers message, Message-ID = <abc@othermail.com>
Processor: INSERT (owner=U, msgid=abc) → succeeds → file into mailbox
Peer's ack timed out → peer RETRIES same message, same Message-ID
Processor: INSERT (owner=U, msgid=abc) → conflicts → skip, do NOT file again
→ exactly one copy in U's mailbox
The duplicate doesn't only come from the peer retrying. Our own sender worker can send the same mail twice — if it crashes after the recipient's server returned 250 OK but before it recorded the send as done, its retry re-sends. The recipient's Message-ID dedup is what saves both cases: peer-side redelivery and our-own-side re-send collapse to one copy on the same unique key.
Deliver external mail synchronously in the send request (avoid)
The API, on receiving a send, opens the SMTP connection to each recipient's server and only returns once the remote server accepts (or rejects).
- Pro: the user gets a definitive "delivered/failed" answer inline; simplest to reason about.
- Con: the user's "send" now blocks on a third-party server that may take tens of seconds, be greylisting, or be down; a burst of sends exhausts connections; there's no natural home for retries or the multi-day SMTP retry window; one bad recipient stalls a multi-recipient send.
- Verdict: avoid. External delivery is inherently slow and retry-heavy; it must be queued.
Queue outbound, deliver with retries; accept inbound durably then process async (recommended)
Send returns 202 after persisting + enqueuing. Workers deliver with exponential backoff over a multi-day window, bounce on permanent failure. Inbound MX persists before ack, then a processor scans, dedups on Message-ID, and files.
- Pro: users never block on remote servers; retries and greylisting are handled naturally; spam scanning can't stall the SMTP handshake; at-least-once redelivery is made safe by
Message-IDdedup. - Con: delivery is eventual (the sender learns of failure via a later bounce, not inline); more moving parts (queues, workers, dedup store).
- Verdict: the standard design. Queueing plus idempotent delivery is exactly what a federated, unreliable medium requires.
Caution
Don't assume delivery is exactly-once. SMTP retries on any ambiguous outcome, so the same message can arrive twice. Dedup on Message-ID at the receiving side; treat "delivered once" as something you enforce, not something the network gives you.
2. Mailbox storage and the read path — metadata vs body, threading, labels
The mailbox is the read-heavy heart of the system, and its central design choice is the split between small hot metadata and large cold bodies.
Metadata in a DB, body in a blob store. A Message row is small — headers, flags, a snippet, label memberships, a thread_id, and a blob_key. The full MIME body and attachments (the bulk of the bytes) live in the blob store, addressed by blob_key. This split is what makes the read path fast: listing a mailbox touches only metadata — no multi-KB bodies dragged through every query — and the body is fetched (via a signed URL, directly from the blob store) only when a message is actually opened. It's also cheaper and more durable: object storage is built for exabytes of write-once immutable blobs, and bodies never bloat the DB's indexes, backups, or table scans. (This body-in-blob-store, metadata-in-DB split is the same pattern the post-search and object-storage designs use — small queryable metadata beside large immutable objects.)
Store the full message body inline in the mailbox DB row (avoid)
Each Message row carries the entire raw MIME body and attachments as a large BLOB/TEXT column.
- Pro: one store, one query gets everything; no signed-URL dance; transactional consistency between metadata and body for free.
- Con: the mailbox DB becomes exabytes of mostly-cold bytes; every list query, index, and backup drags multi-MB attachments it doesn't need; caching the hot metadata is polluted by huge rows; the relational store scales far worse and costs far more than object storage for this shape of data.
- Verdict: avoid at scale. The row must stay small; the body belongs in a blob store.
Small metadata row + body in blob store, joined by blob_key (recommended)
Keep headers, flags, labels, thread id, snippet, and a blob_key in the DB; store the raw MIME + attachments in object storage; serve the body via a signed URL on open.
- Pro: list/read hot path touches only small metadata (fast, cacheable); body storage is cheap, durable, and effectively unbounded; bodies stream client↔blob-store, never through the API tier; enables attachment dedup.
- Con: two stores to keep consistent (a body write and a metadata write); a rare orphaned blob or dangling
blob_keymust be reconciled by a background sweep. - Verdict: the standard split. Its read-path speed and storage economics are exactly what a read-heavy exabyte mailbox needs.
Threading / conversations. Group a message with its replies into a thread. The mechanism is the header chain: a reply carries In-Reply-To (the Message-ID it answers) and References (the whole ancestor chain). On delivery, the processor looks up whether any referenced Message-ID already belongs to a thread in this user's mailbox; if so, it joins that thread_id, else it starts a new one. Subject-similarity is a fallback for clients that omit the headers, but the header chain is authoritative.
flowchart TD
M1["Msg A\nMessage-ID: a1\n(no In-Reply-To)"] -->|"starts"| T["Thread T"]
M2["Msg B\nIn-Reply-To: a1\nReferences: a1"] -->|"references a1 → join"| T
M3["Msg C\nIn-Reply-To: b1\nReferences: a1, b1"] -->|"references a1 → join"| T
T --> View["Thread view:\nA → B → C"]
Labels and folders as many-to-many. In this model there are no rigid folders — a message carries a set of labels via the MessageLabel join table. "Inbox," "Sent," "Spam" are system labels; "Work," "Receipts" are user labels. Archiving is removing the INBOX label; move to folder is remove-one/add-another; a message can legitimately be in Inbox and Work at once. Listing a folder is "select messages where label = X," which needs an index on (owner_user_id, label_id, received_at) to page a label newest-first efficiently.
Read-heavy optimizations. Because reads outnumber writes ~10:1, the mailbox metadata store is indexed for list-by-label and cached aggressively (the inbox's first page is the single most-requested object in the system). Flags like read/unread are hot, small mutations. Thread-level rollups (message_count, last_message_at, unread-count) are maintained on write so a list query doesn't recompute them.
Note
Per-user metadata, shared-eligible bodies. Each recipient gets their own Message row (their own flags, labels, thread) because organization is personal. But the body is identical across recipients of the same email and across identical attachments — so bodies/attachments in the blob store can be content-addressed and deduplicated (extension), storing one copy for many pointers.
Push vs poll for new mail. Clients want new mail to appear without hammering the API. Options: poll (client asks "anything new?" every N seconds — simple, but laggy and wasteful at idle), or push (IMAP IDLE, or a persistent WebSocket/long-poll the server notifies on delivery — near-instant, but holds an open connection per active client). Gmail-scale services push; a smaller design can poll. Say which and why.
3. Search — per-user full-text over the mailbox
Every user expects to type "invoice from alex last March" and get an instant answer over years of mail. That's a full-text search problem, and the defining constraint is that it is strictly per-user: a search must only ever see the caller's own mailbox.
Per-user inverted index. As with any full-text search, the core structure is an inverted index — term → the messages containing it — plus indexed fields for the operators (from:, subject:, after:). The crucial difference from a global search engine is partitioning: the index is sharded/partitioned by user, so user_id is effectively a hard prefix on every query and one user's mail can never leak into another's results. (The inverted-index mechanics — tokenization, posting lists, ranking — are covered in depth in the post-search breakdown; here the interesting twist is the per-user partitioning and the freshness requirement, not re-deriving the index.)
Per-user index vs one giant shared index. A single global index over everyone's mail would force a user_id filter on every query and make correctness (never leaking another user's mail) a filtering concern rather than a structural guarantee — and it concentrates unrelated load. Partitioning by user makes isolation structural: a query only ever touches the caller's partition, each partition is tiny (one mailbox, not a billion), and there's no cross-user fan-out. The tradeoff is a very large number of small indexes to manage, which is exactly what a per-user-partitioned search cluster is built for.
Kept in sync as mail arrives. The index must update as part of delivery — a message searchable within seconds of landing. The delivery processor, after filing a message, emits it to the indexing pipeline (tokenize subject/body/headers, update that user's partition). This is a near-real-time indexing path riding on the same inbound flow.
flowchart LR
Deliver["Delivery Processor\n(message filed)"] -->|"index event"| IdxQ[["Indexing Queue"]]
IdxQ --> Indexer["Indexer\n(tokenize subject/body/headers)"]
Indexer -->|"update user's partition"| Part[("Per-user index partition")]
Client["Client"] -->|"GET /search?q=..."| API["Mail API"]
API -->|"query only this user's partition"| Part
Part -->|"matching message_ids"| API
API -->|"hydrate metadata + snippet"| Store["Mailbox Store"]
- On delivery, the message is queued for indexing (decoupled so a slow indexer doesn't stall delivery). Because indexing lags delivery, a just-arrived message is readable in the mailbox before it's findable in search — a small, acceptable staleness the queue trades for delivery-path isolation.
- The indexer tokenizes and updates only that user's partition.
- A search query is routed to the caller's partition, returns matching
message_ids, and the API hydrates them into snippets from the mailbox store — ranked by recency (and relevance), paginated.
Ranking. Mail search is dominated by recency ("the invoice from last week") more than pure textual relevance, so newest-matching-first with operator filters is the sensible default — a lighter ranking problem than web search.
Tip
Say "per-user partitioned index" out loud. It turns the scariest requirement — never leaking one user's mail into another's search — from a filter you might forget into a structural property of where the data lives. That's the move interviewers want here.
Note
If the interviewer steers toward spam instead of search, spam filtering is the natural alternative third deep dive — the Extensions section sketches it (connection-time signals, a content classifier, the Spam label, a mark-as-spam feedback loop).
Tradeoffs & bottlenecks
- Metadata-DB + blob-store split vs inline bodies. The split makes the read path fast and storage cheap and durable, at the cost of two stores to keep consistent and the occasional orphaned blob. Inline bodies are simpler but blow up the DB. The split is the standard call at this scale.
- Async delivery vs synchronous send. Queueing outbound makes the user's send instant and handles retries/greylisting/bounces, at the cost of eventual delivery and failure-by-bounce instead of inline. Standard and necessary.
- At-least-once + dedup vs "exactly-once." True exactly-once across federated servers is impossible; you get at-least-once from SMTP and enforce single-copy with
Message-IDdedup. Accept that guarantee and dedup rather than pretend the network is exactly-once. - Per-user index vs shared index. Per-user partitioning makes isolation structural and queries cheap, at the cost of managing a huge number of small indexes. A shared index is fewer moving parts but pushes correctness onto a filter and concentrates load. Per-user wins for a mailbox.
- Sync vs async spam scan. Cheap connection-time signals (IP reputation, auth checks, greylisting) run during the SMTP handshake to reject the worst before you even accept the body; the expensive content classifier runs after the ack, off the queue, so it can't stall the handshake. Split the cheap-early / expensive-late work.
- Push (IMAP IDLE / WebSocket) vs poll. Push gives instant new-mail with an open connection per active client; poll is simpler but laggy and wasteful. Large services push.
- Durability write cost. Persisting every accepted message redundantly before ack (and storing bodies in a replicated blob store) is significant write and storage load — but losing mail is unacceptable, so this cost is non-negotiable.
Extensions if asked
Add only the extension that changes the design discussion:
- Spam / phishing detection (if search was the deep dive). Connection-time reputation + SPF/DKIM/DMARC + greylisting, then a content classifier filing to the Spam label, with a "mark as spam" feedback loop. The counterpart deep dive to search.
- Attachments and large files. Very large attachments bypass the message path — upload to the blob store first, send only a reference/link; enforce per-message and per-mailbox size quotas.
- Dedup of identical attachments. Content-address blobs by hash so one physical copy backs many messages (the same 5 MB slide deck mailed to 500 people is stored once), with reference counting for cleanup.
- Threading edge cases. Missing/forged
Referencesheaders, subject-only threading fallbacks, and splitting a runaway thread — where the header chain isn't authoritative. - IMAP / POP access. Support standard mail clients: IMAP (server-side folders, sync,
IDLEpush — maps naturally onto our label model) and POP3 (download-and-delete, a simpler legacy path). - End-to-end encryption (PGP / S-MIME). Encrypt bodies so only the recipient can read them — which breaks server-side search and spam scanning (you can't index or classify what you can't read). Worth naming precisely because it trades away two features this design is built around.
What interviewers look for & common mistakes
What interviewers usually reward:
- Splitting metadata from body — small hot rows in the DB, large cold bodies in a blob store — as the first structural move, and knowing why (read-heavy mailbox, exabyte bodies).
- Queued, asynchronous sending with retries, exponential backoff, the multi-day SMTP retry window, greylisting, and bounces/DSNs — never blocking the user on external SMTP.
- Idempotent delivery — recognizing SMTP is at-least-once and deduping on
Message-IDso a retried delivery doesn't duplicate. - Accept-durably-then-process-async on inbound — persist before
250 OK, then scan/thread/index off a queue so a slow scanner can't stall the handshake. - Threading via
References/In-Reply-Toand labels as many-to-many, with archiving/move modeled as label mutations. - Per-user partitioned search index, kept in sync on delivery, making cross-user isolation structural.
Before you finish, do a quick mistake check:
- Did you keep large bodies/attachments out of the relational DB and in a blob store, with only small metadata in the row?
- Did you queue outbound sends with retries/backoff instead of doing SMTP synchronously in the request?
- Did you make inbound delivery idempotent (dedup on
Message-ID) because SMTP retries can deliver twice? - Did you accept mail durably before acking, then push spam scanning / threading / indexing to an async stage?
- Did you respect the read-heavy mailbox vs write-heavy delivery split — fast cached metadata reads, bodies fetched only on open?
- Did you make search per-user-partitioned so one user's mail can never surface in another's results?
Practice this live
Run this exact question with our AI voice interviewer and get feedback.
Start the interview →