Skip to content

feat(api): list the inboxes the caller may send from - #242

Open
Waiel5 wants to merge 3 commits into
choyiny:mainfrom
Waiel5:feat/own-inboxes
Open

feat(api): list the inboxes the caller may send from#242
Waiel5 wants to merge 3 commits into
choyiny:mainfrom
Waiel5:feat/own-inboxes

Conversation

@Waiel5

@Waiel5 Waiel5 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Stacked on #238 — the last commit here is the only new one; the scope-policy entry belongs with the route it governs, so it ships alongside rather than after. Review the tip commit.

The gap

GET /api/admin/inboxes is admin-only, and it was the only inbox list in the API.

A member therefore had no way to discover a fromAddress that POST /api/send would accept. A client could hardcode one, or build a From picker by guessing and let the user find out from a 403. Neither is a contract, and it makes composing from a non-admin account impossible to implement correctly — which is how I hit it, building a From picker in a native client.

The route

GET /api/inboxes returns the caller's own sending identities: every inbox for an admin, the assigned ones for a member.

It is built from the same universe the admin route builds — received recipients UNION configured sender identities — and narrowed with inboxScopeSql, the same resolver POST /api/send enforces with. That equivalence is the whole point of the route, so it is asserted directly rather than assumed: one test sends from a listed address and an unlisted one and checks the pair does not 403 / does 403. The two cannot drift into a picker that offers addresses which fail.

The response carries displayName, displayMode and signatureHtml — what a client needs to render and label a From row. It deliberately omits two fields the admin route returns:

  • forwardTo is a standing relay destination and admin configuration.
  • assignedUserIds discloses which other users hold an inbox.

Tests

10 tests, worker/src/__tests__/inboxes-router.test.ts.

The member path is set up explicitly in every scoping test. createTestUser defaults to role: "admin" and resolveAllowedInboxes short-circuits on isAdmin, so a suite that omits that setup asserts an unrestricted caller sees everything — green, and exercising none of the scoping.

The empty grant gets its own test. A member with no assignments must get [], and both plausible shortcuts fail: an inlined IN () is a SQLite syntax error (500), and skipping the filter when the list is empty scopes nothing and returns every inbox on the deployment. inboxScopeSql is the one place that renders it as a false predicate, which is why the query goes through it rather than a hand-rolled IN.

Also covered: case-insensitive matching against grants stored before insert-time canonicalization, the chat default for an inbox with no sender_identities row, 401 unauthenticated, and the scope classification.

Scope

Classified email:read. It discloses addresses the caller already controls and sends nothing, and a read-only client still needs it to label a message with the inbox that received it. Unclassified routes are denied outright by #238, so leaving it out would let an OAuth client read mail but never find a From address.

Verification

Test Files  3 passed (3)
     Tests  51 passed (51)

(inboxes-router, oauth-api-access, inbox-permission-enforcement.) yarn tsc --noEmit clean.

Waiel5 added 3 commits August 5, 2026 17:11
This deployment already issues OAuth access tokens, but only `/mcp` accepted
them. `/api/*` string-matched the `Bearer sk_` API-key prefix and rejected
anything else, so a client could complete authorization, be granted
`email:read`, and then find that the scope reached nothing. A third-party or
native client had no usable path to the API at all.

Tokens become a third credential alongside the session cookie and API keys.

**One resolver, both surfaces.** Access tokens verify offline against the
JWKS, so a signature proves only that this deployment minted the token at some
point; everything that can revoke a live one — disabling the client, deleting
or banning the user, removing their passkey — lives in the database and must be
re-checked per request. `/mcp` already did all of that. Rather than write it
twice, `resolveOAuthPrincipal` holds it and both callers use it, so the two
bearer surfaces cannot drift apart. `/mcp` keeps its narrower audience and its
`WWW-Authenticate` challenge; its handler loses about 2,700 characters of
inline validation and behaves identically.

**Scopes, not audience, are the boundary.** No `/api` audience is introduced.
`@better-auth/oauth-provider` at the pinned version is subject to
GHSA-p2fr-6hmx-4528, where the authorization-time resource is dropped and the
token endpoint will mint a token for another allowlisted audience — so a
distinct API audience would look like an authorization boundary without being
one, which is worse than not having it. Scopes do the work instead, checked per
route.

**The policy classifies on method plus exact path.** Three routes send mail
from under a router whose other routes do not — template send, sequence enroll
and outbox retry — so a prefix rule would file them as `email:manage` and let a
client that was never granted `email:send` send mail. Anything unclassified is
denied, so adding a route without classifying it breaks an integration rather
than quietly widening every existing token.

**Two things are closed to tokens outright.** The credential surface
(`/api/api-keys`, `/api/user/passkeys`, `/api/auth/*`), because minting an
unscoped API key would convert a narrow mail consent into the user's whole
account and destroy whatever key they already had. And a set of admin
operations that escalate the principal or open a standing channel: rewriting
inbox assignments (a token could grant itself every inbox, then read them),
changing a user's role, minting or listing invite tokens, revoking OAuth
clients, repointing the webhook, and setting an inbox's `forwardTo`. These are
a different risk from an admin doing the same in a browser — a token is held by
software, acts with no human present, and may be compromised without anyone
noticing. `admin:manage` is for operating the deployment, not for rewriting who
may operate it.

Session and API-key callers are untouched and remain unscoped; the scope
middleware returns immediately unless the request authenticated as `oauth`.

Also here because the same clients need them:

- `admin:manage`, never implied and required in addition to `role === "admin"`.
- `GET /api/user/me`, so a client can decide whether to offer admin screens
  without probing an admin route and reading the 403.
- `Authorization` in the CORS allow-list. Hono's default is empty, so a
  cross-origin client that preflighted a bearer request had the header stripped
  and the request blocked.
`GET /api/admin/inboxes` is admin-only, and it was the only inbox list.
A member therefore had no way to discover a `fromAddress` that
`POST /api/send` would accept — a client could hardcode one, or build a
picker by guessing and let the user find out from a 403. Neither is a
contract, and it makes composing from a non-admin account impossible to
implement correctly.

`GET /api/inboxes` returns the caller's own sending identities: every
inbox for an admin, the assigned ones for a member. It is built from the
same universe the admin route builds (received recipients plus configured
sender identities) and narrowed with `inboxScopeSql`, the same resolver
the send route enforces with. That equivalence is the point and is
asserted directly: a test sends from a listed address and an unlisted one
and checks the pair 200s and 403s respectively, so the two cannot drift
into a picker that offers addresses which fail.

The response carries display name, display mode and signature — what a
client needs to render and label a From row. It omits `forwardTo` and
`assignedUserIds`: the first is a standing relay destination and admin
configuration, the second discloses which other users hold an inbox.

Tests cover the member path explicitly. `createTestUser` defaults to
`role: "admin"` and `resolveAllowedInboxes` short-circuits for admins, so
a suite that omits that setup asserts an unrestricted caller sees
everything and passes without exercising the scoping at all. The empty
grant is covered too: a member with no assignments must get `[]`, and an
inlined `IN ()` is a SQLite syntax error, so the naive implementations
fail either loudly or open.

Classified `email:read` for OAuth callers. It discloses addresses the
caller already controls and sends nothing, and unclassified routes are
denied outright — leaving it unclassified would mean an OAuth client
could read mail but never find a From address.
`GET /api/inboxes` declares its response with `.openapi("Inbox")`, which
registers a fifth named component. This assertion pins the exact set, so
adding a named schema fails it by design — extending the list is the
intended maintenance, not a weakening of the check.

Naming it is deliberate: an inlined response shape is anonymous in /doc,
which is worse for the integrators the route exists to serve.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant