# Proving a mailbox belongs to a workspace

Every route that takes a mailbox identifier from a browser resolves it through one workspace-scoped lookup rather than reading the row directly. Before that change four of them — the connection form, sync, and the start and disconnect of a Google connection — would answer for any workspace's mailbox. One provider webhook is a deliberate exception, because it has to find its mailbox before any workspace is known.

- **Status:** Available
- **Audience:** both
- **Channels:** email
- **In the app:** #/mailboxes
- **Last verified:** 2026-09-10
- **Canonical:** https://connectbyjbrh.com/docs/security/mailbox-ownership/

## What went wrong, precisely

The four routes each did the natural thing: take the mailbox identifier out of the request, load that row, act on it. The workspace filter that guards every ordinary query was not bypassed on purpose — it was never consulted, because a load by primary key can be satisfied from the identity map of rows the current unit of work has already seen. A read that never becomes a query cannot be filtered by a query filter, and the database's row-level policy does not see it either.

The consequence was not an exposure of message content, which lives on scoped canonical rows and is read through scoped paths. It was narrower and quieter: the affected routes were the ones that change a mailbox — its connection settings, a sync, and the beginning and the end of a provider connection.

> **Careful** This class of fault is invisible in a single-workspace test, because with one workspace every row that matches an identifier is also the right row. It appears only when a second workspace exists and holds a row whose identifier the first can name.

## What the fix is

1. Resolve the workspace from the session first, before anything is read.
   - Result: There is now something to filter by, and a request with no workspace gets no further.
2. Ask the one mailbox lookup for that identifier, inside that workspace.
   - Result: It returns a row or it returns nothing. There is no third answer, and nothing is loaded that the workspace does not own.
3. Act on the row that came back, never on the identifier that came in.
   - Result: The action is bounded by what the lookup could see, so a stale or borrowed identifier changes nothing at all.

One lookup, used by both audiences, is the point. A second implementation for the operator's screens is how two halves drift until one of them misses a check the other has, so the Owner's handlers were rewired onto the shared path rather than kept as a parallel copy of it.

## The exception, and why it is one

A provider's delivery webhook arrives with no session and no workspace. It identifies its mailbox before there is any scope to resolve it in, so the ordering that protects everything else is not available to it. It is the only remaining caller that resolves a mailbox from outside a workspace, it is written down as such, and it is verified as a webhook instead. [Verifying an inbound webhook](/docs/security/webhook-verification/) covers what stands in for the missing scope.

> **Note** An exception that is named, counted and justified is a different object from an exception nobody has noticed. Writing this one down is what lets the next reader tell the difference without reading the code.

## Two neighbouring defects in the same area

1. **A row with an empty workspace stamp is invisible, not missing.** It matches no scope and no policy, so every list omitted it silently while by-identifier actions on it still worked. Two connected Google identities can disappear from the Mailboxes screen this way, with no duplicate warning, and a reconnection would have written a second row for one real inbox.
2. **The operator had no way to connect a Google mailbox** while every customer did: the capability existed with nothing on screen calling it. Adding a mailbox now asks both audiences the same question, over the same wizard.

The first of those is repaired by the schema owner at boot rather than by a request, because stamping the row from a request is refused by the policy's own write check. A repair attempted from the wrong side turns an invisible row into an error, which is a worse outcome than the silence it replaced.

## How to tell whether a screen is on the scoped path

**Two screens disagree about the same mailbox** — One is reading through the shared lookup and one is not. The disagreement itself is the signal.
**An action works on a mailbox that is not in the list** — The row exists, the list cannot see it, the by-identifier path can. That is the empty-stamp case rather than a permissions fault.
**A mailbox appears twice after reconnecting** — A uniqueness check that could not see the first row wrote a second one. Both the list and the check now ask the query that can see them.
**A route answers for a mailbox nobody in the workspace recognises** — That is the defect this page describes, and it is worth reporting immediately rather than working around.

## Questions

### Was message content exposed by the four routes?

Conversation content is read through scoped paths over canonical records, so the exposure was to mailbox configuration and connection actions rather than to mail itself. That is not a comfort — changing where a mailbox connects is a serious action — but it is the accurate description of what was reachable.

### Why not simply add a filter to each of the four routes?

Because the fifth route added later would have to remember. One lookup that every caller uses is a rule the code enforces; the same filter repeated in four places is a habit, and habits are exactly what a new route breaks.

### How is the remaining exception kept honest?

It is enumerated rather than assumed. The webhook resolves before any workspace exists, so it is verified as a webhook — signature and freshness — and everything it can do afterwards is bounded by the one mailbox it resolved.

## Related

- [Proving a phone number belongs to a workspace](https://connectbyjbrh.com/docs/security/number-ownership/)
- [Verifying an inbound webhook](https://connectbyjbrh.com/docs/security/webhook-verification/)
- [Workspace isolation](https://connectbyjbrh.com/docs/security/workspace-isolation/)
- [Data that should be here is not](https://connectbyjbrh.com/docs/troubleshooting/data-not-visible/)
- [Mailboxes](https://connectbyjbrh.com/docs/email/mailboxes/)

## What this page is based on

- Connect source pack section 1 — three mailbox defects worth documenting (`docs-source/sources/CHANNELS.md`)
- Connect source pack section 2 and 3 — the request flow and scoped tables (`docs-source/sources/GENERAL.md`)
- Connect capability registry (`docs-source/facts.py`) — `mailbox_roles`, `mailbox_health`, `rls_isolation`
