}
diff --git a/admin/src/lib/api/models/admin.ts b/admin/src/lib/api/models/admin.ts
index 2c6648904..e1912ef89 100644
--- a/admin/src/lib/api/models/admin.ts
+++ b/admin/src/lib/api/models/admin.ts
@@ -190,6 +190,7 @@ export interface AdminMailboxesResult {
}
export interface AdminMailboxSearch {
+ mailbox_id?: string;
q?: string;
status?: string;
provider?: string;
diff --git a/docs/content/docs/api/authentication.mdx b/docs/content/docs/api/authentication.mdx
index f01f8d8ac..76b5201cf 100644
--- a/docs/content/docs/api/authentication.mdx
+++ b/docs/content/docs/api/authentication.mdx
@@ -232,9 +232,10 @@ Returned when:
This backend flow is separate from workspace API-key authorization. Platform
admin access uses a normal bearer session, never an organization API key.
-The admin UI and CLI implement this contract in a separate rollout stage;
-these endpoints alone do not provide a consent page or CLI command. The CLI
-never listens on an HTTP port or receives a browser redirect.
+Use [remote `warmblyctl` login](/development/warmblyctl/#remote-administrator-commands)
+and the administrator website's `/device` consent page. Deploy the backend
+before upgrading the admin UI and CLI. The CLI never listens on an HTTP port
+or receives a browser redirect.
### Endpoint contract
@@ -298,8 +299,8 @@ and request. A different session cannot spend it. If decide returns
again; never auto-approve when login/reauth returns.
Poll no faster than every five seconds. Pending/denied responses contain
-`status: "pending"` or `"denied"`, no `token`; the existing UUID serializer may
-include a zero `user_id`, which is not an identity. An approving poll returns:
+`status: "pending"` or `"denied"`, with neither `token` nor `user_id`.
+An approving poll returns:
```json
{
diff --git a/docs/content/docs/api/endpoints.mdx b/docs/content/docs/api/endpoints.mdx
index fd6a3ddb6..ec7696a5b 100644
--- a/docs/content/docs/api/endpoints.mdx
+++ b/docs/content/docs/api/endpoints.mdx
@@ -623,6 +623,7 @@ Alongside them, `api_url` is this API's own public base (for a copyable example
- `GET /admin/warmup-content/overview` includes effective generation controls and requires `view_warmup_pool`; `PUT /admin/warmup-content/settings` writes effective controls with `manage_settings`. These are platform-admin routes, not organization API-key scopes. See [effective generation policy and stopping](/development/warmup-generation/).
- `POST /admin/organizations/:id/sample-data`: `manage_testers` and an MFA-verified admin session. Adds the [reviewer sample dataset](/development/accounts-and-access/#adding-sample-data) only to an active dedicated Test workspace. No request body. Returns `200` with `organization_id`, `created` (false when already seeded) and `seeded_at`; retries preserve edits and deletions. An ordinary workspace, revoked or expired tester, or missing/expired managed Test grant returns `400`. Inserts and the admin audit marker commit together. No mailbox connection, email delivery, external request or credit spend occurs.
- `GET /admin/testers`: `view_users` and an MFA-verified admin session. Each tester in `data` may include `test_workspace_id` for its owned dedicated Test workspace and `sample_data_seeded_at` when populated. These fields are absent for a tester merely attached to an ordinary workspace.
+ - `GET /admin/mailboxes`: `view_users` and an MFA-verified admin session. Optional `mailbox_id` filters by exact UUID; `status=all` includes inactive mailboxes. Node evidence links use `/mailboxes?mailbox_id=UUID`, regardless of loaded worker assignments or earlier browse filters. Deleted identities return an empty result, not a substitute mailbox.
`POST /subscription/checkout`, `POST /subscription/portal`, `POST /subscription/cancel`, `POST /subscription/change-plan` and `GET /subscription/preview-change` require the `MANAGE_BILLING` organization permission; reading the subscription, its limits, trial and features needs only membership. `POST /subscription/portal` requires an existing Stripe billing customer. A workspace without one receives `400` with code `bad_request` and a message to complete checkout first; free and operator-granted plans alone do not create a billing customer.
diff --git a/internal/models/admin.go b/internal/models/admin.go
index 09045a903..a7a494bfd 100644
--- a/internal/models/admin.go
+++ b/internal/models/admin.go
@@ -435,6 +435,7 @@ type AdminMailboxesResult struct {
// All fields optional; empty status = active, "all" returns every
// status. provider lets ops filter to "all gmail mailboxes" quickly.
type AdminMailboxSearch struct {
+ MailboxID *ParamUUID `form:"mailbox_id"`
Query string `form:"q"`
Status string `form:"status"`
Provider string `form:"provider"`
diff --git a/internal/models/param_uuid_test.go b/internal/models/param_uuid_test.go
index 5699fa319..1607b291b 100644
--- a/internal/models/param_uuid_test.go
+++ b/internal/models/param_uuid_test.go
@@ -38,6 +38,20 @@ func TestParamUUIDRejectsGarbage(t *testing.T) {
}
}
+func TestAdminMailboxExactIdentityBindsAndRejectsInvalidUUID(t *testing.T) {
+ id := uuid.New()
+ var search AdminMailboxSearch
+ if err := bindQuery(t, "mailbox_id="+id.String()+"&status=all", &search); err != nil {
+ t.Fatal(err)
+ }
+ if search.MailboxID == nil || search.MailboxID.UUID != id || search.Status != "all" || search.WorkerID != nil {
+ t.Fatalf("exact mailbox lookup changed identity or placement scope: %+v", search)
+ }
+ if err := bindQuery(t, "mailbox_id=not-a-uuid", &AdminMailboxSearch{}); err == nil {
+ t.Fatal("invalid exact mailbox identity accepted")
+ }
+}
+
// Every admin search struct must bind its ids and cursor together, so a field
// typed as a bare uuid.UUID cannot 400 an explorer again.
func TestAdminSearchStructsBindIDsAndCursor(t *testing.T) {
diff --git a/internal/repository/pg_admin.go b/internal/repository/pg_admin.go
index abdb8b66f..ef4833a1e 100644
--- a/internal/repository/pg_admin.go
+++ b/internal/repository/pg_admin.go
@@ -1913,6 +1913,11 @@ func (r *adminRepository) SearchMailboxesForAdmin(ctx context.Context, search *m
args := []interface{}{}
argNum := 1
where := "WHERE 1=1"
+ if search.MailboxID != nil {
+ where += " AND ea.id = $" + itoa(argNum)
+ args = append(args, search.MailboxID.UUID)
+ argNum++
+ }
if search.Query != "" {
where += ` AND (ea.email ILIKE $` + itoa(argNum) +
diff --git a/qa/flows/admin-operations.flow.ts b/qa/flows/admin-operations.flow.ts
index b14c2ff61..0b1024121 100644
--- a/qa/flows/admin-operations.flow.ts
+++ b/qa/flows/admin-operations.flow.ts
@@ -122,7 +122,7 @@ test("admin CLI consent and redacted node diagnostics with disclosed local respo
await expect(logs.getByRole("table", { name: "Redacted node events", exact: true })).toBeVisible();
await expect(logs.getByText("Control-plane sync held", { exact: true })).toBeVisible();
await expect(logs).toContainText("HTTP 503"); await expect(logs).toContainText("Partial coverage"); await expect(logs).toContainText("per process run");
- await expect(logs.getByRole("link", { name: mailbox, exact: true })).toHaveAttribute("href", new RegExp("/mailboxes\\?q=fixture-mailbox%40example.test"));
+ await expect(logs.getByRole("link", { name: mailbox, exact: true })).toHaveAttribute("href", `/mailboxes?mailbox_id=${mailbox}`);
await proof.chapter("Redacted operational evidence", "All node capture/log/offset responses are synthetic localhost fixtures. They do not establish production sampling, provider health, delivery or the cause of an arrival 503.");
await logs.getByRole("table", { name: "Redacted node events", exact: true }).scrollIntoViewIfNeeded();
await proof.shot("admin-node-redacted-evidence", { caption: "Synthetic capture and allowlisted events: timestamps, partial coverage, retention bounds, three known per-run drops, numeric control-plane HTTP 503 and native mailbox link." });