Email looks like a simple input, but in real automation it behaves like an unreliable event stream: retries, duplicates, encoding quirks, multi-part bodies, and inconsistent headers. If you are feeding inbound mail into an LLM agent, a QA harness, or a data pipeline, “clean emails” is not about pretty formatting. It is about deduplication, deterministic normalization, and storage that preserves provenance.
This guide lays out a practical pipeline design that turns inbound email into stable, queryable records you can trust, without brittle HTML scraping or “sleep(10)” guessing.
What “clean emails” means in a pipeline
For pipeline work, a “clean” email record typically has these properties:
- Stable identifiers: you can refer to “this message” consistently across webhook retries, polling loops, and reprocessing.
- Normalized structure: headers, addresses, bodies, and attachments are represented with predictable types and encodings.
- Clear lineage: you can always trace a derived field (like an OTP) back to the original message and the exact extraction logic.
- Idempotent ingestion: the pipeline can safely handle at-least-once delivery.
- Safe agent consumption: LLMs see a minimized, sanitized view, not raw HTML and untrusted headers.
A useful mental model is to treat email ingestion like clickstream or payment events: you want an append-only log, a canonical normalized representation, and derived tables for fast consumption.
Step 1: Deduplicate correctly (message, delivery, artifact)
Email pipelines often fail because teams dedupe at the wrong layer.
The three dedupe layers
Delivery dedupe (transport layer)
The same message can be delivered multiple times due to SMTP retries, greylisting, webhook retries, or polling races.
Message dedupe (content identity)
Two deliveries may represent the same logical message. You want one canonical record.
Artifact dedupe (what you actually need)
In verification flows, the “thing you act on” is often an OTP or magic link. You want to consume the artifact once, even if you received the message multiple times.
Dedupe keys that actually work
No single field is universally reliable. Use a tiered strategy and store all candidates.
| Layer | Goal | Good dedupe key candidates | Notes |
|---|---|---|---|
| Delivery | Don’t process the same delivery twice |
provider_delivery_id (if available), webhook event id, (inbox_id, message_id, delivered_at)
|
Webhooks are typically at-least-once, so you must assume duplicates. |
| Message | One row per logical email | RFC Message-ID (normalized), hash of raw source, (inbox_id, internal_message_id)
|
Message-ID can be missing or duplicated in the wild, keep a fallback. |
| Artifact | “Consume once” semantics | sha256(artifact_type + canonical_value + context) |
Best for OTP and verification URLs. |
A practical dedupe algorithm
Use a deterministic sequence:
-
If your provider gives a stable internal
message_id, use it as the primary key within an inbox. - Store the RFC
Message-ID(normalized) and a content hash (for example, a hash of the raw source or a canonicalized subset). - Upsert the canonical message record by primary key, and record each delivery event in a separate table.
- When extracting artifacts, compute an
artifact_hashand enforce a unique constraint to guarantee consume-once.
This is the backbone of idempotency: you accept duplicates, but your database state stays correct.
Step 2: Normalize email into a deterministic shape
Normalization is where most “pipeline pain” hides. The aim is not to perfectly model every corner of MIME, it is to create a stable contract for automation.
Normalize addresses without breaking edge cases
Common mistakes:
- Lowercasing the entire address, which can be incorrect for some local parts.
- Treating display names as trustworthy identifiers.
- Parsing addresses with regex.
Prefer a mail parsing library and normalize conservatively:
- Lowercase only the domain.
- Preserve the local part as received.
- Store both a parsed structure and the original string.
A pragmatic normalized address object looks like this:
| Field | Type | Example |
|---|---|---|
original |
string | "Jane Doe" <[email protected]> |
address |
string | [email protected] |
local |
string | Jane.Doe+qa |
domain |
string | example.com |
display_name |
string or null | Jane Doe |
Normalize headers with a trust model
Headers are attacker-controlled input. A good normalized representation:
- Preserves the raw header block (for forensic debugging).
- Produces a parsed map that handles folded lines and duplicate headers.
- Separates “high-trust for dedupe/trace” fields (like
Message-ID) from “low-trust UI fields” (likeSubject).
If you want a reference point for how messy raw email can be, skim the core format in RFC 5322.
Normalize bodies for automation (text-first)
For pipelines and LLM agents:
- Prefer
text/plainwhen available. - Store HTML, but do not make your automation depend on brittle HTML selectors.
- Consider producing a “safe text” field by stripping tracking pixels, collapsing whitespace, and limiting length.
Normalize timestamps with intent
Email contains multiple timestamps:
-
Date:header: sender-provided, can be wrong. - Transport timestamps: your provider’s receipt time, usually most reliable.
Store at least:
-
received_at(provider receipt time, canonical for ordering) -
date_header(optional, for display or diagnostics)
Normalize attachments as metadata + content pointer
Do not dump attachments into logs or agent prompts.
A storage-friendly model:
- Keep attachment metadata (filename, content-type, size).
- Store a hash for integrity.
- Store the bytes in object storage, referenced by a key.
Step 3: Store for reprocessing, not just for “the happy path”
A pipeline that cannot be replayed is a pipeline you cannot trust.
Recommended storage layers
Most teams end up with three layers:
- Raw: original RFC 5322 source (or provider raw payload), immutable.
- Normalized: canonical JSON representation used by downstream systems.
- Derived: artifacts extracted for specific workflows, like OTPs, verification URLs, ticket IDs.
That structure makes it easy to re-run normalization or extraction when templates change.
Minimal relational schema
Here is a practical baseline schema (works in Postgres, MySQL, etc.):
| Table | Purpose | Key columns |
|---|---|---|
email_messages |
One row per logical message |
pk, inbox_id, provider_message_id, rfc_message_id, received_at, normalized_json, raw_ref
|
email_deliveries |
Every delivery attempt / event |
pk, message_pk, delivered_at, source (webhook/poll), event_id
|
email_artifacts |
Consume-once derived records |
pk, message_pk, artifact_type, artifact_value, artifact_hash (unique), extracted_at
|
Two important operational notes:
- Put a unique constraint on
(inbox_id, provider_message_id)or your chosen message primary key. - Put a unique constraint on
artifact_hashfor consume-once semantics.
Retention and privacy by design
Email can contain secrets and personal data. Even in QA, teams accidentally route production-like content into test inboxes.
Treat retention as a first-class part of “clean emails”:
- Keep raw for the minimum window needed to debug and replay.
- Redact or tokenize sensitive fields in logs.
- Encrypt at rest if you store raw content.
Step 4: Ingest reliably (webhook-first, polling fallback)
Even “perfect normalization” fails if ingestion is flaky.
A robust pattern is:
- Webhook-first for low-latency eventing.
- Polling fallback for resilience when webhooks fail, queues back up, or your endpoint is down.
Webhook delivery is typically at-least-once, so your handler must be idempotent.
Webhook ingestion pseudocode (idempotent)
def handle_webhook(event):
verify_signature(event) # reject spoofed or replayed requests
msg = event["message"]
inbox_id = msg["inbox_id"]
provider_message_id = msg["id"] # provider-stable id (example)
# 1) Upsert message (idempotent)
message_pk = upsert_email_message(
inbox_id=inbox_id,
provider_message_id=provider_message_id,
rfc_message_id=normalize_message_id(msg.get("headers", {}).get("message-id")),
received_at=msg["received_at"],
normalized_json=msg,
raw_ref=msg.get("raw_ref"),
)
# 2) Record the delivery event (append-only)
insert_delivery_event(
message_pk=message_pk,
event_id=event["event_id"],
delivered_at=event["delivered_at"],
source="webhook",
)
# 3) Extract artifacts (consume-once)
for artifact in extract_artifacts(msg):
insert_artifact_if_new(message_pk, artifact)
This structure means webhook retries do not create duplicates, and extraction remains safe.
Batch processing for throughput
If you ingest high volume (CI fleets, many agent runs, or backfills), batch APIs matter. Batch ingestion lets you:
- Reduce per-message overhead.
- Apply consistent backpressure.
- Re-run extraction jobs over a slice of messages.
Step 5: Create an “LLM-safe view” of email
LLMs are powerful consumers, but email is untrusted input and a common prompt-injection vector. “Clean emails” for agents means providing a minimized contract.
A typical agent-facing view:
-
message_id,inbox_id,received_at -
from,to(parsed addresses) -
subject(optional) -
text(plain text only, length-capped) -
artifacts(already extracted OTPs or allowlisted URLs)
Keep raw HTML and full headers out of the agent context unless you have a very specific reason.

Where Mailhook fits in a clean email pipeline
If you are building automation around disposable inboxes (for LLM agents, QA, or verification flows), Mailhook provides primitives that map well to the pipeline above:
- Create disposable inboxes via API.
- Receive emails as structured JSON.
- Deliver events via real-time webhooks.
- Use polling as a fallback retrieval mechanism.
- Verify signed payloads for webhook security.
- Process messages in batches.
- Choose shared domains or bring a custom domain.
For exact request/response fields and the canonical integration contract, use Mailhook’s machine-readable reference: llms.txt. You can also start from the main site at Mailhook.
A final checklist for “clean emails”
If you implement only a few things, make them these:
- Deduplicate by design: separate delivery events from canonical messages, and enforce unique constraints.
- Normalize deterministically: conservative address normalization, parsed headers with duplicates handled, text-first bodies.
- Store for replay: keep raw (briefly) plus normalized JSON, and derive artifacts into a consume-once table.
- Assume at-least-once: webhook handlers must be idempotent.
- Keep agents on a leash: give LLMs a minimized email view and extracted artifacts, not raw HTML.
Clean emails are not just nicer data, they are the difference between pipelines you can scale and pipelines you babysit.