Email for the Owner and for a customer
The operator's own workspace and a customer's run the same mail code. One console sits over one policy, and each audience's router adds a route rather than a second implementation. What differs is the allowance a plan sets, the Google client an operator's connection falls back to, and the operator-only parts of the usage ledger. The mail behaviour itself does not differ.
One body, two doors#
Both audiences reach mail through the same console over the same policy module. The operator's own handlers were rewired onto that console rather than kept as a parallel version of the same thing, which is the difference between a product with two audiences and a product with two codebases that drift.
The rule this follows is worth stating because it is the reason the two stay level: new behaviour goes in the shared module, and a router adds only the route. A feature written into one audience's router is a feature the other will be missing, and nobody notices until somebody reports that something works on one account and not the other.
What the plan changes#
| Thing | Operator's own workspace | A customer's |
|---|---|---|
| The daily allowance | None — there is no plan to set one | Set by the plan; when it is spent, work is held rather than dropped |
| Google connection | Falls back to the platform's own Google client | Uses the customer's connection flow, asking the same question |
| Usage ledger detail | Visible, including per-call and per-run breakdowns | Usage and allowance are visible; the operator-level ledger is not |
| Mailboxes, roles, signatures | Identical | Identical |
| Autonomy, drafts, approvals | Identical | Identical |
| Send boundary and send evidence | Identical | Identical |
| Compliance: suppression, unsubscribe, do-not-contact | Identical | Identical |
| Rendering, sanitising, image proxying | Identical | Identical |
The list of identical rows is longer than the list of differences, and that is deliberate. An allowance is a commercial boundary; how a reply is written, held, approved, sent and evidenced is the product, and it would be a strange product that behaved differently depending on who was running it.
Two tables, one model#
The rows do still live in two tables, and that is a deliberate stopping point rather than unfinished work. The customer-side record gained the columns the operator-side one always had — role, signature, autonomy and the health verdicts — and now presents the transport and configuration that the provider builder reads, which is all it reads. From above, one shape.
Merging the two would mean moving live customer credentials for connected accounts, and nothing about the capability requires it. There is also a real difference between them: the customer-side table carries proper foreign keys to the workspace and to the platform user, which the operator-side one does not.
Where the asymmetry has actually bitten#
- A capability with no door
- The operator had no way to connect a Google account while every customer did. The action existed; nothing on screen called it. Adding a mailbox now asks the same question the customer's flow asks.
- A lookup that trusted an identifier
- Four mail routes accepted an identifier from a browser and fetched the row without scoping the query, which the kernel's filter can miss when the row is already loaded. They now go through the one scoped lookup. The only remaining exception is a provider delivery webhook, which has to resolve its own mailbox before any workspace is known.
- A row that was invisible rather than missing
- A record with an empty workspace stamp matches no scope and no security policy, so every list omitted it silently while every by-identifier action on it still worked. Two connected identities can disappear from a screen this way, with no duplicate warning and no error.
The third one has a repair rule worth knowing. Stamping such a row from an ordinary request is refused by the write policy, which would turn a missing row into a server error, so the repair belongs to the migration that runs at boot as the schema owner. The listing and the uniqueness check both ask the one query that can see these rows, read-only — because the alternative was reconnecting an account and quietly creating a second record for one real inbox. It is written up as invisible, not missing.
What keeps them apart#
Three independent layers, and they are independent on purpose: an allowlist in the middleware that decides which routes a customer session may reach at all, the workspace kernel that scopes every query the application makes, and row-level security in PostgreSQL, forced on every scoped table. A mistake has to pass all three to become a leak.
That is also why an operator-only surface is not merely hidden in the interface. Hiding a button is a presentation decision; refusing the route and refusing the row are the two that hold when somebody calls the endpoint directly. Security and isolation covers the layers in full.
Questions#
Does the operator's workspace get mail features first?
Not as a policy. Both audiences run the same console over the same policy, and the failures that produced this arrangement were features that reached one audience and not the other by accident — including a connection flow the operator could not reach at all.
Why keep two tables if the model is one?
Because merging them would move live credentials for connected accounts and buys nothing the capability needs. The customer-side table also has real foreign keys the older one lacks, so they are not simply two copies of the same thing.
Can a customer workspace see anything about another one?
No. Isolation is enforced at three independent layers — the route allowlist, the query kernel and row-level security in the database — and a record belongs to exactly one workspace. What a customer cannot see includes the operator-level ledger.