Hard

Design an Email Service (Gmail)

A web email service that receives mail from the open internet over SMTP, filters spam, stores mailboxes durably, supports fast full-text search over years of mail, and handles attachments. The hard parts are the untrusted ingestion boundary, a multi-stage spam pipeline, storage layout for billions of small messages, and search index freshness.

1Requirements

Functional

  • Receive email from any internet MTA via SMTP and deliver it to the correct user's mailbox, including plus-addressing and aliases.
  • Send outbound email with proper SPF/DKIM signing and queued retries to remote servers.
  • Classify incoming mail as inbox or spam with a multi-signal pipeline; users can correct classifications.
  • Full-text search across a user's entire mail history including sender, subject, body, and attachment names, with operators like from: and has:attachment.
  • Support attachments up to 25 MB, stored once even when sent to many recipients.
  • Standard mailbox operations: read/unread, labels/folders, archive, delete, threads (conversation grouping).

Non-functional

  • Never lose an accepted message: once the SMTP 250 OK is returned, the message is durably replicated.
  • Inbox load under 200 ms p99; search under 500 ms p99 over a 10-year mailbox.
  • Spam pipeline decision within 2 seconds so delivery is not delayed noticeably.
  • Scale to 1 billion accounts and roughly 100 billion messages received per day (most of it spam to be rejected cheaply).
  • Strong isolation between tenants: one user's mail is never visible to another under any failure mode.
  • Encryption at rest for message bodies and attachments; TLS for all SMTP and client connections.

2Back-of-envelope estimation

Inbound SMTP rate~1.2M msgs/s attempted
Accepted mail rate~230K msgs/s
Storage growth~1.5 PB/day
Per-user quota math15 GB x 1B users = 15 EB ceiling
Search index size~10-15% of corpus

3API design

GET /v1/mailbox/threads?label=INBOX&cursor=...

Paginated thread list for a label, newest first, with snippet, participants, and unread counts.

GET /v1/messages/{messageId}

Full message: parsed headers, sanitized HTML body, attachment metadata with signed download URLs.

POST /v1/messages/send {to, cc, subject, body, attachmentIds}

Queues an outbound message; returns immediately, delivery status is tracked asynchronously.

GET /v1/search?q=from:alice has:attachment invoice

Full-text search over the user's mailbox with operator support; returns ranked message ids and snippets.

POST /v1/messages/{messageId}/labels {add, remove}

Mutates labels (spam/not-spam corrections here feed the classifier training loop).

4High-level design

The ingestion edge is a fleet of SMTP servers behind DNS MX records. They terminate TLS, apply connection-level defenses (IP reputation, rate limits, greylisting), and validate envelopes (SPF check, recipient exists). Most spam dies here with a cheap rejection before the message body is even transferred. Accepted messages are written to a durable write-ahead queue (Kafka) before the 250 OK is sent: the SMTP acknowledgment is a durability promise.

From the queue, a processing pipeline runs stages in order: parse MIME, extract and detach attachments to blob storage (content-addressed by hash so a 10 MB attachment sent to 500 recipients is stored once), run the spam pipeline, then deliver. Delivery means writing message metadata to the mailbox database, the body to message storage, and emitting an index event for search.

Mailbox storage is split by access pattern. Metadata (headers, flags, labels, thread ids) lives in a wide-row store like Bigtable or Cassandra keyed by (user id, message id) so an inbox page is one contiguous range read. Bodies live in blob storage, compressed, with hot recent messages cached. Threading is computed at delivery time using the References and In-Reply-To headers plus normalized subject fallback.

Search uses per-user index partitions in a Lucene-style engine (Elasticsearch or self-managed). Sharding by user keeps every query single-shard and makes tenant isolation structural. The indexer consumes delivery events from the queue, so a message is searchable within seconds. Attachment text extraction (PDF, docx) runs as a lower-priority enrichment that updates the index document.

Outbound mail is the mirror image: a submission service signs with DKIM, queues per destination domain, and retries with exponential backoff per SMTP rules (transient 4xx vs permanent 5xx). Sending reputation (IP warming, feedback loops, bounce handling) is its own operational discipline that determines whether your mail lands in other providers' inboxes.

5Data model

messages_meta (wide-row store)

user_id, message_id, thread_id, from_addr, to_addrs, subject, snippet, labels, flags, size_bytes, body_blob_key, attachment_keys, received_at, spam_score

Row key (user_id, reversed received_at, message_id) so newest-first inbox reads are one range scan.

message_bodies (blob store)

blob_key, compressed_mime_content, encryption_key_id

Bodies compressed with zstd; hot tier on SSD, cold tier erasure-coded on HDD.

attachments (content-addressed blob store)

sha256_hash, content, content_type, size_bytes, refcount

Content addressing dedupes identical attachments across all recipients globally.

search_index (per-user partition)

user_id, message_id, tokenized_subject, tokenized_body, from_addr, has_attachment, label_set, received_at

One logical index partition per user; queries never cross tenants.

6Deep dives

The SMTP acceptance boundary and durability

SMTP has a brutal contract: once you respond 250 OK to DATA, you own the message. The sending server deletes its copy. If you lose it after that, it is gone forever and silently. So the golden rule is: replicate before acknowledging. The edge server writes the raw message to a Kafka topic with acks=all (replicated to 3 brokers) and only then sends 250 OK. If Kafka is unavailable, respond 451 (transient failure) and the remote MTA will retry for days, which is a free durability mechanism.

Everything before 250 OK should reject as much as possible because rejection is cheap and lossless: the sender is notified by their own MTA. Reject unknown recipients at RCPT TO, reject failed SPF from known-bad IPs at MAIL FROM, apply greylisting (temp-fail first contact from unknown IPs; real MTAs retry, most spam cannons do not). This edge filtering is why the accepted rate is a fraction of the attempted rate.

After acceptance, filtering can only move mail to the spam folder, never drop it silently. Silent loss of a legitimate accepted message is the cardinal sin of email systems; a false positive in the spam folder is recoverable by the user.

Spam pipeline as staged filters

Spam filtering is a funnel of increasingly expensive checks. Stage 1 (connection time, microseconds): IP reputation lists, rate limits per IP and per sender domain. Stage 2 (envelope, milliseconds): SPF alignment, recipient validation, greylisting state. Stage 3 (content, tens of milliseconds): DKIM verification, DMARC policy evaluation, URL blocklists, fuzzy hashes of known spam campaigns (a Bloom filter of recent spam signature hashes makes this check O(1)). Stage 4 (expensive, only for survivors): ML classifier over text features, sender history, and user-specific signals.

The classifier improves through a feedback loop: every user marks-as-spam and not-spam action becomes a labeled training example. Aggregate signals matter too: if 10,000 users mark the same campaign hash as spam within an hour, retroactively reclassify it for everyone who has not opened it yet.

Score, do not binarize, until the end. Each stage adds to a spam score; final routing compares against per-user thresholds. This lets you tune aggressiveness globally and per user, and lets stage results be logged for offline analysis of misclassifications.

Mailbox storage layout and the small-object problem

Email is billions of small objects with a skewed access pattern: the last 30 days are read constantly, everything else almost never. Storing each message as one row in a relational database dies on both size and write amplification. The proven layout separates metadata from bodies.

Metadata goes in a wide-row/LSM store keyed by (user_id, time-reversed timestamp). An inbox page is then a single sequential range read of the newest N rows for that user, no index needed. Flags and labels are small mutable columns on those rows. Bodies go to blob storage in compressed form; group messages into larger append-only blocks per user to avoid filesystem small-file overhead, with an index mapping message id to (block, offset).

Tiering does the economics: recent blocks replicated 3x on SSD, blocks older than 90 days erasure-coded (e.g., 6+3 Reed-Solomon, 1.5x overhead instead of 3x) on HDD. Attachment dedup by content hash is a massive win because forwarded and mass-mailed attachments dominate raw bytes.

Search over a decade of mail

The key structural decision is per-user index partitioning. A global index sharded by term would make every query hit every shard and make tenant isolation a filtering problem. Per-user partitions mean each query touches one small index (hundreds of MB), latency is naturally bounded, and a bug cannot leak results across users.

Indexing rides the delivery event stream: after metadata write, an event triggers tokenization and index update, so mail is searchable within seconds of arrival. Deletes and label changes are index updates too. Attachment text extraction is asynchronous and lower priority: the message is findable by subject and body immediately, and by attachment content minutes later.

Query-time features that users expect: operator parsing (from:, to:, has:attachment, before:/after:), phrase matching, and ranking that blends relevance with recency (recent mail dominates intent). Snippet generation highlights matched terms from the stored body. For inactive users, their index partitions can be compacted and moved to cold storage, then rehydrated on first search.

7Rapid implementation: build the MVP

Theory is table stakes. Here is how you would stand up a working version fast, on a budget, with the core algorithm in real code.

Stack

Node.js (smtp-server npm package) + Postgres with tsvector search + S3-compatible blob storage (Cloudflare R2 free tier) + Rspamd in Docker for spam scoring.

  1. 01Rent a cheap VPS with port 25 open (most clouds block it; Hetzner/OVH allow), set MX, SPF, DKIM, and DMARC DNS records for a test domain.
  2. 02Stand up an SMTP listener with the smtp-server package: validate RCPT TO against a users table, stream DATA to disk, respond 250 only after fsync.
  3. 03Parse stored raw mail with mailparser: extract headers, text/html bodies, and attachments; upload attachments to R2 keyed by sha256.
  4. 04Pipe each message through Rspamd over its HTTP API; store the score and route to INBOX or SPAM label accordingly.
  5. 05Create messages table with a generated tsvector column over subject and body, plus a GIN index; write the delivery insert.
  6. 06Build a minimal web UI (Next.js): thread list grouped by normalized subject + References header, message view with sanitized HTML (DOMPurify), search box hitting the tsvector query.
  7. 07Implement outbound send via nodemailer with DKIM signing, and a spam/not-spam button that relabels and logs the correction.
  8. 08Test end-to-end: send from a Gmail account, verify delivery, search, attachment download via signed URL, and reply back to Gmail.

Durable SMTP acceptance (ack only after persist)

typescript
import { SMTPServer } from "smtp-server";
import { simpleParser } from "mailparser";
import { deliver } from "./pipeline";

const server = new SMTPServer({
  secure: false, // STARTTLS configured via key/cert opts in prod
  onRcptTo(addr, session, cb) {
    // Reject unknown recipients BEFORE the body is transferred.
    userExists(addr.address).then((ok) =>
      ok ? cb() : cb(Object.assign(new Error("5.1.1 No such user"), { responseCode: 550 }))
    );
  },
  onData(stream, session, cb) {
    simpleParser(stream)
      .then((mail) => deliver(session.envelope, mail)) // persist + fsync/replicate
      .then(() => cb(null)) // 250 OK: we now own the message
      .catch(() => {
        // Temp-fail: the remote MTA will retry. Never lose silently.
        const err = Object.assign(new Error("4.3.0 Try again later"), { responseCode: 451 });
        cb(err);
      });
  },
});
server.listen(25);

Mailbox schema with built-in full-text search

sql
CREATE TABLE messages (
  user_id      BIGINT NOT NULL,
  message_id   UUID DEFAULT gen_random_uuid(),
  thread_key   TEXT NOT NULL,        -- normalized subject or References root
  from_addr    TEXT NOT NULL,
  subject      TEXT NOT NULL DEFAULT '',
  body_text    TEXT NOT NULL DEFAULT '',
  labels       TEXT[] NOT NULL DEFAULT ARRAY['INBOX'],
  spam_score   REAL NOT NULL DEFAULT 0,
  received_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  search_vec   TSVECTOR GENERATED ALWAYS AS (
    setweight(to_tsvector('english', coalesce(subject, '')), 'A') ||
    setweight(to_tsvector('english', coalesce(body_text, '')), 'B')
  ) STORED,
  PRIMARY KEY (user_id, message_id)
);

CREATE INDEX idx_inbox ON messages (user_id, received_at DESC);
CREATE INDEX idx_search ON messages USING GIN (search_vec);

-- Search query with ranking blended toward recency:
SELECT message_id, subject,
       ts_rank(search_vec, q) * exp(-extract(epoch from now() - received_at) / 8.64e6) AS score
FROM messages, websearch_to_tsquery('english', 'invoice from alice') q
WHERE user_id = 42 AND search_vec @@ q
ORDER BY score DESC LIMIT 20;

Content-addressed attachment dedup

typescript
import { createHash } from "crypto";
import { S3Client, PutObjectCommand, HeadObjectCommand } from "@aws-sdk/client-s3";

const s3 = new S3Client({ endpoint: process.env.R2_ENDPOINT, region: "auto" });
const BUCKET = "mail-attachments";

export async function storeAttachment(content: Buffer, contentType: string) {
  const hash = createHash("sha256").update(content).digest("hex");
  const key = hash.slice(0, 2) + "/" + hash; // prefix for key distribution
  try {
    await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: key }));
    return { key, deduped: true }; // identical bytes already stored
  } catch {
    await s3.send(new PutObjectCommand({
      Bucket: BUCKET,
      Key: key,
      Body: content,
      ContentType: contentType,
    }));
    return { key, deduped: false };
  }
}

Bottlenecks & failure modes

  • Ingestion spikes during spam storms: solved by the edge rejecting before DATA and the Kafka buffer absorbing bursts; the pipeline consumes at its own pace.
  • Small-object write amplification in mailbox storage: mitigated by batching bodies into append-only blocks and LSM-based metadata storage.
  • Search index write throughput: every accepted message is an index update; per-user partitioning plus batched segment merges keep this tractable.
  • Attachment bandwidth: large attachments dominate egress; signed direct-to-blob-storage URLs keep them off the application servers.
  • Spam classifier latency vs accuracy: the expensive ML stage must be reserved for the minority of mail that survives cheap stages, or the pipeline backs up.

Key takeaways

  • The SMTP 250 OK is a durability contract: replicate to a write-ahead queue before acknowledging, and use 4xx temp-fails to lean on sender retries when degraded.
  • Structure spam filtering as a funnel of increasingly expensive stages that each accumulate a score; reject cheaply at the edge, classify expensively only for survivors.
  • Separate mailbox metadata (wide-row store, range-readable by user and time) from bodies (compressed blobs, tiered and erasure-coded when cold).
  • Per-user search index partitions give bounded query latency and structural tenant isolation; index from the delivery event stream for freshness in seconds.
  • Content-addressed attachment storage dedupes the largest byte consumer in the system almost for free.

Brush up on the underlying topics