# Inbound email, end to end

A message is fetched by the mailbox's provider adapter into the `tenant_*` tables, copied into the canonical `threads` and `messages` by the bridge, attached to a person, given a priority, and only then read by the engine. The read cursor moves after the copy has been written, never before, which is why an allowance refusal holds work instead of losing it.

- **Status:** Available
- **Audience:** both
- **Channels:** email
- **In the app:** #/inbox, #/needs-you, #/mailboxes
- **Last verified:** 2026-09-10
- **Canonical:** https://connectbyjbrh.com/docs/workflows/inbound-email/

## The chain for one message

1. Trigger — the sync tick for a connected mailbox, or a person pressing Sync on the Mailboxes screen.
2. External event — the message is already sitting at Gmail, Microsoft Graph or an IMAP server; nothing has been pushed to Connect.
3. Authentication and workspace resolution — the mailbox row is fetched through `mailbox_console.row_for`, inside the owning workspace, before any credential or setting is read.
4. Ingest — the provider adapter fetches new mail from the stored cursor and writes it to the `tenant_*` tables in the provider's own shape.
5. Canonical record — `connect_core.bridge_*` copies each message into `threads`, `messages` and `contacts`, which is the only shape the engine and every screen read.
6. Classification — the thread is matched to an existing conversation or opened as a new one, the sender is resolved to a person, and `threads.priority` is set.
7. Knowledge, memory and rules — memory resolves narrowest-first across workspace, channel, endpoint and contact; `directives()` reads the tag list, so a `block:email` tag stops the thread here.
8. Autonomy and approval — the mailbox's autonomy mode decides whether a reply may be drafted, held, or not attempted at all.
9. Action — a draft is written, sent, or queued for a person, on the paths described in [drafting and approving a reply](/docs/workflows/email-reply-approval/).
10. Result — the conversation appears on Conversations with its priority, its people and its full body.
11. Relationship, timeline and memory — the message joins the person's timeline and their Customer 360; anything worth keeping is written to memory rather than left in the thread.
12. Audit, usage and Needs You — the fetch is metered against the daily allowance, the decision is recorded, and a thread that needs a person is ranked into Needs You.

> **Note** Stage three is not a formality. Every setting on this path — the autonomy mode, the signature, the cursor, the credentials — belongs to a workspace, and reading one before entering that workspace reads a default or nothing at all. The workspace is resolved first, every time.

## The same chain as a table

| Stage | What you see | What changes | What can fail |
|---|---|---|---|
| Fetch | The mailbox row shows a fresh last-sync time | Rows in `tenant_*`; the cursor is still where it was | An expired token, a revoked consent, or an IMAP server that refuses the connection |
| Bridge | Nothing yet — the copy is quick and silent | `threads`, `messages` and `contacts` gain rows | A malformed message; the copy stops there and the cursor does not pass it |
| Cursor advance | The mailbox stops re-reading old mail | The stored read position moves past what was written | Nothing, by design: the cursor is written last, so a crash re-reads instead of skipping |
| Threading | One conversation instead of several | The message joins an existing `threads` row or opens one | A missing or rewritten reference header, which splits the thread |
| Contact resolution | A name and a history beside the message | An identity is attached to a person, or a person is created | An address the workspace has never seen is a new person, correctly |
| Triage | A priority, and the thread's place in the queue | `threads.priority`, which the engine reads when it picks work | A misjudged priority — correcting it changes what Connect does next, not just the sort order |
| Engine | A draft, a sent reply, or an item in Needs You | A draft row, an outbound message, or an approval | Autonomy set to `off`, a block directive, or the daily allowance already spent |

## Why the same message exists twice

Mail is stored twice on purpose. The `tenant_*` tables hold exactly what the provider adapter fetched, in the provider's own shape — Gmail's history records look nothing like an IMAP fetch, and neither looks like Microsoft Graph. The canonical `threads`, `messages` and `contacts` are one shape, and they are what the engine, the screens and the Assistant read. `connect_core.bridge_*` is the only thing that copies between them.

Two consequences follow, and both matter to a person watching mail arrive. Adding a provider changes nothing downstream, because nothing downstream has ever read a provider's shape. And a bug in one adapter cannot corrupt a workspace's conversation history, because the adapter writes to a table the engine never opens.

It also explains a symptom that otherwise makes no sense: mail that is visibly in the provider and visibly absent from Conversations. That is a fetch that ran and a bridge that did not, and it is checked in a fixed order on [mail is in the provider but not in Connect](/docs/troubleshooting/mail-not-arriving/).

## What the engine reads before it decides

By the time the engine looks at a thread, the earlier stages have already put their work on the record, and the engine reads records rather than re-deriving anything:

- `threads.priority` — set at triage and editable by a person. Because the engine reads it when it picks work, correcting a priority changes behaviour rather than re-sorting a list.
- Memory at four tiers, resolved narrowest-first: workspace, then channel, then endpoint (this mailbox), then contact. An empty tier is returned as empty rather than dropped, so 'nothing is set here' is a visible answer.
- Directives from the tag list — a contact tagged `block:email` is not replied to on this channel, and the block holds on future conversations because it lives on the contact, not on the thread.
- Knowledge the workspace supplied, so an answer is grounded rather than improvised.
- The autonomy mode that applies to this mailbox, at the narrowest scope that has a rule.

## Where a message can be lost, and what stops it

**The allowance is spent mid-fetch** — Work is held rather than dropped, and the read cursor deliberately does not advance. Advancing a cursor on a refusal is how a mail agent loses messages permanently: the provider is next asked for mail after a point that was never processed. Needs You shows the refusal.
**The process stops between bridge and cursor** — The next tick re-reads the same messages. Duplicates are resolved on the canonical side by message identity, which is the cheaper failure to have.
**The mailbox authenticates and returns nothing** — That is a quiet mailbox — a health signal, not an error, and worth investigating, because a mailbox that has gone silent looks exactly like one that is working.
**A row carries an empty workspace stamp** — An empty stamp matches no scope and no policy, so the mailbox is invisible on the Mailboxes screen while every by-id action on it still works. Repair belongs to the boot-time migration, not to a request.

> **Careful** A fetch that reports success is not proof that a message arrived. The evidence a message is present is the canonical thread on Conversations, not a green mailbox row.

## Questions

### How soon after a message arrives does Connect see it?

On the next sync for that mailbox, or immediately if somebody presses Sync. No provider pushes mail into Connect, so the interval between ticks is the floor on how fresh Conversations can be.

### Does Connect delete anything from my mailbox?

No. The fetch reads. The only thing written back to Gmail is read state and labels, and that write-back is deliberately allowed to fail without affecting the reply — see [writing back to Gmail](/docs/workflows/gmail-writeback-flow/).

### Why did one message open a new conversation instead of joining the old one?

Threading follows the reference headers the sender's client wrote. Some clients and some mailing lists rewrite them, which splits a conversation in two. [The same conversation appears twice](/docs/troubleshooting/duplicate-threads/) covers what merges them.

## Related

- [Syncing an inbox](https://connectbyjbrh.com/docs/email/inbox-sync/)
- [From a provider message to a canonical one](https://connectbyjbrh.com/docs/email/message-canonicalisation/)
- [Drafting and approving a reply, end to end](https://connectbyjbrh.com/docs/workflows/email-reply-approval/)
- [Hitting the daily allowance, end to end](https://connectbyjbrh.com/docs/workflows/quota-refusal/)
- [Mail is in the provider but not in Connect](https://connectbyjbrh.com/docs/troubleshooting/mail-not-arriving/)
- [Triage and priority](https://connectbyjbrh.com/docs/email/triage/)

## What this page is based on

- Connect source pack — channels, §1 email: the two mail models, the cursor and the allowance (`docs-source/sources/CHANNELS.md`)
- Connect source pack — architecture: the record model, memory tiers, triage on canonical rows (`docs-source/sources/GENERAL.md`)
- Connect capability registry (`docs-source/facts.py`) — `gmail_history_sync`, `imap_smtp`, `daily_quota_refusal`
