# From a provider message to a canonical one

Mail exists twice on purpose. `tenant_*` tables hold what the provider adapter fetched, in the provider's own shape; the canonical `threads`, `messages` and `contacts` are what the engine and every screen read. `connect_core.bridge_*` copies between them, in one direction, and the engine never reads the provider tables at all.

- **Status:** Available
- **Audience:** both, developer
- **Channels:** email
- **In the app:** #/inbox
- **Last verified:** 2026-09-10
- **Canonical:** https://connectbyjbrh.com/docs/email/message-canonicalisation/

## Two models, one direction

An adapter's output is shaped by its provider: Gmail's identifiers and labels, Graph's own fields, an IMAP server's UIDs and flags. Storing that faithfully is the right thing to do — it is the evidence of what actually arrived — and building a product on it would be the wrong thing, because every screen and every decision would then carry three shapes.

So the adapter writes `tenant_*` and stops. `connect_core.bridge_*` copies into the canonical records, and everything downstream reads only those. The flow is one-way: nothing after the bridge writes back into the provider tables, and nothing before it reads the canonical ones.

| Provider side | Canonical side |
|---|---|
| `tenant_*`, in the provider's shape | `threads`, `messages`, `contacts` |
| Written by the adapter | Written by `connect_core.bridge_*` |
| Read by the bridge | Read by the engine, every screen, the Assistant and the audit trail |
| Meaningful only alongside its provider | Provider-independent by construction |
| Discarded relevance once bridged | The workspace's actual history, outliving any connection |

## What the separation buys

1. **Adding a provider changes nothing downstream.** The canonical shape is the only thing anything after the bridge has ever seen, so a new adapter is an adapter rather than a migration.
2. **A bug in one adapter cannot corrupt conversation history.** It can corrupt its own table, which is recoverable, rather than the workspace's record of what was said to whom.
3. **Disconnecting a mailbox does not delete a workspace's past.** Canonical records were copied, not referenced — see [Disconnecting a mailbox](/docs/email/mailbox-disconnect/).
4. **One triage model, not three.** A person's own marks — `threads.priority`, `threads.starred`, `threads.deleted_at` — live on the canonical rows, so they mean the same thing whatever the message arrived through.

The cost is real and worth naming: the same message is stored twice, and the bridge is a place where things can fail. That trade was made deliberately, and the failure mode it buys — a bridge that stops — is loud and local, where the one it avoids is silent and spread across every screen.

## What the bridge produces

**A thread** — The conversation the message belongs to, matched or created — [Conversation threads](/docs/email/threads/).
**A message** — The canonical record: who, when, what, on which thread, through which mailbox.
**A contact** — The counterparty as a record, which is then resolved to a person and possibly a company — [Resolving a sender to a person](/docs/email/contact-resolution/).

Everything the workspace does afterwards hangs off those three. Triage reads them, the engine grounds a draft against them, memory and knowledge resolve against the people they name, and the audit trail refers to them. None of that machinery has a provider-specific branch, and that is the property being protected.

> **Note** Every canonical table carries `workspace_id` and is filtered three times over — by the `customer_safe` allowlist in the middleware, by the SQLAlchemy workspace kernel, and by PostgreSQL row-level security. [Three independent layers of tenant isolation](/research/three-layers-of-isolation/) explains why three.

## When something does not appear

A message that reached `tenant_*` but is not in Conversations has stopped at the bridge, and that is a different problem from a mailbox that fetched nothing. The distinguishing question is whether anything at all arrived in that window: nothing arriving is a [quiet mailbox](/docs/email/quiet-mailbox/) or a health problem; some things arriving and one not is a canonicalisation problem.

The cursor rule protects you in both cases. A position advances only for work that produced a canonical record, so a message the bridge rejected leaves the position where it was and is offered again rather than passed over — [IMAP UID and cursor lifecycle](/docs/email/imap-cursor/) sets out the outcomes.

## Questions

### Why store the same message twice?

Because the two copies answer different questions. The provider copy is evidence of exactly what arrived in the provider's own terms; the canonical copy is the workspace's record of the conversation. Collapsing them would make every screen and every decision provider-aware.

### Can the engine be pointed at the provider tables for speed?

That is the shortcut the design exists to refuse. The moment the engine reads a provider shape, adding a provider stops being free and a bug in one adapter stops being contained.

### Does the canonical record keep the original message?

The provider-side record is what holds the message as the provider expressed it. How the body is displayed to you is a separate concern handled by `mail_render.py` — [Rendering HTML email safely](/docs/email/html-email/).

## Related

- [Conversation threads](https://connectbyjbrh.com/docs/email/threads/)
- [Resolving a sender to a person](https://connectbyjbrh.com/docs/email/contact-resolution/)
- [Syncing an inbox](https://connectbyjbrh.com/docs/email/inbox-sync/)
- [Rendering HTML email safely](https://connectbyjbrh.com/docs/email/html-email/)
- [MIME](https://connectbyjbrh.com/docs/technology/mime/)
- [Three independent layers of tenant isolation](https://connectbyjbrh.com/research/three-layers-of-isolation/)

## What this page is based on

- Connect source pack — channels, §1: the two mail models; the engine never reads the provider tables
- Connect source pack — the data model and three layers of isolation (`docs-source/sources/GENERAL.md` §3, §2)
- Connect capability registry (`docs-source/facts.py`) — `rls_isolation`, `html_mail_render`
