Task
A mailbox owner can today add/remove ACL members only by hand-calling POST/DELETE /api/v1/mailboxes/:id/acl/members. Add a Settings-page UI so the owner can see the current owner + members of a mailbox they own and add/remove members without touching the API. This is the follow-up #240 explicitly deferred ("UI for member management — API only; the Settings page can surface this in a follow-up"). There is no read endpoint for ACL membership today (workers/routes/acl-members.ts has only POST /, POST /members, DELETE /members/:email), so adding GET /api/v1/mailboxes/:id/acl returning { owner, members } (owner-only, 403 otherwise, documented response when unscoped) is part of this task — the UI needs to render current state.
Context
#27 introduced per-mailbox ACLs; #240 shipped the member add/remove API but deferred any UI; #241 added the per-row "Lock down". Granting a second user access still requires a raw API call. This is the documented-but-never-filed #240 follow-up.
Motivation
As a mailbox owner, I want to manage who can access my mailbox from Settings, so that I don't have to hand-craft API requests to add a teammate.
Pointers
workers/routes/acl-members.ts:25-90 — existing POST/DELETE handlers; add the GET here
workers/lib/mailbox-acl.ts:16-70 — MailboxAcl, readMailboxAcl, callerInAcl
workers/index.ts:145 — app.route("/api/v1/mailboxes/:mailboxId/acl", aclMemberRoutes); :141 requireMailbox mount
app/services/api.ts:143 — lockDownMailbox; add getMailboxAcl/addMember/removeMember alongside
app/queries/mailboxes.ts:63 — useLockDownMailbox mutation pattern to mirror
app/routes/settings.tsx:90,759 — SettingsRoute; where SecuritySettingsPanel mounts; add an Access/Members panel here
app/components/SecuritySettingsPanel.tsx — panel component pattern to follow
tests/lib/mailbox-acl.test.ts, tests/routes/mailboxes-acl-status.test.ts — in-memory R2 stub test patterns
Constraints
- Only the owner (
acl.owner) may read or write membership; any other CF-Access-admitted caller → 403 (same guard as existing /members handlers)
- Emails normalized to lower-case before write (consistent with existing
writeMailboxAcl callers)
- Owner cannot remove themselves; owner always in
members
- ACL blob is not a settings tier — do NOT route through
stripDefaultEqual (CLAUDE.md)
- Test mock URL host checks must use
new URL(url).hostname, not startsWith/includes (CLAUDE.md)
- On an unscoped mailbox the panel should prompt "Lock down" first (reuse existing flow), not show an empty member editor
Acceptance criteria
GET /api/v1/mailboxes/:id/acl as owner → { owner, members } 200; as non-owner → 403; on an unscoped mailbox → a documented response (e.g. 404 or { acl_status: "unscoped" }); all three covered by tests
- Settings page shows current owner + members for a mailbox the caller owns, with an add (email input) and per-member remove control wired to the existing endpoints
- Adding/removing a member updates the list without a full page reload; a non-owner viewing the page does not see the editor
- All new endpoint behavior covered by tests using the in-memory R2 stub pattern in
tests/lib/mailbox-acl.test.ts
Out of scope
Cross-references
Dependencies
Task
A mailbox owner can today add/remove ACL members only by hand-calling
POST/DELETE /api/v1/mailboxes/:id/acl/members. Add a Settings-page UI so the owner can see the current owner + members of a mailbox they own and add/remove members without touching the API. This is the follow-up #240 explicitly deferred ("UI for member management — API only; the Settings page can surface this in a follow-up"). There is no read endpoint for ACL membership today (workers/routes/acl-members.tshas onlyPOST /,POST /members,DELETE /members/:email), so addingGET /api/v1/mailboxes/:id/aclreturning{ owner, members }(owner-only, 403 otherwise, documented response when unscoped) is part of this task — the UI needs to render current state.Context
#27 introduced per-mailbox ACLs; #240 shipped the member add/remove API but deferred any UI; #241 added the per-row "Lock down". Granting a second user access still requires a raw API call. This is the documented-but-never-filed #240 follow-up.
Motivation
As a mailbox owner, I want to manage who can access my mailbox from Settings, so that I don't have to hand-craft API requests to add a teammate.
Pointers
workers/routes/acl-members.ts:25-90— existing POST/DELETE handlers; add the GET hereworkers/lib/mailbox-acl.ts:16-70—MailboxAcl,readMailboxAcl,callerInAclworkers/index.ts:145—app.route("/api/v1/mailboxes/:mailboxId/acl", aclMemberRoutes);:141requireMailboxmountapp/services/api.ts:143—lockDownMailbox; addgetMailboxAcl/addMember/removeMemberalongsideapp/queries/mailboxes.ts:63—useLockDownMailboxmutation pattern to mirrorapp/routes/settings.tsx:90,759—SettingsRoute; whereSecuritySettingsPanelmounts; add an Access/Members panel hereapp/components/SecuritySettingsPanel.tsx— panel component pattern to followtests/lib/mailbox-acl.test.ts,tests/routes/mailboxes-acl-status.test.ts— in-memory R2 stub test patternsConstraints
acl.owner) may read or write membership; any other CF-Access-admitted caller → 403 (same guard as existing/membershandlers)writeMailboxAclcallers)membersstripDefaultEqual(CLAUDE.md)new URL(url).hostname, notstartsWith/includes(CLAUDE.md)Acceptance criteria
GET /api/v1/mailboxes/:id/aclas owner →{ owner, members }200; as non-owner → 403; on an unscoped mailbox → a documented response (e.g. 404 or{ acl_status: "unscoped" }); all three covered by teststests/lib/mailbox-acl.test.tsOut of scope
Cross-references
Dependencies
GET /api/v1/mailboxes/:id/aclread endpoint. Add org-wide ACL access-overview (who can see which mailboxes) #292 (org-wide access-overview) generalizes that read to an org-level surface, so land this first; Add org-wide ACL access-overview (who can see which mailboxes) #292 should build on this endpoint rather than add a second, divergent ACL read path.