From 6aef425fcf272040b9cb5b84bae333d73d1b9eae Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:43:21 -0400 Subject: [PATCH 1/3] feat: support inbox-scoped AgentMail API keys An inbox-scoped key passes every inbox check and is then refused on the organization-level webhook endpoints, which read as a permissions problem even for a full-access key. The save now falls back to the inbox's own webhook endpoints on that refusal, records the detected scope so status and disconnect address the same endpoints, and the error names the exact request AgentMail refused when the fallback does not apply. Also fixes the doubled "(optional)" on the Pod ID field and teaches the mock AgentMail server the inbox-scoped webhook routes. --- apps/docs/environment-variables.mdx | 1 + .../providers/communications/agentmail.mdx | 10 +- .../settings/CommsProviderSection.tsx | 8 + .../web/src/trpc/commands/comms/index.test.ts | 135 +++++++++++ apps/web/src/trpc/commands/comms/index.ts | 141 ++++++++++-- .../__tests__/agentmail-api-client.test.ts | 47 ++++ .../__tests__/mock-agentmail-server.test.ts | 72 ++++++ .../communication/src/agentmail-provider.ts | 45 +++- .../src/mock-agentmail-server.ts | 217 +++++++++++------- .../src/lib/agentmail-runtime-credentials.ts | 18 ++ packages/env/src/index.ts | 3 + packages/types/src/control-plane-env-vars.ts | 1 + 12 files changed, 586 insertions(+), 112 deletions(-) diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index dbeddba413..2f6515bbc6 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -366,6 +366,7 @@ as per-task auth tokens or workspace paths. | `R_AGENTMAIL_WEBHOOK_SECRET` | Optional | AgentMail webhook secret. Overrides the value managed by the settings UI. | | `R_AGENTMAIL_INBOX_ID` | Optional | AgentMail deployment inbox. Overrides the inbox connected in the settings UI. | | `R_AGENTMAIL_POD_ID` | Optional | AgentMail pod the inbox and webhook live in; required with a pod-scoped API key. Overrides the pod entered in the settings UI. | +| `R_AGENTMAIL_KEY_SCOPE` | Optional | `inbox` when the AgentMail API key is inbox-scoped, so the webhook is managed on the inbox. Detected and saved by the settings UI; set it only for env-var-only setups. | | `AGENTMAIL_API_BASE_URL` | Optional | AgentMail API base URL override, primarily for testing. Defaults to `https://api.agentmail.to`. | | `R_MICROSOFT_CLIENT_ID` | Microsoft sign-in | Microsoft OAuth client ID. | | `R_MICROSOFT_CLIENT_SECRET` | Microsoft sign-in | Microsoft OAuth client secret. | diff --git a/apps/docs/providers/communications/agentmail.mdx b/apps/docs/providers/communications/agentmail.mdx index d935adbd57..83d433fb31 100644 --- a/apps/docs/providers/communications/agentmail.mdx +++ b/apps/docs/providers/communications/agentmail.mdx @@ -35,9 +35,17 @@ The API key must carry these AgentMail permissions (or be a full-access key): `inbox_read`, `inbox_create`, `inbox_update`, `webhook_read`, `webhook_create`, `webhook_update`, `webhook_delete`, `message_read`, and `message_send`. Missing permissions fail at save time with an error naming -the refused step, except `message_send`, which has no side-effect-free +the refused request, except `message_send`, which has no side-effect-free check and is exercised on the first reply. +The key can be an organization-level key or an inbox-scoped key. An +inbox-scoped key (created from inside the inbox in the AgentMail console) is +the least-privilege choice for a one-inbox deployment: Roomote detects it on +save and registers the webhook on the inbox itself instead of at the +organization level. Enter that inbox's address, or leave the field blank +when it is the only inbox the key can see. A pod-scoped key needs the pod id +(see [Pods and pod-scoped keys](#pods-and-pod-scoped-keys)). + In the Roomote UI (**Settings > Communications > Email (AgentMail)**), paste the API key, then save. Roomote proposes a deployment inbox address such as `roomote-yourhost-a1b2c3@agentmail.to`; edit the address before creation, or diff --git a/apps/web/src/components/settings/CommsProviderSection.tsx b/apps/web/src/components/settings/CommsProviderSection.tsx index c7f4fcf956..aab95b5c46 100644 --- a/apps/web/src/components/settings/CommsProviderSection.tsx +++ b/apps/web/src/components/settings/CommsProviderSection.tsx @@ -310,6 +310,14 @@ function AgentMailSetupStatus({

) : null} + {status.keyScope === 'inbox' ? ( +
+ +

+ Inbox-scoped API key: the webhook is registered on the inbox. +

+
+ ) : null} {status.inboxAddress ? (
diff --git a/apps/web/src/trpc/commands/comms/index.test.ts b/apps/web/src/trpc/commands/comms/index.test.ts index 619de99dd4..ee912b34fb 100644 --- a/apps/web/src/trpc/commands/comms/index.test.ts +++ b/apps/web/src/trpc/commands/comms/index.test.ts @@ -47,6 +47,7 @@ const { webhookSecret: null as string | null, inboxId: null as string | null, podId: null as string | null, + keyScope: 'organization' as 'organization' | 'inbox', })), mockAgentMailClientConstructor: vi.fn(), mockAgentMailListInboxes: vi.fn(), @@ -324,6 +325,7 @@ describe('comms commands', () => { webhookSecret: null, inboxId: null, podId: null, + keyScope: 'organization', }); mockAgentMailListInboxes.mockResolvedValue({ inboxes: [] }); mockAgentMailListWebhooks.mockResolvedValue({ webhooks: [] }); @@ -1039,6 +1041,130 @@ describe('comms commands', () => { ); }); + it('names the refused webhook request and the scoped-key cause on a webhook 403', async () => { + mockAgentMailListInboxes.mockResolvedValue({ + inboxes: [{ inbox_id: 'roomote@roomote.me' }], + }); + // An inbox- or pod-scoped key passes the inbox checks but cannot + // reach the organization-level webhook endpoints. + mockAgentMailListWebhooks.mockRejectedValue( + new AgentMailApiError( + 'AgentMail GET /v0/webhooks failed (403): {"message":"Forbidden"}', + 403, + ), + ); + + await expect( + saveCommsAuthConfigCommand(buildMockAuth(), { + provider: 'agentmail', + values: { R_AGENTMAIL_API_KEY: 'am-scoped-key' }, + }), + ).rejects.toThrow( + /refused permission while configuring the webhook \(403 Forbidden\)\. Request: GET \/v0\/webhooks \(\{"message":"Forbidden"\}\)\. A key scoped to a pod .* AgentMail Pod ID/, + ); + expect(mockUpsertDeploymentEnvironmentVariables).not.toHaveBeenCalled(); + }); + + it('falls back to the inbox-scoped webhook endpoints for an inbox-scoped key and records the scope', async () => { + mockAgentMailListInboxes.mockResolvedValue({ + inboxes: [{ inbox_id: 'roomote@roomote.me' }], + }); + // The organization-level listing is refused; the same key succeeds + // against the inbox's own webhook endpoints. + mockAgentMailListWebhooks + .mockRejectedValueOnce( + new AgentMailApiError( + 'AgentMail GET /v0/webhooks failed (403): {"message":"Forbidden"}', + 403, + ), + ) + .mockResolvedValueOnce({ webhooks: [] }); + mockAgentMailCreateWebhook.mockResolvedValue({ + webhook_id: 'wh-inbox', + url: expectedWebhookUrl, + secret: 'whsec_inbox', + inbox_ids: ['roomote@roomote.me'], + }); + + await expect( + saveCommsAuthConfigCommand(buildMockAuth(), { + provider: 'agentmail', + values: { R_AGENTMAIL_API_KEY: 'am-inbox-key' }, + }), + ).resolves.toMatchObject({ + agentmail: { keyScope: 'inbox', inboxAddress: 'roomote@roomote.me' }, + }); + + expect(mockAgentMailClientConstructor).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: 'am-inbox-key', + webhookInboxId: 'roomote@roomote.me', + }), + ); + expect(mockUpsertDeploymentEnvironmentVariables).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + values: expect.arrayContaining([ + { name: 'R_AGENTMAIL_KEY_SCOPE', value: 'inbox' }, + { name: 'R_AGENTMAIL_WEBHOOK_SECRET', value: 'whsec_inbox' }, + ]), + }), + ); + }); + + it('addresses the inbox webhook endpoints directly once an inbox-scoped key is recorded', async () => { + mockResolveAgentMailRuntimeCredentials.mockResolvedValue({ + apiKey: 'am-inbox-key', + webhookSecret: 'whsec_inbox', + inboxId: 'roomote@roomote.me', + podId: null, + keyScope: 'inbox', + }); + mockGetPersistedEnvironmentVariableNames.mockResolvedValue([ + 'R_AGENTMAIL_API_KEY', + 'R_AGENTMAIL_INBOX_ID', + 'R_AGENTMAIL_WEBHOOK_SECRET', + 'R_AGENTMAIL_KEY_SCOPE', + ]); + mockAgentMailGetInbox.mockResolvedValue({ + inbox_id: 'roomote@roomote.me', + }); + mockAgentMailListWebhooks.mockResolvedValue({ + webhooks: [ + { + webhook_id: 'wh-inbox', + url: expectedWebhookUrl, + client_id: `roomote-agentmail-webhook-${hostHash}`, + inbox_ids: ['roomote@roomote.me'], + event_types: [ + 'message.received', + 'message.bounced', + 'message.complained', + ], + }, + ], + }); + + await saveCommsAuthConfigCommand(buildMockAuth(), { + provider: 'agentmail', + values: { R_AGENTMAIL_INBOX_ID: 'roomote@roomote.me' }, + }); + + // No organization-level probe, so no 403 round trip: one client, inbox + // scoped from the start. + expect(mockAgentMailListWebhooks).toHaveBeenCalledOnce(); + expect(mockAgentMailClientConstructor).toHaveBeenLastCalledWith( + expect.objectContaining({ webhookInboxId: 'roomote@roomote.me' }), + ); + expect(mockAgentMailUpdateWebhook).not.toHaveBeenCalled(); + expect(mockAgentMailCreateWebhook).not.toHaveBeenCalled(); + + const status = await getCommsStatusCommand(buildMockAuth()); + const agentmail = status.providers.find((p) => p.id === 'agentmail'); + expect(agentmail?.agentmail?.keyScope).toBe('inbox'); + expect(agentmail?.agentmail?.webhook.status).toBe('connected'); + }); + it('rejects a bad API key with clear copy and persists nothing', async () => { mockAgentMailListInboxes.mockRejectedValue( new Error('AgentMail GET /v0/inboxes failed (401): Unauthorized'), @@ -1111,6 +1237,7 @@ describe('comms commands', () => { webhookSecret: 'whsec_existing', inboxId: 'support@agentmail.to', podId: null, + keyScope: 'organization', }); mockAgentMailCreateWebhook.mockResolvedValue({ webhook_id: 'wh-2', @@ -1166,6 +1293,7 @@ describe('comms commands', () => { webhookSecret: 'whsec_existing', inboxId: 'old-inbox@agentmail.to', podId: null, + keyScope: 'organization', }); mockGetPersistedEnvironmentVariableNames.mockResolvedValue([ 'R_AGENTMAIL_API_KEY', @@ -1281,6 +1409,7 @@ describe('comms commands', () => { webhookSecret: 'whsec_existing', inboxId: 'support@agentmail.to', podId: 'pod_old', + keyScope: 'organization', }); mockGetPersistedEnvironmentVariableNames.mockResolvedValue([ 'R_AGENTMAIL_API_KEY', @@ -1328,6 +1457,7 @@ describe('comms commands', () => { webhookSecret: 'whsec_existing', inboxId: 'support@agentmail.to', podId: null, + keyScope: 'organization', }); mockGetPersistedEnvironmentVariableNames.mockResolvedValue([ 'R_AGENTMAIL_API_KEY', @@ -1506,6 +1636,7 @@ describe('comms commands', () => { webhookSecret: null, inboxId: null, podId: null, + keyScope: 'organization', }); mockAgentMailListInboxes.mockResolvedValue({ inboxes: [ @@ -1537,6 +1668,7 @@ describe('comms commands', () => { webhookSecret: null, inboxId: null, podId: null, + keyScope: 'organization', }); mockAgentMailListInboxes.mockResolvedValue({ inboxes: [{ inbox_id: 'existing@agentmail.to' }], @@ -1596,6 +1728,7 @@ describe('comms commands', () => { webhookSecret: 'whsec_existing', inboxId: 'support@agentmail.to', podId: null, + keyScope: 'organization', }); mockAgentMailListWebhooks.mockResolvedValue({ webhooks: [ @@ -1634,6 +1767,7 @@ describe('comms commands', () => { webhookSecret: 'whsec_existing', inboxId: 'support@agentmail.to', podId: null, + keyScope: 'organization', }); mockAgentMailListWebhooks.mockRejectedValue( new Error('AgentMail GET /v0/webhooks failed (500)'), @@ -1657,6 +1791,7 @@ describe('comms commands', () => { webhookSecret: 'whsec_existing', inboxId: 'support@agentmail.to', podId: null, + keyScope: 'organization', }); }); diff --git a/apps/web/src/trpc/commands/comms/index.ts b/apps/web/src/trpc/commands/comms/index.ts index 5d6e667b08..5ea68ddf45 100644 --- a/apps/web/src/trpc/commands/comms/index.ts +++ b/apps/web/src/trpc/commands/comms/index.ts @@ -7,6 +7,7 @@ import { invalidateDiscordRuntimeCredentialsCache, normalizeDiscordBotToken, resolveAgentMailRuntimeCredentials, + type AgentMailKeyScope, resolveDiscordGatewaySecret, resolveDiscordRuntimeCredentials, validateDiscordBotToken, @@ -142,7 +143,7 @@ const ADDITIONAL_COMMS_PROVIDERS: Record< { envVarName: 'R_AGENTMAIL_POD_ID', acceptedEnvVarNames: ['R_AGENTMAIL_POD_ID'], - label: 'AgentMail Pod ID (optional)', + label: 'AgentMail Pod ID', required: false, }, { @@ -214,6 +215,8 @@ type AgentMailWebhookStatus = { export type AgentMailCommsStatus = { /** The AgentMail pod the inbox and webhook live in, when pod-scoped. */ podId: string | null; + /** 'inbox' when the key is inbox-scoped and the webhook lives on the inbox. */ + keyScope: AgentMailKeyScope; /** The routed inbox_id (the persisted configuration value). */ inboxAddress: string | null; /** The deliverable address for display, resolved live from AgentMail. */ @@ -662,14 +665,37 @@ function buildExpectedAgentMailWebhookUrl(): string { return new URL('/api/webhooks/agentmail', Env.R_APP_URL).toString(); } -function createAgentMailApiClient(apiKey: string, podId: string | null) { +function createAgentMailApiClient( + apiKey: string, + podId: string | null, + webhookInboxId: string | null = null, +) { return new AgentMailApiClient({ apiKey, ...(podId ? { podId } : {}), + ...(webhookInboxId ? { webhookInboxId } : {}), timeoutMs: AGENTMAIL_API_TIMEOUT_MS, }); } +/** The inbox to manage webhooks under, when the saved key is inbox-scoped. */ +function webhookInboxForScope(credentials: { + keyScope: AgentMailKeyScope; + inboxId: string | null; + podId: string | null; +}): string | null { + return credentials.keyScope === 'inbox' && !credentials.podId + ? credentials.inboxId + : null; +} + +function isAgentMailPermissionError(error: unknown): boolean { + return ( + error instanceof AgentMailApiError && + (error.status === 401 || error.status === 403) + ); +} + function normalizeAgentMailPodId(value: string | null | undefined) { const trimmed = value?.trim(); return trimmed || null; @@ -684,6 +710,25 @@ function assertEmailChannelEnabled(): void { } } +/** + * Pull "METHOD /path" plus AgentMail's response detail out of the client's + * error message (`AgentMail GET /v0/webhooks failed (403): {...}`), trimmed + * so a long body cannot swamp the settings toast. + */ +function describeAgentMailRequest(message: string): string | null { + const match = + /^AgentMail (GET|POST|PATCH|DELETE) (\S+) failed \(\d+\)(?::\s*([\s\S]*))?$/u.exec( + message.trim(), + ); + if (!match) { + return null; + } + const [, method, path, body] = match; + const detail = body?.trim().replace(/\s+/gu, ' ') ?? ''; + const clipped = detail.length > 160 ? `${detail.slice(0, 157)}...` : detail; + return clipped ? `${method} ${path} (${clipped})` : `${method} ${path}`; +} + /** Map AgentMail API / network failures into admin-facing setup copy. */ /** * AgentMail keys carry fine-grained permissions @@ -735,9 +780,20 @@ function classifyAgentMailSetupError( lower.includes('forbidden') || lower.includes('invalid api key') ) { - return operation === 'validating the API key' - ? `AgentMail rejected this API key. Create a key in the AgentMail console with these permissions (or full access) and save again: ${AGENTMAIL_REQUIRED_PERMISSIONS}.` - : `AgentMail refused permission while ${operation} (${message.includes('(403)') ? '403 Forbidden' : '401 Unauthorized'}). Create a key with these permissions (or full access) and save again: ${AGENTMAIL_REQUIRED_PERMISSIONS}.`; + // Name the exact request AgentMail refused: the same key can pass the + // inbox checks and still be refused on the organization-level webhook + // endpoints when it is scoped to an inbox or a pod, and "check your + // permissions" alone sends the operator in circles. + const request = describeAgentMailRequest(message); + const requestDetail = request ? ` Request: ${request}.` : ''; + if (operation === 'validating the API key') { + return `AgentMail rejected this API key. Create a key in the AgentMail console with these permissions (or full access) and save again: ${AGENTMAIL_REQUIRED_PERMISSIONS}.${requestDetail}`; + } + const scopeHint = + operation === 'configuring the webhook' + ? ' A key scoped to a pod is refused here even with full permissions unless the AgentMail Pod ID is entered, so Roomote registers the webhook inside that pod.' + : ''; + return `AgentMail refused permission while ${operation} (${message.includes('(403)') ? '403 Forbidden' : '401 Unauthorized'}).${requestDetail}${scopeHint} Otherwise create a key with these permissions (or full access) and save again: ${AGENTMAIL_REQUIRED_PERMISSIONS}.`; } return `AgentMail failed while ${operation}: ${message.trim() || 'could not connect.'}`; @@ -861,6 +917,7 @@ async function getAgentMailCommsStatus(): Promise { const client = createAgentMailApiClient( credentials.apiKey, credentials.podId, + webhookInboxForScope(credentials), ); try { @@ -892,6 +949,7 @@ async function getAgentMailCommsStatus(): Promise { return { podId: credentials.podId, + keyScope: credentials.keyScope, inboxAddress: credentials.inboxId, inboxEmail, webhook: { @@ -908,6 +966,7 @@ async function getAgentMailCommsStatus(): Promise { } catch (error) { return { podId: credentials.podId, + keyScope: credentials.keyScope, inboxAddress: credentials.inboxId, inboxEmail: null, webhook: { @@ -982,6 +1041,8 @@ export async function listAgentMailInboxesCommand( type AgentMailReconcileResult = { /** The pod the inbox and webhook were reconciled in, when pod-scoped. */ podId: string | null; + /** 'inbox' when the key turned out to be inbox-scoped. */ + keyScope: AgentMailKeyScope; /** The routed inbox_id — persisted and used in API paths/webhook scoping. */ inboxAddress: string; /** The deliverable address, display only. */ @@ -1091,17 +1152,6 @@ async function reconcileAgentMailSetup(input: { ); } - // Webhook permissions are the ones default console keys most often lack; - // prove them during validation so the failure names the missing permission - // before any inbox work happens. - try { - await client.listWebhooks(); - } catch (error) { - throw new Error( - classifyAgentMailSetupError(error, 'configuring the webhook'), - ); - } - const requestedInboxId = normalizeAgentMailInboxAddress(input.enteredInboxId) ?? existing.inboxId; let inboxAddress: string; @@ -1208,11 +1258,46 @@ async function reconcileAgentMailSetup(input: { ]; let webhookSecret = existing.webhookSecret; + // Which endpoints the key can manage the webhook through. An inbox-scoped + // key passes every inbox check above and is then refused on the + // organization-level webhook endpoints, so on that refusal the same key is + // tried against the inbox's own webhook endpoints before failing the save. + // The detected scope is persisted so status and disconnect use the same + // endpoints without probing again. + let keyScope: AgentMailKeyScope = + existing.keyScope === 'inbox' && !podId ? 'inbox' : 'organization'; + let webhookClient = + keyScope === 'inbox' + ? createAgentMailApiClient(apiKey, null, inboxAddress) + : client; + let webhooks: AgentMailWebhook[] | undefined; + try { + webhooks = (await webhookClient.listWebhooks()).webhooks; + } catch (error) { + const inboxScopedClient = + keyScope === 'organization' && !podId && isAgentMailPermissionError(error) + ? createAgentMailApiClient(apiKey, null, inboxAddress) + : null; + const inboxScoped = inboxScopedClient + ? await inboxScopedClient + .listWebhooks() + .then((listed) => listed.webhooks) + .catch(() => null) + : null; + if (!inboxScopedClient || inboxScoped === null) { + throw new Error( + classifyAgentMailSetupError(error, 'configuring the webhook'), + ); + } + keyScope = 'inbox'; + webhookClient = inboxScopedClient; + webhooks = inboxScoped; + } + try { - const { webhooks } = await client.listWebhooks(); const existingWebhook = findRoomoteAgentMailWebhook(webhooks); const createDeploymentWebhook = async (): Promise => { - const created = await client.createWebhook({ + const created = await webhookClient.createWebhook({ url: webhookUrl, clientId: buildAgentMailWebhookClientId(Env.R_APP_URL), inboxIds: desiredInboxIds, @@ -1248,14 +1333,14 @@ async function reconcileAgentMailSetup(input: { // secret we cannot verify deliveries for is useless: both cases mean a // fresh registration. Scope and event drift converge in place. if (existingWebhook.url !== webhookUrl || !webhookSecret) { - await client.deleteWebhook(existingWebhook.webhook_id); + await webhookClient.deleteWebhook(existingWebhook.webhook_id); webhookSecret = await createDeploymentWebhook(); } else if ( addInboxIds.length > 0 || removeInboxIds.length > 0 || !eventTypesMatch ) { - await client.updateWebhook(existingWebhook.webhook_id, { + await webhookClient.updateWebhook(existingWebhook.webhook_id, { ...(addInboxIds.length ? { addInboxIds } : {}), ...(removeInboxIds.length ? { removeInboxIds } : {}), ...(eventTypesMatch ? {} : { eventTypes: desiredEventTypes }), @@ -1272,6 +1357,7 @@ async function reconcileAgentMailSetup(input: { return { podId, + keyScope, inboxAddress, inboxEmail: inboxEmails.get(inboxAddress) ?? inboxAddress, webhookUrl, @@ -1287,6 +1373,7 @@ async function deleteAgentMailWebhookBestEffort(): Promise { const client = createAgentMailApiClient( credentials.apiKey, credentials.podId, + webhookInboxForScope(credentials), ); const { webhooks } = await client.listWebhooks(); const webhook = findRoomoteAgentMailWebhook(webhooks); @@ -1841,6 +1928,14 @@ export async function saveCommsAuthConfigCommand( ) { await deleteDeploymentEnvVarsByNames(tx, ['R_AGENTMAIL_POD_ID']); } + // The key scope is detected, never entered: record an inbox-scoped key + // so status and disconnect address the inbox's webhook endpoints, and + // drop the record once an organization-level key replaces it. + if (agentmailSetup.keyScope === 'inbox') { + valuesToSave.push({ name: 'R_AGENTMAIL_KEY_SCOPE', value: 'inbox' }); + } else if (persistedEnvVarNames.includes('R_AGENTMAIL_KEY_SCOPE')) { + await deleteDeploymentEnvVarsByNames(tx, ['R_AGENTMAIL_KEY_SCOPE']); + } } const hasConfiguredAuthEnvVar = (name: string) => @@ -1932,6 +2027,7 @@ export async function saveCommsAuthConfigCommand( ? { agentmail: { podId: agentmailSetup.podId, + keyScope: agentmailSetup.keyScope, inboxAddress: agentmailSetup.inboxAddress, inboxEmail: agentmailSetup.inboxEmail, webhookUrl: agentmailSetup.webhookUrl, @@ -1960,7 +2056,10 @@ export async function clearCommsAuthConfigCommand( // The webhook secret is provisioned server-side rather than entered, so // it is not a field; remove it with the credentials, and best-effort // unregister the webhook while the API key is still available. - fieldEnvVarNames.push('R_AGENTMAIL_WEBHOOK_SECRET'); + fieldEnvVarNames.push( + 'R_AGENTMAIL_WEBHOOK_SECRET', + 'R_AGENTMAIL_KEY_SCOPE', + ); await deleteAgentMailWebhookBestEffort(); } diff --git a/packages/communication/src/__tests__/agentmail-api-client.test.ts b/packages/communication/src/__tests__/agentmail-api-client.test.ts index ee074c7a82..7dae4891e4 100644 --- a/packages/communication/src/__tests__/agentmail-api-client.test.ts +++ b/packages/communication/src/__tests__/agentmail-api-client.test.ts @@ -136,6 +136,53 @@ describe('AgentMailApiClient pod scoping', () => { expect(client.podId).toBe('pod_acme'); }); + it('manages webhooks under the inbox for an inbox-scoped key', async () => { + const calls: Array<{ method: string; url: string; body: unknown }> = []; + const client = new AgentMailApiClient({ + apiKey: 'am_inbox_scoped', + apiBaseUrl: 'https://agentmail.test', + webhookInboxId: 'roomote@roomote.me', + fetch: (async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ + method: init?.method ?? 'GET', + url: String(input), + body: init?.body ? JSON.parse(String(init.body)) : undefined, + }); + return jsonResponse({ inboxes: [], webhooks: [] }); + }) as typeof fetch, + }); + + await client.listInboxes(); + await client.listWebhooks(); + await client.createWebhook({ + url: 'https://app.example.com/api/webhooks/agentmail', + inboxIds: ['roomote@roomote.me'], + eventTypes: ['message.received'], + }); + await client.updateWebhook('wh-1', { + addInboxIds: ['x@roomote.me'], + eventTypes: ['message.received', 'message.bounced'], + }); + await client.deleteWebhook('wh-1'); + + expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([ + 'GET https://agentmail.test/v0/inboxes', + 'GET https://agentmail.test/v0/inboxes/roomote%40roomote.me/webhooks', + 'POST https://agentmail.test/v0/inboxes/roomote%40roomote.me/webhooks', + 'PATCH https://agentmail.test/v0/inboxes/roomote%40roomote.me/webhooks/wh-1', + 'DELETE https://agentmail.test/v0/inboxes/roomote%40roomote.me/webhooks/wh-1', + ]); + // The path pins the inbox: no inbox list on create, no add/remove on update. + expect(calls[2]?.body).toEqual({ + url: 'https://app.example.com/api/webhooks/agentmail', + event_types: ['message.received'], + }); + expect(calls[3]?.body).toEqual({ + event_types: ['message.received', 'message.bounced'], + }); + expect(client.webhookInboxId).toBe('roomote@roomote.me'); + }); + it('stays at organization level without a pod', async () => { const { client, calls } = recordingClient(); await client.listWebhooks(); diff --git a/packages/communication/src/__tests__/mock-agentmail-server.test.ts b/packages/communication/src/__tests__/mock-agentmail-server.test.ts index dbe2096436..a8f9d82998 100644 --- a/packages/communication/src/__tests__/mock-agentmail-server.test.ts +++ b/packages/communication/src/__tests__/mock-agentmail-server.test.ts @@ -277,6 +277,78 @@ describe('MockAgentMailServer', () => { ).toEqual([]); }); + it('serves inbox-scoped webhook routes pinned to the inbox', async () => { + const state = baseState(); + state.inboxes.push({ + inbox_id: 'other@agentmail.to', + username: 'other', + domain: 'agentmail.to', + created_at: '2026-08-01T00:00:00.000Z', + }); + const { server, baseUrl } = await startServer(state); + onCleanup(() => server.stop()); + + const created = await api( + baseUrl, + 'POST', + `/v0/inboxes/${INBOX_ID}/webhooks`, + { + url: 'https://roomote.example.test/api/webhooks/agentmail', + client_id: 'roomote-webhook', + event_types: ['message.received'], + // Ignored: the path pins the scope. + inbox_ids: ['other@agentmail.to'], + }, + ); + expect(created.status).toBe(200); + expect(created.body.inbox_ids).toEqual([INBOX_ID]); + expect(created.body.pod_ids).toBeUndefined(); + const webhookId = String(created.body.webhook_id); + + expect( + (await api(baseUrl, 'GET', `/v0/inboxes/${INBOX_ID}/webhooks`)).body + .webhooks, + ).toHaveLength(1); + expect( + (await api(baseUrl, 'GET', '/v0/inboxes/other@agentmail.to/webhooks')) + .body.webhooks, + ).toEqual([]); + expect( + ( + await api( + baseUrl, + 'GET', + `/v0/inboxes/other@agentmail.to/webhooks/${webhookId}`, + ) + ).status, + ).toBe(404); + + const patched = await api( + baseUrl, + 'PATCH', + `/v0/inboxes/${INBOX_ID}/webhooks/${webhookId}`, + { + add_inbox_ids: ['other@agentmail.to'], + event_types: ['message.received', 'message.bounced'], + }, + ); + // Only event types move on an inbox-scoped webhook. + expect(patched.body.inbox_ids).toEqual([INBOX_ID]); + expect(patched.body.event_types).toEqual([ + 'message.received', + 'message.bounced', + ]); + + await api( + baseUrl, + 'DELETE', + `/v0/inboxes/${INBOX_ID}/webhooks/${webhookId}`, + ); + expect((await api(baseUrl, 'GET', '/v0/webhooks')).body.webhooks).toEqual( + [], + ); + }); + it('delivers pod-scoped webhooks only for inboxes inside the pod', async () => { const received: ReceivedDelivery[] = []; const listener = await startStubWebhook(received); diff --git a/packages/communication/src/agentmail-provider.ts b/packages/communication/src/agentmail-provider.ts index 6e03ff4396..a182d6d343 100644 --- a/packages/communication/src/agentmail-provider.ts +++ b/packages/communication/src/agentmail-provider.ts @@ -422,6 +422,13 @@ export type AgentMailApiClientOptions = { * message endpoints are inbox-addressed either way. */ podId?: string; + /** + * Manage webhooks through the inbox-scoped endpoints + * (`/v0/inboxes/{inbox_id}/webhooks`), which is all an inbox-scoped API + * key can reach. Such a webhook is fixed to the inbox: creation carries no + * inbox or pod scope and updates only change event types. + */ + webhookInboxId?: string; apiBaseUrl?: string; fetch?: typeof fetch; timeoutMs?: number; @@ -458,6 +465,8 @@ export class AgentMailApiClient { * the organization otherwise. Message paths always hang off `/v0/inboxes`. */ private readonly managementPrefix: string; + /** Prefix for webhook paths: the pod, the inbox, or the organization. */ + private readonly webhookPrefix: string; constructor(private readonly options: AgentMailApiClientOptions) { this.apiBaseUrl = trimTrailingSlashes( @@ -466,15 +475,26 @@ export class AgentMailApiClient { this.fetchImpl = options.fetch ?? fetch; this.timeoutMs = options.timeoutMs ?? DEFAULT_AGENTMAIL_TIMEOUT_MS; const podId = options.podId?.trim(); + const webhookInboxId = options.webhookInboxId?.trim(); this.managementPrefix = podId ? `/v0/pods/${encodeURIComponent(podId)}` : '/v0'; + this.webhookPrefix = podId + ? this.managementPrefix + : webhookInboxId + ? `/v0/inboxes/${encodeURIComponent(webhookInboxId)}` + : '/v0'; } get podId(): string | null { return this.options.podId?.trim() || null; } + /** The inbox webhooks are managed under, when inbox-scoped. */ + get webhookInboxId(): string | null { + return this.podId ? null : this.options.webhookInboxId?.trim() || null; + } + /** * Lists ALL inboxes, following `next_page_token` pagination — a first-page * read can make a many-inbox account look like it has exactly one, which @@ -551,12 +571,13 @@ export class AgentMailApiClient { listWebhooks(): Promise< { webhooks?: AgentMailWebhook[] } & Record > { - return this.request('GET', `${this.managementPrefix}/webhooks`); + return this.request('GET', `${this.webhookPrefix}/webhooks`); } /** - * Under a pod, the created webhook is scoped to that pod by the path; - * `inboxIds` narrows it further either way. + * Under a pod, the created webhook is scoped to that pod by the path and + * `inboxIds` narrows it further; under an inbox, the path fixes the scope + * and no inbox list is sent. */ createWebhook(input: { url: string; @@ -564,10 +585,12 @@ export class AgentMailApiClient { inboxIds?: string[]; eventTypes?: string[]; }): Promise { - return this.request('POST', `${this.managementPrefix}/webhooks`, { + return this.request('POST', `${this.webhookPrefix}/webhooks`, { url: input.url, ...(input.clientId ? { client_id: input.clientId } : {}), - ...(input.inboxIds ? { inbox_ids: input.inboxIds } : {}), + ...(input.inboxIds && !this.webhookInboxId + ? { inbox_ids: input.inboxIds } + : {}), ...(input.eventTypes ? { event_types: input.eventTypes } : {}), }); } @@ -575,7 +598,7 @@ export class AgentMailApiClient { getWebhook(webhookId: string): Promise { return this.request( 'GET', - `${this.managementPrefix}/webhooks/${encodeURIComponent(webhookId)}`, + `${this.webhookPrefix}/webhooks/${encodeURIComponent(webhookId)}`, ); } @@ -588,14 +611,16 @@ export class AgentMailApiClient { webhookId: string, input: AgentMailWebhookUpdate, ): Promise { + // An inbox-scoped webhook is fixed to its inbox: only event types move. + const inboxScoped = Boolean(this.webhookInboxId); return this.request( 'PATCH', - `${this.managementPrefix}/webhooks/${encodeURIComponent(webhookId)}`, + `${this.webhookPrefix}/webhooks/${encodeURIComponent(webhookId)}`, { - ...(input.addInboxIds?.length + ...(!inboxScoped && input.addInboxIds?.length ? { add_inbox_ids: input.addInboxIds } : {}), - ...(input.removeInboxIds?.length + ...(!inboxScoped && input.removeInboxIds?.length ? { remove_inbox_ids: input.removeInboxIds } : {}), // A non-empty list REPLACES the subscription in full (AgentMail @@ -608,7 +633,7 @@ export class AgentMailApiClient { deleteWebhook(webhookId: string): Promise { return this.request( 'DELETE', - `${this.managementPrefix}/webhooks/${encodeURIComponent(webhookId)}`, + `${this.webhookPrefix}/webhooks/${encodeURIComponent(webhookId)}`, ); } diff --git a/packages/communication/src/mock-agentmail-server.ts b/packages/communication/src/mock-agentmail-server.ts index f570d040e0..f6f64d9444 100644 --- a/packages/communication/src/mock-agentmail-server.ts +++ b/packages/communication/src/mock-agentmail-server.ts @@ -257,18 +257,24 @@ function normalizeState(state: MockAgentMailState): MockAgentMailState { }; } -/** Pod filter for management routes; null is the organization level. */ -type PodScope = { podId: string } | null; +/** + * Scope filter for management routes: a pod (`/v0/pods/{pod}/...`), an inbox + * (`/v0/inboxes/{inbox}/webhooks...`), or null for the organization level. + */ +type PodScope = { podId: string } | { inboxId: string } | null; function inboxInScope(inbox: MockAgentMailInbox, scope: PodScope): boolean { - return scope ? inbox.pod_id === scope.podId : true; + return scope && 'podId' in scope ? inbox.pod_id === scope.podId : true; } function webhookInScope( webhook: MockAgentMailWebhook, scope: PodScope, ): boolean { - return scope ? Boolean(webhook.pod_ids?.includes(scope.podId)) : true; + if (!scope) return true; + return 'podId' in scope + ? Boolean(webhook.pod_ids?.includes(scope.podId)) + : Boolean(webhook.inbox_ids?.includes(scope.inboxId)); } function stringList(value: unknown): string[] { @@ -856,6 +862,24 @@ export class MockAgentMailServer { } } + if (resource[2] === 'webhooks') { + if (!inbox) { + apiError(response, 404, 'Inbox not found'); + return; + } + if ( + this.handleWebhookRoutes( + resource.slice(2), + { inboxId: inbox.inbox_id }, + method, + body, + response, + ) + ) { + return; + } + } + if (resource[2] === 'messages') { if (!inbox) { apiError(response, 404, 'Inbox not found'); @@ -903,92 +927,116 @@ export class MockAgentMailServer { } } - if (resource[0] === 'webhooks') { - if (resource.length === 1) { - if (method === 'GET') { - json(response, 200, { - webhooks: (this.state.webhooks ?? []).filter((webhook) => - webhookInScope(webhook, scope), - ), - }); - return; - } + if ( + resource[0] === 'webhooks' && + this.handleWebhookRoutes(resource, scope, method, body, response) + ) { + return; + } - if (method === 'POST') { - this.handleCreateWebhook(response, body, scope); - return; - } + apiError( + response, + 404, + `Not Found: unhandled mock AgentMail route "${method} ${url.pathname}"`, + ); + } + + /** + * Webhook CRUD shared by the organization, pod, and inbox prefixes; the + * scope decides which registrations are visible and how a new one is + * scoped. Returns false when the route is not a webhook route. + */ + private handleWebhookRoutes( + resource: string[], + scope: PodScope, + method: string, + body: JsonRecord, + response: ServerResponse, + ): boolean { + if (resource.length === 1) { + if (method === 'GET') { + json(response, 200, { + webhooks: (this.state.webhooks ?? []).filter((webhook) => + webhookInScope(webhook, scope), + ), + }); + return true; } - if (resource.length === 2) { - const webhook = (this.state.webhooks ?? []).find( - (entry) => - entry.webhook_id === resource[1] && webhookInScope(entry, scope), - ); + if (method === 'POST') { + this.handleCreateWebhook(response, body, scope); + return true; + } + } - if (!webhook) { - apiError(response, 404, 'Webhook not found'); - return; - } + if (resource.length === 2) { + const webhook = (this.state.webhooks ?? []).find( + (entry) => + entry.webhook_id === resource[1] && webhookInScope(entry, scope), + ); - if (method === 'GET') { - json(response, 200, webhook); - return; - } + if (!webhook) { + apiError(response, 404, 'Webhook not found'); + return true; + } - if (method === 'PATCH') { - // Real AgentMail semantics: the URL is immutable, inbox and pod - // scope change through add/remove lists, and a non-empty - // event_types list replaces the subscription in full. - const addInboxIds = stringList(body.add_inbox_ids); - const removeInboxIds = stringList(body.remove_inbox_ids); - if (addInboxIds.length || removeInboxIds.length) { - webhook.inbox_ids = [ + if (method === 'GET') { + json(response, 200, webhook); + return true; + } + + if (method === 'PATCH') { + // Real AgentMail semantics: the URL is immutable, inbox and pod + // scope change through add/remove lists, and a non-empty + // event_types list replaces the subscription in full. + // An inbox-scoped webhook is fixed to its inbox: only event types + // can change there. + const inboxScoped = Boolean(scope && 'inboxId' in scope); + const addInboxIds = inboxScoped ? [] : stringList(body.add_inbox_ids); + const removeInboxIds = inboxScoped + ? [] + : stringList(body.remove_inbox_ids); + if (addInboxIds.length || removeInboxIds.length) { + webhook.inbox_ids = [ + ...new Set([ + ...(webhook.inbox_ids ?? []).filter( + (id) => !removeInboxIds.includes(id), + ), + ...addInboxIds, + ]), + ]; + } + if (!scope) { + const addPodIds = stringList(body.add_pod_ids); + const removePodIds = stringList(body.remove_pod_ids); + if (addPodIds.length || removePodIds.length) { + webhook.pod_ids = [ ...new Set([ - ...(webhook.inbox_ids ?? []).filter( - (id) => !removeInboxIds.includes(id), + ...(webhook.pod_ids ?? []).filter( + (id) => !removePodIds.includes(id), ), - ...addInboxIds, + ...addPodIds, ]), ]; } - if (!scope) { - const addPodIds = stringList(body.add_pod_ids); - const removePodIds = stringList(body.remove_pod_ids); - if (addPodIds.length || removePodIds.length) { - webhook.pod_ids = [ - ...new Set([ - ...(webhook.pod_ids ?? []).filter( - (id) => !removePodIds.includes(id), - ), - ...addPodIds, - ]), - ]; - } - } - const eventTypes = stringList(body.event_types); - if (eventTypes.length) { - webhook.event_types = eventTypes; - } - json(response, 200, webhook); - return; } - - if (method === 'DELETE') { - this.state.webhooks = (this.state.webhooks ?? []).filter( - (entry) => entry !== webhook, - ); - json(response, 200, { ok: true }); - return; + const eventTypes = stringList(body.event_types); + if (eventTypes.length) { + webhook.event_types = eventTypes; } + json(response, 200, webhook); + return true; } - } - apiError( - response, - 404, - `Not Found: unhandled mock AgentMail route "${method} ${url.pathname}"`, - ); + if (method === 'DELETE') { + this.state.webhooks = (this.state.webhooks ?? []).filter( + (entry) => entry !== webhook, + ); + json(response, 200, { ok: true }); + return true; + } + } + return false; } private handleCreatePod(response: ServerResponse, body: JsonRecord): void { @@ -1059,7 +1107,7 @@ export class MockAgentMailServer { ? { display_name: body.display_name } : {}), ...(clientId ? { client_id: clientId } : {}), - ...(scope ? { pod_id: scope.podId } : {}), + ...(scope && 'podId' in scope ? { pod_id: scope.podId } : {}), created_at: new Date().toISOString(), }; @@ -1093,10 +1141,19 @@ export class MockAgentMailServer { } } - const inboxIds = stringList(body.inbox_ids); - // Under a pod the path fixes the pod scope; organization-level creates - // may name pods explicitly. - const podIds = scope ? [scope.podId] : stringList(body.pod_ids); + // The path fixes the scope: a pod webhook is pinned to its pod (and may + // narrow to inboxes), an inbox webhook is pinned to its inbox, and an + // organization-level create may name either explicitly. + const inboxIds = + scope && 'inboxId' in scope + ? [scope.inboxId] + : stringList(body.inbox_ids); + const podIds = + scope && 'podId' in scope + ? [scope.podId] + : scope + ? [] + : stringList(body.pod_ids); const eventTypes = stringList(body.event_types); const webhook: MockAgentMailWebhook = { webhook_id: this.nextId('wh'), diff --git a/packages/db/src/lib/agentmail-runtime-credentials.ts b/packages/db/src/lib/agentmail-runtime-credentials.ts index 694b74d99c..56d3cc5cfa 100644 --- a/packages/db/src/lib/agentmail-runtime-credentials.ts +++ b/packages/db/src/lib/agentmail-runtime-credentials.ts @@ -10,8 +10,22 @@ export type AgentMailRuntimeCredentials = { * organization-level resources. */ podId: string | null; + /** + * Whether the API key is an inbox-scoped key. Inbox-scoped keys manage the + * webhook through the inbox's own endpoints; detected at save time and + * persisted so status and disconnect use the same endpoints. + */ + keyScope: AgentMailKeyScope; }; +export type AgentMailKeyScope = 'organization' | 'inbox'; + +function normalizeKeyScope( + value: string | null | undefined, +): AgentMailKeyScope { + return value?.trim().toLowerCase() === 'inbox' ? 'inbox' : 'organization'; +} + const CACHE_TTL_MS = 30_000; let cachedCredentials: { @@ -30,6 +44,7 @@ function readProcessEnvCredentials(): AgentMailRuntimeCredentials { webhookSecret: process.env.R_AGENTMAIL_WEBHOOK_SECRET?.trim() || null, inboxId: normalizeInboxId(process.env.R_AGENTMAIL_INBOX_ID), podId: process.env.R_AGENTMAIL_POD_ID?.trim() || null, + keyScope: normalizeKeyScope(process.env.R_AGENTMAIL_KEY_SCOPE), }; } @@ -64,6 +79,9 @@ export async function resolveAgentMailRuntimeCredentials(): Promise = new Set([ 'SLACK_APP_ID', 'R_AGENTMAIL_INBOX_ID', 'R_AGENTMAIL_POD_ID', + 'R_AGENTMAIL_KEY_SCOPE', 'ADO_CLIENT_ID', 'ADO_TENANT_ID', 'ADO_AUTH_MODE', From dafa56e0dd5b92dd2b60ad9e4ee45b09d64e1545 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:48:26 -0400 Subject: [PATCH 2/3] fix: re-detect the AgentMail key scope for a newly entered key --- .../web/src/trpc/commands/comms/index.test.ts | 58 +++++++++++++++++++ apps/web/src/trpc/commands/comms/index.ts | 9 ++- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/apps/web/src/trpc/commands/comms/index.test.ts b/apps/web/src/trpc/commands/comms/index.test.ts index ee912b34fb..b9364ec956 100644 --- a/apps/web/src/trpc/commands/comms/index.test.ts +++ b/apps/web/src/trpc/commands/comms/index.test.ts @@ -1112,6 +1112,64 @@ describe('comms commands', () => { ); }); + it('re-detects the scope for a newly entered key instead of inheriting the recorded inbox scope', async () => { + const txDelete = vi.fn(() => ({ + where: vi.fn(async () => undefined), + })); + mockDbTransaction.mockImplementation(async (callback) => + callback({ delete: txDelete } as never), + ); + mockResolveAgentMailRuntimeCredentials.mockResolvedValue({ + apiKey: 'am-old-inbox-key', + webhookSecret: 'whsec_inbox', + inboxId: 'roomote@roomote.me', + podId: null, + keyScope: 'inbox', + }); + mockGetPersistedEnvironmentVariableNames.mockResolvedValue([ + 'R_AGENTMAIL_API_KEY', + 'R_AGENTMAIL_INBOX_ID', + 'R_AGENTMAIL_WEBHOOK_SECRET', + 'R_AGENTMAIL_KEY_SCOPE', + ]); + mockAgentMailGetInbox.mockResolvedValue({ + inbox_id: 'roomote@roomote.me', + }); + // The new organization-level key sees the old inbox-scoped + // registration from the organization listing and converges it. + mockAgentMailListWebhooks.mockResolvedValue({ + webhooks: [ + { + webhook_id: 'wh-inbox', + url: expectedWebhookUrl, + client_id: `roomote-agentmail-webhook-${hostHash}`, + inbox_ids: ['roomote@roomote.me'], + event_types: [ + 'message.received', + 'message.bounced', + 'message.complained', + ], + }, + ], + }); + + await expect( + saveCommsAuthConfigCommand(buildMockAuth(), { + provider: 'agentmail', + values: { + R_AGENTMAIL_API_KEY: 'am-new-org-key', + R_AGENTMAIL_INBOX_ID: 'roomote@roomote.me', + }, + }), + ).resolves.toMatchObject({ agentmail: { keyScope: 'organization' } }); + + expect(mockAgentMailClientConstructor).not.toHaveBeenCalledWith( + expect.objectContaining({ webhookInboxId: expect.anything() }), + ); + // The stale inbox-scope record is dropped with the new key. + expect(txDelete).toHaveBeenCalled(); + }); + it('addresses the inbox webhook endpoints directly once an inbox-scoped key is recorded', async () => { mockResolveAgentMailRuntimeCredentials.mockResolvedValue({ apiKey: 'am-inbox-key', diff --git a/apps/web/src/trpc/commands/comms/index.ts b/apps/web/src/trpc/commands/comms/index.ts index 5ea68ddf45..8e826f8f8e 100644 --- a/apps/web/src/trpc/commands/comms/index.ts +++ b/apps/web/src/trpc/commands/comms/index.ts @@ -1263,9 +1263,14 @@ async function reconcileAgentMailSetup(input: { // organization-level webhook endpoints, so on that refusal the same key is // tried against the inbox's own webhook endpoints before failing the save. // The detected scope is persisted so status and disconnect use the same - // endpoints without probing again. + // endpoints without probing again. A newly entered key never inherits the + // recorded scope: an organization-level key replacing an inbox-scoped one + // must reconcile (and remove) the old inbox registration from the + // organization listing, which an inherited inbox scope could not see. let keyScope: AgentMailKeyScope = - existing.keyScope === 'inbox' && !podId ? 'inbox' : 'organization'; + existing.keyScope === 'inbox' && !podId && input.enteredApiKey === null + ? 'inbox' + : 'organization'; let webhookClient = keyScope === 'inbox' ? createAgentMailApiClient(apiKey, null, inboxAddress) From bf0556c38a7a899c9b8e39ba692d6c29f18ec709 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:06:03 -0400 Subject: [PATCH 3/3] feat: derive the AgentMail inbox from an inbox-scoped key A Roomote deployment owns exactly one inbox, so the intended AgentMail key is one created from inside that inbox. The save now resolves the inbox from the key (it must see exactly one, or the one pinned by R_AGENTMAIL_INBOX_ID) and manages the webhook through the inbox's own endpoints, which is all an inbox-scoped key can reach. That removes the inbox chooser and free-text field, the inbox provisioning path, the pod id field and pod plumbing, and the key-scope detection this PR briefly introduced. Permission errors name the exact refused request. --- .../skills/mock-agentmail-testing/SKILL.md | 8 +- apps/docs/environment-variables.mdx | 6 +- .../providers/communications/agentmail.mdx | 74 +- .../setup/ProviderSetupInstructions.tsx | 13 +- .../(onboarding)/setup/providerSetupCopy.ts | 4 +- .../settings/CommsProviderSection.tsx | 326 +------ .../web/src/trpc/commands/comms/index.test.ts | 833 ++++-------------- apps/web/src/trpc/commands/comms/index.ts | 483 ++-------- apps/web/src/trpc/routers/_app.ts | 15 - .../__tests__/agentmail-api-client.test.ts | 63 +- .../communication/src/agentmail-provider.ts | 91 +- .../src/lib/agentmail-runtime-credentials.ts | 29 +- packages/env/src/index.ts | 6 - packages/types/src/control-plane-env-vars.ts | 2 - 14 files changed, 322 insertions(+), 1631 deletions(-) diff --git a/.agents/skills/mock-agentmail-testing/SKILL.md b/.agents/skills/mock-agentmail-testing/SKILL.md index ea6fdfb3fe..4a3e04b994 100644 --- a/.agents/skills/mock-agentmail-testing/SKILL.md +++ b/.agents/skills/mock-agentmail-testing/SKILL.md @@ -32,9 +32,9 @@ R_AGENTMAIL_API_KEY=mock-agentmail-api-key # any value; the harness a AGENTMAIL_API_BASE_URL=http://127.0.0.1:3015 # reroutes ALL outbound AgentMail API calls to the harness ``` -Pods are supported: `POST /v0/pods` (idempotent per `client_id`) creates one, and the pod-scoped management routes (`/v0/pods/{pod_id}/inboxes...`, `/v0/pods/{pod_id}/webhooks...`) only see resources inside that pod, while the organization-level routes see everything. To exercise a pod-scoped setup, seed a pod in the scenario file (`pods: [{ pod_id: 'pod_acme' }]`) and set `R_AGENTMAIL_POD_ID=pod_acme` for the app. Webhook updates follow the real API: `add_inbox_ids` / `remove_inbox_ids` (and `add_pod_ids` / `remove_pod_ids` at organization level), a non-empty `event_types` list replaces the subscription, and the `url` is immutable. +The app registers its webhook through the inbox-scoped routes (`/v0/inboxes/{inbox_id}/webhooks...`), which is what an inbox-scoped API key can reach; the harness serves those alongside the organization-level and pod-scoped (`/v0/pods/{pod_id}/...`) routes, and the app resolves its inbox from `GET /v0/inboxes`, so seed exactly one inbox unless you are testing the "key sees several inboxes" refusal. Webhook updates follow the real API: `add_inbox_ids` / `remove_inbox_ids` (and `add_pod_ids` / `remove_pod_ids` at organization level), an inbox-scoped webhook only changes `event_types`, a non-empty `event_types` list replaces the subscription, and the `url` is immutable. -Webhook secrets need no manual wiring: when the app registers its webhook through `POST /v0/webhooks`, the harness mints the `whsec_...` secret and returns it, exactly like real AgentMail. If the app relies on a pre-provisioned secret (`R_AGENTMAIL_WEBHOOK_SECRET`), seed a webhook with that secret in the scenario file instead — deliveries are signed with whatever secret the registration holds. +Webhook secrets need no manual wiring: when the app registers its webhook through `POST /v0/inboxes/{inbox_id}/webhooks`, the harness mints the `whsec_...` secret and returns it, exactly like real AgentMail. If the app relies on a pre-provisioned secret (`R_AGENTMAIL_WEBHOOK_SECRET`), seed a webhook with that secret in the scenario file instead — deliveries are signed with whatever secret the registration holds. ## Step 2: Create a scenario file @@ -159,8 +159,8 @@ To reset between scenarios, `POST /mock/state` with a fresh state object (it rep - **`duplicate-delivery`** — `duplicate: true` → same svix-id twice → exactly-once handling - **`oversize-payload`** — `oversize: true` → app must re-fetch the message body by id before acting - **`auto-submitted-loop-guard`** — `autoSubmitted: true` → automated senders must not trigger reply loops -- **`webhook-registration`** — app boots, registers its webhook via `POST /v0/webhooks` (idempotent per `client_id`), and the secret round-trips into signature verification -- **`pod-scoped-setup`** — with `R_AGENTMAIL_POD_ID` set and a seeded pod, the app provisions its inbox and webhook under `/v0/pods/{pod_id}/...` and deliveries for inboxes outside the pod never reach it +- **`webhook-registration`** — app boots, registers its webhook via `POST /v0/inboxes/{inbox_id}/webhooks` (idempotent per `client_id`), and the secret round-trips into signature verification +- **`inbox-scoped-setup`** — with one seeded inbox, the app resolves it from the key and registers its webhook under `/v0/inboxes/{inbox_id}/webhooks`; with two seeded inboxes the save is refused and names both - **`reply-idempotency`** — app retries a reply with the same `Idempotency-Key` → exactly one outbound message in `/mock/state` - **`bounce-suppression`** — `kind: 'bounce'` (Permanent) / `kind: 'complaint'` → the recipient lands in `agentmail_suppressions` and outbound-initiated email to them is refused; `bounceType: 'Transient'` must NOT suppress diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index 2f6515bbc6..7fff9905ef 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -362,11 +362,9 @@ as per-task auth tokens or workspace paths. | `R_DISCORD_GATEWAY_SECRET` | Discord | Shared internal secret for Discord event delivery between BullMQ and API. Auto-generated when Discord is saved in the UI if unset, and auto-healed when Discord is already configured without one. | | `DISCORD_API_BASE_URL` | Optional | Discord REST API base URL override, primarily for testing. | | `R_EMAIL_CHANNEL_ENABLED` | Optional | Set to `true` to enable the email (AgentMail) channel. Without it, email is absent from settings, inbound webhooks are ignored, and Roomote never sends email. Enabling it also turns on account email verification. | -| `R_AGENTMAIL_API_KEY` | Optional | AgentMail API key for email. Overrides the value saved in the settings UI. | +| `R_AGENTMAIL_API_KEY` | Optional | AgentMail API key for email, created from inside the deployment's inbox (inbox-scoped). Overrides the value saved in the settings UI. | | `R_AGENTMAIL_WEBHOOK_SECRET` | Optional | AgentMail webhook secret. Overrides the value managed by the settings UI. | -| `R_AGENTMAIL_INBOX_ID` | Optional | AgentMail deployment inbox. Overrides the inbox connected in the settings UI. | -| `R_AGENTMAIL_POD_ID` | Optional | AgentMail pod the inbox and webhook live in; required with a pod-scoped API key. Overrides the pod entered in the settings UI. | -| `R_AGENTMAIL_KEY_SCOPE` | Optional | `inbox` when the AgentMail API key is inbox-scoped, so the webhook is managed on the inbox. Detected and saved by the settings UI; set it only for env-var-only setups. | +| `R_AGENTMAIL_INBOX_ID` | Optional | AgentMail deployment inbox. Normally derived from the key on save; set it to pin the inbox for env-var-only setups. | | `AGENTMAIL_API_BASE_URL` | Optional | AgentMail API base URL override, primarily for testing. Defaults to `https://api.agentmail.to`. | | `R_MICROSOFT_CLIENT_ID` | Microsoft sign-in | Microsoft OAuth client ID. | | `R_MICROSOFT_CLIENT_SECRET` | Microsoft sign-in | Microsoft OAuth client secret. | diff --git a/apps/docs/providers/communications/agentmail.mdx b/apps/docs/providers/communications/agentmail.mdx index 83d433fb31..9fa86b3084 100644 --- a/apps/docs/providers/communications/agentmail.mdx +++ b/apps/docs/providers/communications/agentmail.mdx @@ -29,61 +29,45 @@ is never blocked on verification. ## Connect an AgentMail inbox -Create an AgentMail account and API key at -[console.agentmail.to/dashboard/api-keys](https://console.agentmail.to/dashboard/api-keys). -The API key must carry these AgentMail permissions (or be a full-access -key): `inbox_read`, `inbox_create`, `inbox_update`, `webhook_read`, -`webhook_create`, `webhook_update`, `webhook_delete`, `message_read`, and -`message_send`. Missing permissions fail at save time with an error naming -the refused request, except `message_send`, which has no side-effect-free -check and is exercised on the first reply. - -The key can be an organization-level key or an inbox-scoped key. An -inbox-scoped key (created from inside the inbox in the AgentMail console) is -the least-privilege choice for a one-inbox deployment: Roomote detects it on -save and registers the webhook on the inbox itself instead of at the -organization level. Enter that inbox's address, or leave the field blank -when it is the only inbox the key can see. A pod-scoped key needs the pod id -(see [Pods and pod-scoped keys](#pods-and-pod-scoped-keys)). - -In the Roomote UI (**Settings > Communications > Email (AgentMail)**), -paste the API key, then save. Roomote proposes a deployment inbox address such as -`roomote-yourhost-a1b2c3@agentmail.to`; edit the address before creation, or -supply the address of an existing AgentMail inbox instead. On save, Roomote -creates the inbox and registers a webhook for `message.received`, -`message.bounced`, and `message.complained` events automatically. The -connected inbox address and the registered webhook URL are shown in -settings. +Create an AgentMail account at [console.agentmail.to](https://console.agentmail.to), +then: + +1. **Create the inbox** Roomote should receive mail at (or pick an existing + one). AgentMail's free tier includes 3 inboxes and 100 emails per day and + adds a "Sent via AgentMail" footer to outbound mail; custom domains, with + AgentMail-managed SPF/DKIM/DMARC, need a paid plan and are recommended for + production. +2. **Create an API key from inside that inbox**, so the key is scoped to it. + Give it these permissions (or full access): `inbox_read`, `inbox_update`, + `webhook_read`, `webhook_create`, `webhook_update`, `webhook_delete`, + `message_read`, and `message_send`. +3. In the Roomote UI (**Settings > Communications > Email (AgentMail)**), + paste the key and save. + +Roomote uses the inbox the key is scoped to; there is nothing else to enter. +On save it validates the key, registers a webhook on the inbox for +`message.received`, `message.bounced`, and `message.complained` events, and +shows the connected address and webhook URL in settings. Missing permissions +fail at save time with an error naming the refused request, except +`message_send`, which has no side-effect-free check and is exercised on the +first reply. + +An inbox-scoped key is the intended shape: it cannot read other inboxes or +create new ones, so a leaked key exposes one mailbox rather than the +account. An organization-level key is accepted only while the account has +exactly one inbox, since Roomote would otherwise have no way to tell which +inbox is for this deployment. For self-hosted env-var configuration instead of the UI, all values are optional overrides of the settings UI: ```sh # Optional — configure email entirely from settings when unset: -# R_AGENTMAIL_API_KEY= +# R_AGENTMAIL_API_KEY= # R_AGENTMAIL_WEBHOOK_SECRET= # R_AGENTMAIL_INBOX_ID= -# R_AGENTMAIL_POD_ID= ``` -### Pods and pod-scoped keys - -AgentMail [pods](https://docs.agentmail.to/documentation/core-concepts/pods) -isolate inboxes, domains, and mail per tenant inside one AgentMail -organization, and a pod-scoped API key can only reach its own pod. If the -deployment was handed a pod-scoped key, or you want Roomote's inbox and -webhook kept inside a particular pod, enter the pod id in the **AgentMail -Pod ID** field (or set `R_AGENTMAIL_POD_ID`). Roomote then lists, creates, -and updates the inbox and webhook through the pod's endpoints; inbound and -outbound mail is unaffected. Leave the field empty to use the organization's -inboxes directly. Inboxes cannot move between pods, so changing the pod id -later means choosing or creating an inbox inside the new pod. - -AgentMail's free tier allows 3 inboxes and 100 emails per day and adds a -"Sent via AgentMail" footer to outbound mail. Custom domains, with -AgentMail-managed SPF/DKIM/DMARC, require a paid AgentMail plan. For -production use, a paid plan with a custom domain is recommended. - ## Email Roomote Send an email to the deployment inbox from an email address on your Roomote diff --git a/apps/web/src/app/(onboarding)/setup/ProviderSetupInstructions.tsx b/apps/web/src/app/(onboarding)/setup/ProviderSetupInstructions.tsx index 5123b74d16..0be8eff8d5 100644 --- a/apps/web/src/app/(onboarding)/setup/ProviderSetupInstructions.tsx +++ b/apps/web/src/app/(onboarding)/setup/ProviderSetupInstructions.tsx @@ -185,13 +185,14 @@ export function ProviderSetupInstructions({ if (providerId === 'agentmail') { return (
- - In the AgentMail console, create an API key and paste it below. + + In the AgentMail console, create the inbox Roomote should receive mail + at (or pick an existing one). - - Leave the address blank and Roomote provisions an inbox for this - deployment automatically, or enter an existing AgentMail inbox address - to use it instead. + + Open that inbox and create an API key from inside it, so the key is + scoped to the inbox. Paste it below; Roomote uses the inbox the key is + for. Roomote registers the AgentMail webhook for incoming mail diff --git a/apps/web/src/app/(onboarding)/setup/providerSetupCopy.ts b/apps/web/src/app/(onboarding)/setup/providerSetupCopy.ts index cf2c1e909c..115bb0fd33 100644 --- a/apps/web/src/app/(onboarding)/setup/providerSetupCopy.ts +++ b/apps/web/src/app/(onboarding)/setup/providerSetupCopy.ts @@ -30,8 +30,8 @@ const PROVIDER_SETUP_COPY: Record = { setupLabel: 'Discord bot', }, agentmail: { - creationHref: 'https://console.agentmail.to/dashboard/api-keys', - setupLabel: 'AgentMail API key', + creationHref: 'https://console.agentmail.to/dashboard/inboxes', + setupLabel: 'AgentMail inbox', }, }; diff --git a/apps/web/src/components/settings/CommsProviderSection.tsx b/apps/web/src/components/settings/CommsProviderSection.tsx index aab95b5c46..2b35c40c86 100644 --- a/apps/web/src/components/settings/CommsProviderSection.tsx +++ b/apps/web/src/components/settings/CommsProviderSection.tsx @@ -83,15 +83,9 @@ import { DialogTitle, ExternalLink, Info, - Input, Mail, Plug, RefreshCw, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, Spinner, Trash2, TriangleAlert, @@ -302,22 +296,6 @@ function AgentMailSetupStatus({ return (
- {status.podId ? ( -
- -

- Pod: {status.podId} -

-
- ) : null} - {status.keyScope === 'inbox' ? ( -
- -

- Inbox-scoped API key: the webhook is registered on the inbox. -

-
- ) : null} {status.inboxAddress ? (
@@ -360,250 +338,6 @@ function AgentMailSetupStatus({ ); } -const AGENTMAIL_CREATE_NEW_INBOX_OPTION = '__agentmail_create_new__'; -const AGENTMAIL_MANUAL_INBOX_OPTION = '__agentmail_manual__'; - -/** - * Chooser for the AgentMail inbox: lists the inboxes the API key can see plus - * a "create new" option for this deployment's proposed address, instead of a - * free-text field. Manual entry stays available for inboxes the key cannot - * list yet (e.g. custom domains). Writes the chosen address into the - * R_AGENTMAIL_INBOX_ID form value; the save reconcile does the rest. - */ -function AgentMailInboxChooser({ - enteredApiKey, - enteredPodId, - keyConfigured, - value, - savedSatisfied, - disabled, - onChange, -}: { - /** API key currently typed into the form (already trimmed). */ - enteredApiKey: string; - /** Pod id currently in the form (already trimmed); scopes the listing. */ - enteredPodId: string; - /** The API key field is satisfied by a saved or runtime value. */ - keyConfigured: boolean; - value: string; - savedSatisfied: boolean; - disabled: boolean; - onChange: (address: string) => void; -}) { - const trpc = useTRPC(); - // A key typed into the form only loads on request, so AgentMail is not - // called on every keystroke. A saved-and-connected config never loads - // automatically either — the status block already names the inbox, so - // AgentMail is only called when the operator actually opens the chooser. - const [loadedEnteredKey, setLoadedEnteredKey] = useState(null); - const [manualEntry, setManualEntry] = useState(false); - const [chooserRequested, setChooserRequested] = useState(false); - - const hasEnteredKey = enteredApiKey.length > 0; - const keyAvailable = hasEnteredKey || keyConfigured; - const loadWanted = chooserRequested || (keyAvailable && !savedSatisfied); - const loadEnabled = - loadWanted && - keyAvailable && - (!hasEnteredKey || loadedEnteredKey === enteredApiKey); - - // A mutation rather than a query: the typed API key travels in the POST - // body instead of being serialized into a GET URL (browser history, proxy - // logs, tracing). - const loadInboxes = useMutation( - trpc.comms.listAgentMailInboxes.mutationOptions(), - ); - const requestedKeyRef = useRef(null); - const requestKey = hasEnteredKey ? enteredApiKey : ''; - // savedSatisfied joins the signature so a save that just created the - // inbox refreshes the list (mutations have no query cache to invalidate). - const requestSignature = `${savedSatisfied ? 'saved' : 'unsaved'}:${requestKey}:${enteredPodId}`; - const { mutate: loadInboxesMutate } = loadInboxes; - - useEffect(() => { - if (!loadEnabled || requestedKeyRef.current === requestSignature) { - return; - } - requestedKeyRef.current = requestSignature; - loadInboxesMutate({ - ...(requestKey ? { apiKey: requestKey } : {}), - podId: enteredPodId, - }); - }, [ - loadEnabled, - requestKey, - requestSignature, - enteredPodId, - loadInboxesMutate, - ]); - - const inboxesLoading = - loadInboxes.isPending || - (loadEnabled && !loadInboxes.isSuccess && !loadInboxes.isError); - - // Entries pair the routed inbox_id (the submitted value) with the - // deliverable email (the label); they are equal today but may diverge. - const inboxes = loadInboxes.data?.inboxes ?? []; - const inboxIds = inboxes.map((inbox) => inbox.inboxId); - const proposedNewAddress = loadInboxes.data?.proposedNewAddress ?? null; - const proposalAlreadyExists = Boolean( - proposedNewAddress && inboxIds.includes(proposedNewAddress), - ); - const normalizedValue = value.trim().toLowerCase(); - const selectValue = !normalizedValue - ? undefined - : inboxIds.includes(normalizedValue) - ? normalizedValue - : normalizedValue === proposedNewAddress && !proposalAlreadyExists - ? AGENTMAIL_CREATE_NEW_INBOX_OPTION - : undefined; - // A value the account listing does not contain (custom domain, older - // config) keeps the manual input visible so it is never silently hidden. - // A saved config also rests on the input until the chooser is opened. - const showManualInput = - manualEntry || - !keyAvailable || - (savedSatisfied && !chooserRequested) || - (loadInboxes.isSuccess && - normalizedValue.length > 0 && - selectValue === undefined); - - const manualEntryLink = ( - - ); - - return ( -
-
Inbox Email Address (optional)
-

- The inbox Roomote receives mail at. Leave it unset to let Roomote adopt - the account's only inbox or create one when you save. -

- {showManualInput ? ( -
-
- onChange(event.target.value)} - placeholder="Inbox Email Address" - disabled={disabled} - data-1p-ignore - /> - {savedSatisfied && } -
- {keyAvailable ? ( - - ) : ( -

- Enter the AgentMail API key above to choose from the - account's inboxes. -

- )} -
- ) : !loadEnabled ? ( -
- - {manualEntryLink} -
- ) : inboxesLoading ? ( -
- - Loading inboxes… -
- ) : loadInboxes.isError ? ( -
-

- {loadInboxes.error.message} -

-
- - {manualEntryLink} -
-
- ) : ( -
- - {savedSatisfied && } -
- )} -
- ); -} - type CommsProviderSectionProps = { provider: CommsProviderStatus; onSave: (provider: CommsProviderId, values: Record) => void; @@ -862,39 +596,6 @@ export function CommsProviderSection({ ? getTeamsAppPackageUnavailableReason(enteredTeamsBotAppId) : null; - // AgentMail replaces the free-text inbox field with a chooser fed by the - // account's inbox list, so that field is pulled out of the generic setup - // fields and rendered below (unless an env var pins it at runtime). - const agentMailInboxField = - provider.id === 'agentmail' - ? provider.fields.find( - (field) => field.envVarName === 'R_AGENTMAIL_INBOX_ID', - ) - : undefined; - const agentMailChooserActive = Boolean( - agentMailInboxField && !agentMailInboxField.runtimeSatisfied, - ); - const setupExperienceProvider = agentMailChooserActive - ? { - ...provider, - fields: provider.fields.filter( - (field) => field.envVarName !== 'R_AGENTMAIL_INBOX_ID', - ), - } - : provider; - const agentMailApiKeyField = - provider.id === 'agentmail' - ? provider.fields.find( - (field) => field.envVarName === 'R_AGENTMAIL_API_KEY', - ) - : undefined; - const agentMailKeyConfigured = Boolean( - agentMailApiKeyField && - (agentMailApiKeyField.runtimeSatisfied || - (agentMailApiKeyField.savedSatisfied && - !clearedSavedValues['R_AGENTMAIL_API_KEY'])), - ); - const handleSave = () => { onSave(provider.id, getSetupSubmitValues({ provider, values })); }; @@ -941,7 +642,7 @@ export function CommsProviderSection({ ) : (
@@ -986,29 +687,6 @@ export function CommsProviderSection({ } /> - {agentMailChooserActive && agentMailInboxField ? ( - { - setValues((current) => ({ - ...current, - R_AGENTMAIL_INBOX_ID: address, - })); - if (agentMailInboxField.savedSatisfied) { - setClearedSavedValues((current) => ({ - ...current, - R_AGENTMAIL_INBOX_ID: address.length === 0, - })); - } - }} - /> - ) : null} -
{provider.id === 'telegram' && provider.telegramWebhook && (
diff --git a/apps/web/src/trpc/commands/comms/index.test.ts b/apps/web/src/trpc/commands/comms/index.test.ts index b9364ec956..14d24a82e9 100644 --- a/apps/web/src/trpc/commands/comms/index.test.ts +++ b/apps/web/src/trpc/commands/comms/index.test.ts @@ -9,7 +9,6 @@ const { mockResolveAgentMailRuntimeCredentials, mockAgentMailClientConstructor, mockAgentMailListInboxes, - mockAgentMailCreateInbox, mockAgentMailGetInbox, mockAgentMailGetMessage, mockAgentMailListWebhooks, @@ -46,12 +45,9 @@ const { apiKey: null as string | null, webhookSecret: null as string | null, inboxId: null as string | null, - podId: null as string | null, - keyScope: 'organization' as 'organization' | 'inbox', })), mockAgentMailClientConstructor: vi.fn(), mockAgentMailListInboxes: vi.fn(), - mockAgentMailCreateInbox: vi.fn(), mockAgentMailGetInbox: vi.fn(), mockAgentMailGetMessage: vi.fn(), mockAgentMailListWebhooks: vi.fn(), @@ -197,7 +193,6 @@ vi.mock('@roomote/communication/agentmail-provider', () => ({ mockAgentMailClientConstructor(options); } listInboxes = mockAgentMailListInboxes; - createInbox = mockAgentMailCreateInbox; getInbox = mockAgentMailGetInbox; getMessage = mockAgentMailGetMessage; listWebhooks = mockAgentMailListWebhooks; @@ -271,7 +266,6 @@ import { classifyTelegramWebhookCheckError, clearCommsAuthConfigCommand, getCommsStatusCommand, - listAgentMailInboxesCommand, listDiscordChannelsCommand, listDiscordGuildsCommand, repairTelegramWebhookCommand, @@ -324,12 +318,9 @@ describe('comms commands', () => { apiKey: null, webhookSecret: null, inboxId: null, - podId: null, - keyScope: 'organization', }); mockAgentMailListInboxes.mockResolvedValue({ inboxes: [] }); mockAgentMailListWebhooks.mockResolvedValue({ webhooks: [] }); - mockAgentMailCreateInbox.mockReset(); mockAgentMailGetInbox.mockReset(); mockAgentMailCreateWebhook.mockReset(); mockAgentMailUpdateWebhook.mockReset(); @@ -827,8 +818,8 @@ describe('comms commands', () => { .update('app.example.com') .digest('hex') .slice(0, 6); - const expectedUsername = `roomote-app-example-com-${hostHash}`; const expectedWebhookUrl = 'https://app.example.com/api/webhooks/agentmail'; + const INBOX = 'roomote@roomote.example'; beforeEach(() => { mockDbTransaction.mockImplementation(async (callback) => @@ -839,16 +830,17 @@ describe('comms commands', () => { mockAgentMailGetMessage.mockRejectedValue( new AgentMailApiError('AgentMail GET failed (404): Not Found', 404), ); - }); - - it("adopts the org's only existing inbox instead of creating a second", async () => { mockAgentMailListInboxes.mockResolvedValue({ - inboxes: [{ inbox_id: 'existing@agentmail.to' }], + inboxes: [{ inbox_id: INBOX, display_name: 'Roomote' }], }); + }); + + it('uses the inbox the key is for, registers the webhook on it, and persists the result', async () => { mockAgentMailCreateWebhook.mockResolvedValue({ webhook_id: 'wh-1', url: expectedWebhookUrl, - secret: 'whsec_adopted', + secret: 'whsec_test', + inbox_ids: [INBOX], }); await expect( @@ -857,27 +849,50 @@ describe('comms commands', () => { values: { R_AGENTMAIL_API_KEY: 'am-key' }, }), ).resolves.toMatchObject({ - agentmail: { inboxAddress: 'existing@agentmail.to' }, + agentmail: { inboxAddress: INBOX, webhookUrl: expectedWebhookUrl }, }); - expect(mockAgentMailCreateInbox).not.toHaveBeenCalled(); + // Webhook management is addressed to the inbox, which is all an + // inbox-scoped key can reach. + expect(mockAgentMailClientConstructor).toHaveBeenLastCalledWith( + expect.objectContaining({ apiKey: 'am-key', webhookInboxId: INBOX }), + ); + expect(mockAgentMailCreateWebhook).toHaveBeenCalledWith({ + url: expectedWebhookUrl, + // The client id embeds the deployment host hash so deployments + // sharing one inbox never adopt each other's webhook. + clientId: `roomote-agentmail-webhook-${hostHash}`, + eventTypes: [ + 'message.received', + 'message.bounced', + 'message.complained', + ], + }); + expect(mockUpsertDeploymentEnvironmentVariables).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + values: expect.arrayContaining([ + { name: 'R_AGENTMAIL_API_KEY', value: 'am-key' }, + { name: 'R_AGENTMAIL_INBOX_ID', value: INBOX }, + { name: 'R_AGENTMAIL_WEBHOOK_SECRET', value: 'whsec_test' }, + ]), + }), + ); }); - it('routes by inbox_id, not the email field, when adopting', async () => { - // inbox_id is the API key (paths, webhook inbox_ids filters); it must - // win over the display email if the fields ever diverge. + it('routes by inbox_id, not the email field', async () => { mockAgentMailListInboxes.mockResolvedValue({ inboxes: [ { - inbox_id: 'Existing@agentmail.to', - email: 'display-alias@agentmail.to', + inbox_id: 'routing-id@roomote.example', + email: 'pretty@roomote.example', }, ], }); mockAgentMailCreateWebhook.mockResolvedValue({ webhook_id: 'wh-1', url: expectedWebhookUrl, - secret: 'whsec_adopted', + secret: 'whsec_test', }); await expect( @@ -887,169 +902,101 @@ describe('comms commands', () => { }), ).resolves.toMatchObject({ agentmail: { - inboxAddress: 'existing@agentmail.to', - inboxEmail: 'display-alias@agentmail.to', + inboxAddress: 'routing-id@roomote.example', + inboxEmail: 'pretty@roomote.example', }, }); - }); - - it('fails the save when the key lacks message_read', async () => { - mockAgentMailListInboxes.mockResolvedValue({ - inboxes: [{ inbox_id: 'existing@agentmail.to' }], - }); - mockAgentMailGetMessage.mockRejectedValue( - new AgentMailApiError('AgentMail GET failed (403): Forbidden', 403), + expect(mockAgentMailGetMessage).toHaveBeenCalledWith( + 'routing-id@roomote.example', + 'roomote-permission-probe', ); - - await expect( - saveCommsAuthConfigCommand(buildMockAuth(), { - provider: 'agentmail', - values: { R_AGENTMAIL_API_KEY: 'am-key' }, - }), - ).rejects.toThrow(/permission|403/i); - expect(mockAgentMailCreateWebhook).not.toHaveBeenCalled(); }); - it('fails the save when the message_read probe cannot complete (network error)', async () => { - mockAgentMailListInboxes.mockResolvedValue({ - inboxes: [{ inbox_id: 'existing@agentmail.to' }], - }); - mockAgentMailGetMessage.mockRejectedValue( - new Error('fetch failed: socket hang up'), - ); - - await expect( - saveCommsAuthConfigCommand(buildMockAuth(), { - provider: 'agentmail', - values: { R_AGENTMAIL_API_KEY: 'am-key' }, - }), - ).rejects.toThrow(/Could not reach the AgentMail API/); - expect(mockAgentMailCreateWebhook).not.toHaveBeenCalled(); - }); - - it('uses the created inbox email in the result when it differs from the id', async () => { + it('refuses a key that sees no inbox', async () => { mockAgentMailListInboxes.mockResolvedValue({ inboxes: [] }); - mockAgentMailCreateInbox.mockResolvedValue({ - inbox_id: `${expectedUsername}@agentmail.to`, - email: `${expectedUsername}-alias@agentmail.to`, - }); - mockAgentMailCreateWebhook.mockResolvedValue({ - webhook_id: 'wh-1', - url: expectedWebhookUrl, - secret: 'whsec_created', - }); await expect( saveCommsAuthConfigCommand(buildMockAuth(), { provider: 'agentmail', values: { R_AGENTMAIL_API_KEY: 'am-key' }, }), - ).resolves.toMatchObject({ - agentmail: { - inboxAddress: `${expectedUsername}@agentmail.to`, - inboxEmail: `${expectedUsername}-alias@agentmail.to`, - }, - }); + ).rejects.toThrow( + /cannot see any inbox\. In the AgentMail console, open the inbox Roomote should use and create an API key from inside it/, + ); + expect(mockAgentMailCreateWebhook).not.toHaveBeenCalled(); + expect(mockUpsertDeploymentEnvironmentVariables).not.toHaveBeenCalled(); }); - it('asks the operator to choose when the org has several inboxes', async () => { + it('refuses a key that sees several inboxes and names them', async () => { mockAgentMailListInboxes.mockResolvedValue({ inboxes: [ - { inbox_id: 'one@agentmail.to' }, - { inbox_id: 'two@agentmail.to' }, + { inbox_id: 'a@roomote.example' }, + { inbox_id: 'b@roomote.example', email: 'pretty-b@roomote.example' }, ], }); await expect( saveCommsAuthConfigCommand(buildMockAuth(), { provider: 'agentmail', - values: { R_AGENTMAIL_API_KEY: 'am-key' }, - }), - ).rejects.toThrow(/2 inboxes.*one@agentmail\.to, two@agentmail\.to/s); - expect(mockAgentMailCreateInbox).not.toHaveBeenCalled(); - }); - - it('names the failing step when a later call is refused', async () => { - mockAgentMailCreateInbox.mockRejectedValue( - new Error( - 'AgentMail POST /v0/inboxes failed (403): {"message":"Forbidden"}', - ), - ); - - await expect( - saveCommsAuthConfigCommand(buildMockAuth(), { - provider: 'agentmail', - values: { R_AGENTMAIL_API_KEY: 'am-key' }, + values: { R_AGENTMAIL_API_KEY: 'am-org-key' }, }), ).rejects.toThrow( - /refused permission while creating an inbox \(403 Forbidden\)/, + /can see 2 inboxes \(a@roomote\.example, pretty-b@roomote\.example \(b@roomote\.example\)\), so Roomote cannot tell which one is for this deployment/, ); + expect(mockUpsertDeploymentEnvironmentVariables).not.toHaveBeenCalled(); }); - it('validates the key, provisions an inbox and webhook, and persists the result', async () => { - mockAgentMailCreateInbox.mockResolvedValue({ - inbox_id: `${expectedUsername}@agentmail.to`, + it('honors an env-var-pinned inbox the key can see, and refuses one it cannot', async () => { + mockAgentMailListInboxes.mockResolvedValue({ + inboxes: [ + { inbox_id: 'a@roomote.example' }, + { inbox_id: 'b@roomote.example' }, + ], }); mockAgentMailCreateWebhook.mockResolvedValue({ webhook_id: 'wh-1', url: expectedWebhookUrl, secret: 'whsec_test', }); - - await expect( - saveCommsAuthConfigCommand(buildMockAuth(), { - provider: 'agentmail', - values: { R_AGENTMAIL_API_KEY: 'am-key' }, - }), - ).resolves.toMatchObject({ - agentmail: { - inboxAddress: `${expectedUsername}@agentmail.to`, - webhookUrl: expectedWebhookUrl, - }, + process.env.R_AGENTMAIL_INBOX_ID = 'b@roomote.example'; + mockResolveAgentMailRuntimeCredentials.mockResolvedValue({ + apiKey: null, + webhookSecret: null, + inboxId: 'b@roomote.example', }); + try { + await expect( + saveCommsAuthConfigCommand(buildMockAuth(), { + provider: 'agentmail', + values: { R_AGENTMAIL_API_KEY: 'am-org-key' }, + }), + ).resolves.toMatchObject({ + agentmail: { inboxAddress: 'b@roomote.example' }, + }); - expect(mockAgentMailListInboxes).toHaveBeenCalledOnce(); - expect(mockAgentMailCreateInbox).toHaveBeenCalledWith({ - username: expectedUsername, - clientId: `roomote-${hostHash}`, - displayName: 'Roomote', - }); - expect(mockAgentMailCreateWebhook).toHaveBeenCalledWith({ - url: expectedWebhookUrl, - // The client id embeds the deployment host hash so deployments - // sharing one AgentMail account never adopt each other's webhook. - clientId: `roomote-agentmail-webhook-${hostHash}`, - inboxIds: [`${expectedUsername}@agentmail.to`], - eventTypes: [ - 'message.received', - 'message.bounced', - 'message.complained', - ], - }); - expect(mockUpsertDeploymentEnvironmentVariables).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - values: expect.arrayContaining([ - { name: 'R_AGENTMAIL_API_KEY', value: 'am-key' }, - { - name: 'R_AGENTMAIL_INBOX_ID', - value: `${expectedUsername}@agentmail.to`, - }, - { name: 'R_AGENTMAIL_WEBHOOK_SECRET', value: 'whsec_test' }, - ]), - }), - ); + process.env.R_AGENTMAIL_INBOX_ID = 'elsewhere@roomote.example'; + mockResolveAgentMailRuntimeCredentials.mockResolvedValue({ + apiKey: null, + webhookSecret: null, + inboxId: 'elsewhere@roomote.example', + }); + await expect( + saveCommsAuthConfigCommand(buildMockAuth(), { + provider: 'agentmail', + values: { R_AGENTMAIL_API_KEY: 'am-org-key' }, + }), + ).rejects.toThrow( + /R_AGENTMAIL_INBOX_ID is set to elsewhere@roomote\.example, but this API key cannot see that inbox/, + ); + } finally { + delete process.env.R_AGENTMAIL_INBOX_ID; + } }); - it('names the refused webhook request and the scoped-key cause on a webhook 403', async () => { - mockAgentMailListInboxes.mockResolvedValue({ - inboxes: [{ inbox_id: 'roomote@roomote.me' }], - }); - // An inbox- or pod-scoped key passes the inbox checks but cannot - // reach the organization-level webhook endpoints. - mockAgentMailListWebhooks.mockRejectedValue( + it('fails the save when the key lacks message_read', async () => { + mockAgentMailGetMessage.mockRejectedValue( new AgentMailApiError( - 'AgentMail GET /v0/webhooks failed (403): {"message":"Forbidden"}', + 'AgentMail GET /v0/inboxes/x/messages/roomote-permission-probe failed (403): {"message":"Forbidden"}', 403, ), ); @@ -1057,175 +1004,56 @@ describe('comms commands', () => { await expect( saveCommsAuthConfigCommand(buildMockAuth(), { provider: 'agentmail', - values: { R_AGENTMAIL_API_KEY: 'am-scoped-key' }, + values: { R_AGENTMAIL_API_KEY: 'am-key' }, }), ).rejects.toThrow( - /refused permission while configuring the webhook \(403 Forbidden\)\. Request: GET \/v0\/webhooks \(\{"message":"Forbidden"\}\)\. A key scoped to a pod .* AgentMail Pod ID/, + /refused permission while reading inbox messages \(403 Forbidden\)\. Request: GET \/v0\/inboxes\/x\/messages\/roomote-permission-probe \(\{"message":"Forbidden"\}\)\./, ); + expect(mockAgentMailCreateWebhook).not.toHaveBeenCalled(); expect(mockUpsertDeploymentEnvironmentVariables).not.toHaveBeenCalled(); }); - it('falls back to the inbox-scoped webhook endpoints for an inbox-scoped key and records the scope', async () => { - mockAgentMailListInboxes.mockResolvedValue({ - inboxes: [{ inbox_id: 'roomote@roomote.me' }], - }); - // The organization-level listing is refused; the same key succeeds - // against the inbox's own webhook endpoints. - mockAgentMailListWebhooks - .mockRejectedValueOnce( - new AgentMailApiError( - 'AgentMail GET /v0/webhooks failed (403): {"message":"Forbidden"}', - 403, - ), - ) - .mockResolvedValueOnce({ webhooks: [] }); - mockAgentMailCreateWebhook.mockResolvedValue({ - webhook_id: 'wh-inbox', - url: expectedWebhookUrl, - secret: 'whsec_inbox', - inbox_ids: ['roomote@roomote.me'], - }); + it('fails the save when the message_read probe cannot complete (network error)', async () => { + const timeout = new Error('The operation was aborted due to timeout'); + timeout.name = 'TimeoutError'; + mockAgentMailGetMessage.mockRejectedValue(timeout); await expect( saveCommsAuthConfigCommand(buildMockAuth(), { provider: 'agentmail', - values: { R_AGENTMAIL_API_KEY: 'am-inbox-key' }, - }), - ).resolves.toMatchObject({ - agentmail: { keyScope: 'inbox', inboxAddress: 'roomote@roomote.me' }, - }); - - expect(mockAgentMailClientConstructor).toHaveBeenCalledWith( - expect.objectContaining({ - apiKey: 'am-inbox-key', - webhookInboxId: 'roomote@roomote.me', - }), - ); - expect(mockUpsertDeploymentEnvironmentVariables).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - values: expect.arrayContaining([ - { name: 'R_AGENTMAIL_KEY_SCOPE', value: 'inbox' }, - { name: 'R_AGENTMAIL_WEBHOOK_SECRET', value: 'whsec_inbox' }, - ]), + values: { R_AGENTMAIL_API_KEY: 'am-key' }, }), + ).rejects.toThrow( + 'Could not reach the AgentMail API (timed out). Check connectivity and save again.', ); + expect(mockUpsertDeploymentEnvironmentVariables).not.toHaveBeenCalled(); }); - it('re-detects the scope for a newly entered key instead of inheriting the recorded inbox scope', async () => { - const txDelete = vi.fn(() => ({ - where: vi.fn(async () => undefined), - })); - mockDbTransaction.mockImplementation(async (callback) => - callback({ delete: txDelete } as never), + it('names the refused webhook request on a webhook 403', async () => { + mockAgentMailListWebhooks.mockRejectedValue( + new AgentMailApiError( + 'AgentMail GET /v0/inboxes/roomote%40roomote.example/webhooks failed (403): {"message":"Forbidden"}', + 403, + ), ); - mockResolveAgentMailRuntimeCredentials.mockResolvedValue({ - apiKey: 'am-old-inbox-key', - webhookSecret: 'whsec_inbox', - inboxId: 'roomote@roomote.me', - podId: null, - keyScope: 'inbox', - }); - mockGetPersistedEnvironmentVariableNames.mockResolvedValue([ - 'R_AGENTMAIL_API_KEY', - 'R_AGENTMAIL_INBOX_ID', - 'R_AGENTMAIL_WEBHOOK_SECRET', - 'R_AGENTMAIL_KEY_SCOPE', - ]); - mockAgentMailGetInbox.mockResolvedValue({ - inbox_id: 'roomote@roomote.me', - }); - // The new organization-level key sees the old inbox-scoped - // registration from the organization listing and converges it. - mockAgentMailListWebhooks.mockResolvedValue({ - webhooks: [ - { - webhook_id: 'wh-inbox', - url: expectedWebhookUrl, - client_id: `roomote-agentmail-webhook-${hostHash}`, - inbox_ids: ['roomote@roomote.me'], - event_types: [ - 'message.received', - 'message.bounced', - 'message.complained', - ], - }, - ], - }); await expect( saveCommsAuthConfigCommand(buildMockAuth(), { provider: 'agentmail', - values: { - R_AGENTMAIL_API_KEY: 'am-new-org-key', - R_AGENTMAIL_INBOX_ID: 'roomote@roomote.me', - }, + values: { R_AGENTMAIL_API_KEY: 'am-key' }, }), - ).resolves.toMatchObject({ agentmail: { keyScope: 'organization' } }); - - expect(mockAgentMailClientConstructor).not.toHaveBeenCalledWith( - expect.objectContaining({ webhookInboxId: expect.anything() }), - ); - // The stale inbox-scope record is dropped with the new key. - expect(txDelete).toHaveBeenCalled(); - }); - - it('addresses the inbox webhook endpoints directly once an inbox-scoped key is recorded', async () => { - mockResolveAgentMailRuntimeCredentials.mockResolvedValue({ - apiKey: 'am-inbox-key', - webhookSecret: 'whsec_inbox', - inboxId: 'roomote@roomote.me', - podId: null, - keyScope: 'inbox', - }); - mockGetPersistedEnvironmentVariableNames.mockResolvedValue([ - 'R_AGENTMAIL_API_KEY', - 'R_AGENTMAIL_INBOX_ID', - 'R_AGENTMAIL_WEBHOOK_SECRET', - 'R_AGENTMAIL_KEY_SCOPE', - ]); - mockAgentMailGetInbox.mockResolvedValue({ - inbox_id: 'roomote@roomote.me', - }); - mockAgentMailListWebhooks.mockResolvedValue({ - webhooks: [ - { - webhook_id: 'wh-inbox', - url: expectedWebhookUrl, - client_id: `roomote-agentmail-webhook-${hostHash}`, - inbox_ids: ['roomote@roomote.me'], - event_types: [ - 'message.received', - 'message.bounced', - 'message.complained', - ], - }, - ], - }); - - await saveCommsAuthConfigCommand(buildMockAuth(), { - provider: 'agentmail', - values: { R_AGENTMAIL_INBOX_ID: 'roomote@roomote.me' }, - }); - - // No organization-level probe, so no 403 round trip: one client, inbox - // scoped from the start. - expect(mockAgentMailListWebhooks).toHaveBeenCalledOnce(); - expect(mockAgentMailClientConstructor).toHaveBeenLastCalledWith( - expect.objectContaining({ webhookInboxId: 'roomote@roomote.me' }), + ).rejects.toThrow( + /refused permission while configuring the webhook \(403 Forbidden\)\. Request: GET \/v0\/inboxes\/roomote%40roomote\.example\/webhooks \(\{"message":"Forbidden"\}\)\. In the AgentMail console/, ); - expect(mockAgentMailUpdateWebhook).not.toHaveBeenCalled(); - expect(mockAgentMailCreateWebhook).not.toHaveBeenCalled(); - - const status = await getCommsStatusCommand(buildMockAuth()); - const agentmail = status.providers.find((p) => p.id === 'agentmail'); - expect(agentmail?.agentmail?.keyScope).toBe('inbox'); - expect(agentmail?.agentmail?.webhook.status).toBe('connected'); + expect(mockUpsertDeploymentEnvironmentVariables).not.toHaveBeenCalled(); }); it('rejects a bad API key with clear copy and persists nothing', async () => { mockAgentMailListInboxes.mockRejectedValue( - new Error('AgentMail GET /v0/inboxes failed (401): Unauthorized'), + new AgentMailApiError( + 'AgentMail GET /v0/inboxes failed (401): Unauthorized', + 401, + ), ); await expect( @@ -1234,9 +1062,8 @@ describe('comms commands', () => { values: { R_AGENTMAIL_API_KEY: 'bad-key' }, }), ).rejects.toThrow( - /AgentMail rejected this API key\. Create a key in the AgentMail console with these permissions .* webhook_create/, + /AgentMail rejected this API key\. In the AgentMail console, open the inbox .* webhook_create/, ); - expect(mockUpsertDeploymentEnvironmentVariables).not.toHaveBeenCalled(); }); @@ -1253,63 +1080,20 @@ describe('comms commands', () => { ).rejects.toThrow( 'Could not reach the AgentMail API (timed out). Check connectivity and save again.', ); - expect(mockUpsertDeploymentEnvironmentVariables).not.toHaveBeenCalled(); }); - it('adopts an operator-supplied inbox after verifying the key can see it', async () => { - mockAgentMailGetInbox.mockResolvedValue({ - inbox_id: 'support@agentmail.to', - }); - mockAgentMailCreateWebhook.mockResolvedValue({ - webhook_id: 'wh-1', - url: expectedWebhookUrl, - secret: 'whsec_test', - }); - - await saveCommsAuthConfigCommand(buildMockAuth(), { - provider: 'agentmail', - values: { - R_AGENTMAIL_API_KEY: 'am-key', - R_AGENTMAIL_INBOX_ID: 'Support@AgentMail.to', - }, - }); - - expect(mockAgentMailGetInbox).toHaveBeenCalledWith( - 'support@agentmail.to', - ); - expect(mockAgentMailCreateInbox).not.toHaveBeenCalled(); - expect(mockUpsertDeploymentEnvironmentVariables).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - values: expect.arrayContaining([ - { name: 'R_AGENTMAIL_INBOX_ID', value: 'support@agentmail.to' }, - ]), - }), - ); - }); - - it('recreates a legacy client-id webhook whose URL drifted, since AgentMail webhook URLs are immutable', async () => { + it('recreates a webhook whose URL drifted, since AgentMail webhook URLs are immutable', async () => { mockResolveAgentMailRuntimeCredentials.mockResolvedValue({ apiKey: 'am-key', webhookSecret: 'whsec_existing', - inboxId: 'support@agentmail.to', - podId: null, - keyScope: 'organization', - }); - mockAgentMailCreateWebhook.mockResolvedValue({ - webhook_id: 'wh-2', - url: expectedWebhookUrl, - secret: 'whsec_fresh', + inboxId: INBOX, }); mockGetPersistedEnvironmentVariableNames.mockResolvedValue([ 'R_AGENTMAIL_API_KEY', 'R_AGENTMAIL_INBOX_ID', 'R_AGENTMAIL_WEBHOOK_SECRET', ]); - mockAgentMailGetInbox.mockResolvedValue({ - inbox_id: 'support@agentmail.to', - }); mockAgentMailListWebhooks.mockResolvedValue({ webhooks: [ { @@ -1317,22 +1101,24 @@ describe('comms commands', () => { url: 'https://old-deployment.example.com/api/webhooks/agentmail', // Pre-hash client id from an earlier release. client_id: 'roomote-agentmail-webhook', - inbox_ids: ['support@agentmail.to'], + inbox_ids: [INBOX], }, ], }); + mockAgentMailCreateWebhook.mockResolvedValue({ + webhook_id: 'wh-2', + url: expectedWebhookUrl, + secret: 'whsec_fresh', + }); await saveCommsAuthConfigCommand(buildMockAuth(), { provider: 'agentmail', - values: { R_AGENTMAIL_INBOX_ID: 'support@agentmail.to' }, + values: {}, }); expect(mockAgentMailDeleteWebhook).toHaveBeenCalledWith('wh-1'); expect(mockAgentMailCreateWebhook).toHaveBeenCalledWith( - expect.objectContaining({ - url: expectedWebhookUrl, - inboxIds: ['support@agentmail.to'], - }), + expect.objectContaining({ url: expectedWebhookUrl }), ); expect(mockAgentMailUpdateWebhook).not.toHaveBeenCalled(); expect(mockUpsertDeploymentEnvironmentVariables).toHaveBeenCalledWith( @@ -1345,44 +1131,35 @@ describe('comms commands', () => { ); }); - it('re-scopes the webhook inbox_ids in place when the configured inbox changes', async () => { + it('converges event types in place when only they drifted', async () => { mockResolveAgentMailRuntimeCredentials.mockResolvedValue({ apiKey: 'am-key', webhookSecret: 'whsec_existing', - inboxId: 'old-inbox@agentmail.to', - podId: null, - keyScope: 'organization', + inboxId: INBOX, }); mockGetPersistedEnvironmentVariableNames.mockResolvedValue([ 'R_AGENTMAIL_API_KEY', 'R_AGENTMAIL_INBOX_ID', 'R_AGENTMAIL_WEBHOOK_SECRET', ]); - mockAgentMailGetInbox.mockResolvedValue({ - inbox_id: 'new-inbox@agentmail.to', - }); mockAgentMailListWebhooks.mockResolvedValue({ webhooks: [ { webhook_id: 'wh-1', - // URL already matches; only the inbox scoping drifted. url: expectedWebhookUrl, client_id: `roomote-agentmail-webhook-${hostHash}`, - inbox_ids: ['old-inbox@agentmail.to'], + inbox_ids: [INBOX], + event_types: ['message.received'], }, ], }); await saveCommsAuthConfigCommand(buildMockAuth(), { provider: 'agentmail', - values: { R_AGENTMAIL_INBOX_ID: 'new-inbox@agentmail.to' }, + values: {}, }); - // AgentMail's update takes add/remove lists and a full event-type - // replacement; the fixture carries no event types, so they converge too. expect(mockAgentMailUpdateWebhook).toHaveBeenCalledWith('wh-1', { - addInboxIds: ['new-inbox@agentmail.to'], - removeInboxIds: ['old-inbox@agentmail.to'], eventTypes: [ 'message.received', 'message.bounced', @@ -1393,98 +1170,24 @@ describe('comms commands', () => { expect(mockAgentMailDeleteWebhook).not.toHaveBeenCalled(); }); - it('addresses AgentMail through the pod when a pod id is entered and persists it', async () => { - mockAgentMailCreateInbox.mockResolvedValue({ - pod_id: 'pod_acme', - inbox_id: `${expectedUsername}@agentmail.to`, - }); - mockAgentMailCreateWebhook.mockResolvedValue({ - webhook_id: 'wh-1', - url: expectedWebhookUrl, - secret: 'whsec_pod', - }); - - await expect( - saveCommsAuthConfigCommand(buildMockAuth(), { - provider: 'agentmail', - values: { - R_AGENTMAIL_API_KEY: 'am-pod-key', - R_AGENTMAIL_POD_ID: ' pod_acme ', - }, - }), - ).resolves.toMatchObject({ - agentmail: { - podId: 'pod_acme', - inboxAddress: `${expectedUsername}@agentmail.to`, - }, - }); - - // Every management call goes through a client bound to the pod, which - // is what a pod-scoped key can reach. - expect(mockAgentMailClientConstructor).toHaveBeenCalledWith( - expect.objectContaining({ apiKey: 'am-pod-key', podId: 'pod_acme' }), - ); - expect(mockUpsertDeploymentEnvironmentVariables).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - values: expect.arrayContaining([ - { name: 'R_AGENTMAIL_POD_ID', value: 'pod_acme' }, - { name: 'R_AGENTMAIL_WEBHOOK_SECRET', value: 'whsec_pod' }, - ]), - }), - ); - }); - - it('names the pod when the key cannot see it', async () => { - mockAgentMailListInboxes.mockRejectedValue( - new AgentMailApiError( - 'AgentMail GET /v0/pods/pod_missing/inboxes failed (404): Not Found', - 404, - ), - ); - - await expect( - saveCommsAuthConfigCommand(buildMockAuth(), { - provider: 'agentmail', - values: { - R_AGENTMAIL_API_KEY: 'am-key', - R_AGENTMAIL_POD_ID: 'pod_missing', - }, - }), - ).rejects.toThrow(/could not find the pod pod_missing/); - expect(mockUpsertDeploymentEnvironmentVariables).not.toHaveBeenCalled(); - }); - - it('drops a saved pod id when the field is submitted empty', async () => { - const txDelete = vi.fn(() => ({ - where: vi.fn(async () => undefined), - })); - mockDbTransaction.mockImplementation(async (callback) => - callback({ delete: txDelete } as never), - ); + it('leaves a fully converged webhook untouched', async () => { mockResolveAgentMailRuntimeCredentials.mockResolvedValue({ apiKey: 'am-key', webhookSecret: 'whsec_existing', - inboxId: 'support@agentmail.to', - podId: 'pod_old', - keyScope: 'organization', + inboxId: INBOX, }); mockGetPersistedEnvironmentVariableNames.mockResolvedValue([ 'R_AGENTMAIL_API_KEY', 'R_AGENTMAIL_INBOX_ID', - 'R_AGENTMAIL_POD_ID', 'R_AGENTMAIL_WEBHOOK_SECRET', ]); - mockAgentMailGetInbox.mockResolvedValue({ - inbox_id: 'support@agentmail.to', - }); mockAgentMailListWebhooks.mockResolvedValue({ webhooks: [ { webhook_id: 'wh-1', url: expectedWebhookUrl, client_id: `roomote-agentmail-webhook-${hostHash}`, - inbox_ids: ['support@agentmail.to'], + inbox_ids: [INBOX], event_types: [ 'message.received', 'message.bounced', @@ -1496,72 +1199,32 @@ describe('comms commands', () => { await saveCommsAuthConfigCommand(buildMockAuth(), { provider: 'agentmail', - values: { - R_AGENTMAIL_INBOX_ID: 'support@agentmail.to', - R_AGENTMAIL_POD_ID: '', - }, + values: {}, }); - // The reconcile ran at organization level and the stale pod is removed. - expect(mockAgentMailClientConstructor).toHaveBeenCalledWith( - expect.not.objectContaining({ podId: expect.anything() }), - ); - expect(txDelete).toHaveBeenCalled(); + expect(mockAgentMailUpdateWebhook).not.toHaveBeenCalled(); + expect(mockAgentMailCreateWebhook).not.toHaveBeenCalled(); + expect(mockAgentMailDeleteWebhook).not.toHaveBeenCalled(); }); - it('leaves a fully converged webhook untouched', async () => { + it("never adopts another deployment's webhook with a different host hash", async () => { mockResolveAgentMailRuntimeCredentials.mockResolvedValue({ apiKey: 'am-key', - webhookSecret: 'whsec_existing', - inboxId: 'support@agentmail.to', - podId: null, - keyScope: 'organization', + webhookSecret: null, + inboxId: INBOX, }); mockGetPersistedEnvironmentVariableNames.mockResolvedValue([ 'R_AGENTMAIL_API_KEY', 'R_AGENTMAIL_INBOX_ID', 'R_AGENTMAIL_WEBHOOK_SECRET', ]); - mockAgentMailGetInbox.mockResolvedValue({ - inbox_id: 'support@agentmail.to', - }); - mockAgentMailListWebhooks.mockResolvedValue({ - webhooks: [ - { - webhook_id: 'wh-1', - url: expectedWebhookUrl, - client_id: `roomote-agentmail-webhook-${hostHash}`, - inbox_ids: ['support@agentmail.to'], - event_types: [ - 'message.received', - 'message.bounced', - 'message.complained', - ], - }, - ], - }); - - await saveCommsAuthConfigCommand(buildMockAuth(), { - provider: 'agentmail', - values: { R_AGENTMAIL_INBOX_ID: 'support@agentmail.to' }, - }); - - expect(mockAgentMailUpdateWebhook).not.toHaveBeenCalled(); - expect(mockAgentMailCreateWebhook).not.toHaveBeenCalled(); - expect(mockAgentMailDeleteWebhook).not.toHaveBeenCalled(); - }); - - it("never adopts another deployment's webhook with a different host hash", async () => { - mockAgentMailGetInbox.mockResolvedValue({ - inbox_id: 'support@agentmail.to', - }); mockAgentMailListWebhooks.mockResolvedValue({ webhooks: [ { webhook_id: 'wh-other', - url: 'https://other-deployment.example.com/api/webhooks/agentmail', + url: 'https://other.example.com/api/webhooks/agentmail', client_id: 'roomote-agentmail-webhook-ffffff', - inbox_ids: ['other@agentmail.to'], + inbox_ids: [INBOX], }, ], }); @@ -1573,201 +1236,15 @@ describe('comms commands', () => { await saveCommsAuthConfigCommand(buildMockAuth(), { provider: 'agentmail', - values: { - R_AGENTMAIL_API_KEY: 'am-key', - R_AGENTMAIL_INBOX_ID: 'support@agentmail.to', - }, + values: {}, }); - expect(mockAgentMailUpdateWebhook).not.toHaveBeenCalled(); expect(mockAgentMailDeleteWebhook).not.toHaveBeenCalled(); - expect(mockAgentMailCreateWebhook).toHaveBeenCalledWith({ - url: expectedWebhookUrl, - clientId: `roomote-agentmail-webhook-${hostHash}`, - inboxIds: ['support@agentmail.to'], - eventTypes: [ - 'message.received', - 'message.bounced', - 'message.complained', - ], - }); - }); - - it('creates the proposal inbox when the chooser requests it and it is missing', async () => { - mockAgentMailGetInbox.mockRejectedValue( - new Error('AgentMail GET /v0/inboxes/x failed (404): Not Found'), - ); - mockAgentMailCreateInbox.mockResolvedValue({ - inbox_id: `${expectedUsername}@agentmail.to`, - }); - mockAgentMailCreateWebhook.mockResolvedValue({ - webhook_id: 'wh-1', - url: expectedWebhookUrl, - secret: 'whsec_test', - }); - - await expect( - saveCommsAuthConfigCommand(buildMockAuth(), { - provider: 'agentmail', - values: { - R_AGENTMAIL_API_KEY: 'am-key', - R_AGENTMAIL_INBOX_ID: `${expectedUsername}@agentmail.to`, - }, - }), - ).resolves.toMatchObject({ - agentmail: { inboxAddress: `${expectedUsername}@agentmail.to` }, - }); - - expect(mockAgentMailCreateInbox).toHaveBeenCalledWith({ - username: expectedUsername, - clientId: `roomote-${hostHash}`, - displayName: 'Roomote', - }); - expect(mockUpsertDeploymentEnvironmentVariables).toHaveBeenCalledWith( - expect.anything(), + expect(mockAgentMailUpdateWebhook).not.toHaveBeenCalled(); + expect(mockAgentMailCreateWebhook).toHaveBeenCalledWith( expect.objectContaining({ - values: expect.arrayContaining([ - { - name: 'R_AGENTMAIL_INBOX_ID', - value: `${expectedUsername}@agentmail.to`, - }, - ]), - }), - ); - }); - - it('still rejects a missing inbox that is not the deployment proposal', async () => { - mockAgentMailGetInbox.mockRejectedValue( - new Error('AgentMail GET /v0/inboxes/x failed (404): Not Found'), - ); - - await expect( - saveCommsAuthConfigCommand(buildMockAuth(), { - provider: 'agentmail', - values: { - R_AGENTMAIL_API_KEY: 'am-key', - R_AGENTMAIL_INBOX_ID: 'missing@agentmail.to', - }, - }), - ).rejects.toThrow( - /could not find the inbox missing@agentmail\.to with this API key/u, - ); - - expect(mockAgentMailCreateInbox).not.toHaveBeenCalled(); - expect(mockUpsertDeploymentEnvironmentVariables).not.toHaveBeenCalled(); - }); - - it('surfaces a taken username inline with guidance to pick an address', async () => { - mockAgentMailCreateInbox.mockRejectedValue( - new Error( - 'AgentMail POST /v0/inboxes failed (409): Inbox already exists', - ), - ); - - await expect( - saveCommsAuthConfigCommand(buildMockAuth(), { - provider: 'agentmail', - values: { R_AGENTMAIL_API_KEY: 'am-key' }, + clientId: `roomote-agentmail-webhook-${hostHash}`, }), - ).rejects.toThrow(/already taken at AgentMail/u); - - expect(mockUpsertDeploymentEnvironmentVariables).not.toHaveBeenCalled(); - }); - }); - - describe('listAgentMailInboxesCommand', () => { - const hostHash = createHash('sha256') - .update('app.example.com') - .digest('hex') - .slice(0, 6); - const proposedNewAddress = `roomote-app-example-com-${hostHash}@agentmail.to`; - - it('rejects non-admin users', async () => { - await expect( - listAgentMailInboxesCommand(buildMockAuth({ isAdmin: false }), {}), - ).rejects.toThrow('Unauthorized'); - }); - - it('lists normalized inboxes with the entered key even when one is saved', async () => { - mockResolveAgentMailRuntimeCredentials.mockResolvedValue({ - apiKey: 'saved-key', - webhookSecret: null, - inboxId: null, - podId: null, - keyScope: 'organization', - }); - mockAgentMailListInboxes.mockResolvedValue({ - inboxes: [ - { inbox_id: 'One@AgentMail.to' }, - { inbox_id: 'two@agentmail.to' }, - ], - }); - - await expect( - listAgentMailInboxesCommand(buildMockAuth(), { - apiKey: ' typed-key ', - }), - ).resolves.toEqual({ - inboxes: [ - { inboxId: 'one@agentmail.to', email: 'one@agentmail.to' }, - { inboxId: 'two@agentmail.to', email: 'two@agentmail.to' }, - ], - proposedNewAddress, - }); - - expect(mockAgentMailClientConstructor).toHaveBeenCalledWith( - expect.objectContaining({ apiKey: 'typed-key' }), - ); - }); - - it('falls back to the saved API key when none is entered', async () => { - mockResolveAgentMailRuntimeCredentials.mockResolvedValue({ - apiKey: 'saved-key', - webhookSecret: null, - inboxId: null, - podId: null, - keyScope: 'organization', - }); - mockAgentMailListInboxes.mockResolvedValue({ - inboxes: [{ inbox_id: 'existing@agentmail.to' }], - }); - - await expect( - listAgentMailInboxesCommand(buildMockAuth(), {}), - ).resolves.toEqual({ - inboxes: [ - { - inboxId: 'existing@agentmail.to', - email: 'existing@agentmail.to', - }, - ], - proposedNewAddress, - }); - - expect(mockAgentMailClientConstructor).toHaveBeenCalledWith( - expect.objectContaining({ apiKey: 'saved-key' }), - ); - }); - - it('errors clearly when no API key is entered or saved', async () => { - await expect( - listAgentMailInboxesCommand(buildMockAuth(), {}), - ).rejects.toThrow( - 'Enter an AgentMail API key to load the account inboxes.', - ); - - expect(mockAgentMailListInboxes).not.toHaveBeenCalled(); - }); - - it('classifies a refused key with the required permissions copy', async () => { - mockAgentMailListInboxes.mockRejectedValue( - new Error('AgentMail GET /v0/inboxes failed (403): Forbidden'), - ); - - await expect( - listAgentMailInboxesCommand(buildMockAuth(), { apiKey: 'bad-key' }), - ).rejects.toThrow( - /AgentMail rejected this API key\. Create a key in the AgentMail console with these permissions .* webhook_create/, ); }); }); @@ -1785,8 +1262,6 @@ describe('comms commands', () => { apiKey: 'am-key', webhookSecret: 'whsec_existing', inboxId: 'support@agentmail.to', - podId: null, - keyScope: 'organization', }); mockAgentMailListWebhooks.mockResolvedValue({ webhooks: [ @@ -1824,8 +1299,6 @@ describe('comms commands', () => { apiKey: 'am-key', webhookSecret: 'whsec_existing', inboxId: 'support@agentmail.to', - podId: null, - keyScope: 'organization', }); mockAgentMailListWebhooks.mockRejectedValue( new Error('AgentMail GET /v0/webhooks failed (500)'), @@ -1848,8 +1321,6 @@ describe('comms commands', () => { apiKey: 'am-key', webhookSecret: 'whsec_existing', inboxId: 'support@agentmail.to', - podId: null, - keyScope: 'organization', }); }); diff --git a/apps/web/src/trpc/commands/comms/index.ts b/apps/web/src/trpc/commands/comms/index.ts index 8e826f8f8e..90ae70c574 100644 --- a/apps/web/src/trpc/commands/comms/index.ts +++ b/apps/web/src/trpc/commands/comms/index.ts @@ -7,7 +7,6 @@ import { invalidateDiscordRuntimeCredentialsCache, normalizeDiscordBotToken, resolveAgentMailRuntimeCredentials, - type AgentMailKeyScope, resolveDiscordGatewaySecret, resolveDiscordRuntimeCredentials, validateDiscordBotToken, @@ -47,7 +46,6 @@ import { } from '@roomote/sdk/server'; import { Env, isEmailChannelEnabled } from '@/lib/server/env'; -import { buildDeploymentAppName } from '@/lib/server/deployment-app-name'; import { DISCORD_INSTALL_PERMISSIONS } from '@/lib/discord-install'; import { PRODUCT_NAME, @@ -140,18 +138,6 @@ const ADDITIONAL_COMMS_PROVIDERS: Record< label: 'AgentMail API Key', secret: true, }, - { - envVarName: 'R_AGENTMAIL_POD_ID', - acceptedEnvVarNames: ['R_AGENTMAIL_POD_ID'], - label: 'AgentMail Pod ID', - required: false, - }, - { - envVarName: 'R_AGENTMAIL_INBOX_ID', - acceptedEnvVarNames: ['R_AGENTMAIL_INBOX_ID'], - label: 'Inbox Email Address', - required: false, - }, ], }, }; @@ -213,11 +199,7 @@ type AgentMailWebhookStatus = { }; export type AgentMailCommsStatus = { - /** The AgentMail pod the inbox and webhook live in, when pod-scoped. */ - podId: string | null; - /** 'inbox' when the key is inbox-scoped and the webhook lives on the inbox. */ - keyScope: AgentMailKeyScope; - /** The routed inbox_id (the persisted configuration value). */ + /** The routed inbox_id (derived from the key on save and persisted). */ inboxAddress: string | null; /** The deliverable address for display, resolved live from AgentMail. */ inboxEmail: string | null; @@ -659,48 +641,26 @@ const AGENTMAIL_API_TIMEOUT_MS = 5_000; * orphaned. */ const AGENTMAIL_LEGACY_WEBHOOK_CLIENT_ID = 'roomote-agentmail-webhook'; -const AGENTMAIL_INBOX_HASH_LENGTH = 6; +const AGENTMAIL_HOST_HASH_LENGTH = 6; function buildExpectedAgentMailWebhookUrl(): string { return new URL('/api/webhooks/agentmail', Env.R_APP_URL).toString(); } -function createAgentMailApiClient( - apiKey: string, - podId: string | null, - webhookInboxId: string | null = null, -) { +/** + * Every AgentMail call the deployment makes is addressed to its one inbox: + * message reads and sends by design, and webhook management through the + * inbox's own endpoints, which is all an inbox-scoped key can reach (an + * organization-level key can reach them too). + */ +function createAgentMailApiClient(apiKey: string, inboxId: string | null) { return new AgentMailApiClient({ apiKey, - ...(podId ? { podId } : {}), - ...(webhookInboxId ? { webhookInboxId } : {}), + ...(inboxId ? { webhookInboxId: inboxId } : {}), timeoutMs: AGENTMAIL_API_TIMEOUT_MS, }); } -/** The inbox to manage webhooks under, when the saved key is inbox-scoped. */ -function webhookInboxForScope(credentials: { - keyScope: AgentMailKeyScope; - inboxId: string | null; - podId: string | null; -}): string | null { - return credentials.keyScope === 'inbox' && !credentials.podId - ? credentials.inboxId - : null; -} - -function isAgentMailPermissionError(error: unknown): boolean { - return ( - error instanceof AgentMailApiError && - (error.status === 401 || error.status === 403) - ); -} - -function normalizeAgentMailPodId(value: string | null | undefined) { - const trimmed = value?.trim(); - return trimmed || null; -} - const EMAIL_CHANNEL_DISABLED_MESSAGE = 'Email is not enabled for this deployment. Set R_EMAIL_CHANNEL_ENABLED=true and restart to configure it.'; @@ -729,22 +689,24 @@ function describeAgentMailRequest(message: string): string | null { return clipped ? `${method} ${path} (${clipped})` : `${method} ${path}`; } -/** Map AgentMail API / network failures into admin-facing setup copy. */ /** * AgentMail keys carry fine-grained permissions * (https://docs.agentmail.to/core-concepts/permissions); this is the full set - * the channel needs across setup, inbound processing, and replies. + * the channel needs across setup, inbound processing, and replies. An + * inbox-scoped key with these permissions is the intended shape. */ const AGENTMAIL_REQUIRED_PERMISSIONS = - 'inbox_read, inbox_create, inbox_update, webhook_read, webhook_create, webhook_update, webhook_delete, message_read, message_send'; + 'inbox_read, inbox_update, webhook_read, webhook_create, webhook_update, webhook_delete, message_read, message_send'; +const AGENTMAIL_KEY_GUIDANCE = + 'In the AgentMail console, open the inbox Roomote should use and create an API key from inside it (an inbox-scoped key) with these permissions or full access'; + +/** Map AgentMail API / network failures into admin-facing setup copy. */ function classifyAgentMailSetupError( error: unknown, operation: | 'validating the API key' - | 'reading the inbox' | 'reading inbox messages' - | 'creating an inbox' | 'configuring the webhook' = 'validating the API key', ): string { const message = error instanceof Error ? error.message : String(error); @@ -780,35 +742,29 @@ function classifyAgentMailSetupError( lower.includes('forbidden') || lower.includes('invalid api key') ) { - // Name the exact request AgentMail refused: the same key can pass the - // inbox checks and still be refused on the organization-level webhook - // endpoints when it is scoped to an inbox or a pod, and "check your - // permissions" alone sends the operator in circles. + // Name the exact request AgentMail refused; "check your permissions" + // alone sends the operator in circles. const request = describeAgentMailRequest(message); const requestDetail = request ? ` Request: ${request}.` : ''; if (operation === 'validating the API key') { - return `AgentMail rejected this API key. Create a key in the AgentMail console with these permissions (or full access) and save again: ${AGENTMAIL_REQUIRED_PERMISSIONS}.${requestDetail}`; + return `AgentMail rejected this API key. ${AGENTMAIL_KEY_GUIDANCE}, then save again: ${AGENTMAIL_REQUIRED_PERMISSIONS}.${requestDetail}`; } - const scopeHint = - operation === 'configuring the webhook' - ? ' A key scoped to a pod is refused here even with full permissions unless the AgentMail Pod ID is entered, so Roomote registers the webhook inside that pod.' - : ''; - return `AgentMail refused permission while ${operation} (${message.includes('(403)') ? '403 Forbidden' : '401 Unauthorized'}).${requestDetail}${scopeHint} Otherwise create a key with these permissions (or full access) and save again: ${AGENTMAIL_REQUIRED_PERMISSIONS}.`; + return `AgentMail refused permission while ${operation} (${message.includes('(403)') ? '403 Forbidden' : '401 Unauthorized'}).${requestDetail} ${AGENTMAIL_KEY_GUIDANCE}, then save again: ${AGENTMAIL_REQUIRED_PERMISSIONS}.`; } return `AgentMail failed while ${operation}: ${message.trim() || 'could not connect.'}`; } /** - * Short stable hash of the deployment's public hostname. Keys both the inbox - * proposal and the webhook client id, so two deployments sharing one - * AgentMail account never adopt (or delete) each other's resources. + * Short stable hash of the deployment's public hostname, keying the webhook + * client id so two deployments sharing one inbox or account never adopt (or + * delete) each other's registration. */ function buildAgentMailHostHash(publicAppUrl: string): string { return createHash('sha256') .update(new URL(publicAppUrl).hostname) .digest('hex') - .slice(0, AGENTMAIL_INBOX_HASH_LENGTH); + .slice(0, AGENTMAIL_HOST_HASH_LENGTH); } function buildAgentMailWebhookClientId(publicAppUrl: string): string { @@ -838,32 +794,6 @@ function findRoomoteAgentMailWebhook( ); } -/** - * Propose a deterministic inbox username for this deployment: the shared - * deployment app name plus a short hash of the full public hostname so - * truncated app names cannot collide across deployments. The same hash keys - * the createInbox client id, which makes inbox creation idempotent across - * re-saves. - */ -function buildAgentMailInboxProposal(publicAppUrl: string): { - username: string; - clientId: string; -} { - const hostHash = buildAgentMailHostHash(publicAppUrl); - const username = `${buildDeploymentAppName(publicAppUrl).toLowerCase()}-${hostHash}`; - - return { username, clientId: `roomote-${hostHash}` }; -} - -/** Default domain AgentMail assigns to inboxes created without a domain. */ -const AGENTMAIL_DEFAULT_INBOX_DOMAIN = 'agentmail.to'; - -function buildAgentMailProposedInboxAddress(proposal: { - username: string; -}): string { - return `${proposal.username}@${AGENTMAIL_DEFAULT_INBOX_DOMAIN}`; -} - function normalizeAgentMailInboxAddress( value: string | null | undefined, ): string | null { @@ -916,8 +846,7 @@ async function getAgentMailCommsStatus(): Promise { const expectedUrl = buildExpectedAgentMailWebhookUrl(); const client = createAgentMailApiClient( credentials.apiKey, - credentials.podId, - webhookInboxForScope(credentials), + credentials.inboxId, ); try { @@ -948,8 +877,6 @@ async function getAgentMailCommsStatus(): Promise { registeredInboxIds.includes(credentials.inboxId); return { - podId: credentials.podId, - keyScope: credentials.keyScope, inboxAddress: credentials.inboxId, inboxEmail, webhook: { @@ -965,8 +892,6 @@ async function getAgentMailCommsStatus(): Promise { }; } catch (error) { return { - podId: credentials.podId, - keyScope: credentials.keyScope, inboxAddress: credentials.inboxId, inboxEmail: null, webhook: { @@ -979,70 +904,7 @@ async function getAgentMailCommsStatus(): Promise { } } -/** - * List the AgentMail inboxes the given (or saved) API key can see, plus the - * deployment's proposed new-inbox address, so the settings UI can offer a - * chooser instead of a free-text inbox field. Read-only: nothing is created - * or persisted here. - */ -export async function listAgentMailInboxesCommand( - auth: UserAuthSuccess, - input: { apiKey?: string; podId?: string } = {}, -): Promise<{ - inboxes: Array<{ inboxId: string; email: string }>; - proposedNewAddress: string; -}> { - assertAdmin(auth); - assertEmailChannelEnabled(); - - invalidateAgentMailRuntimeCredentialsCache(); - const existing = await resolveAgentMailRuntimeCredentials(); - const apiKey = input.apiKey?.trim() || existing.apiKey; - // A pod typed alongside a new key scopes the listing to that pod; with no - // key typed, the saved pod (if any) applies so the chooser shows what the - // saved key can actually reach. - const podId = - input.podId !== undefined - ? normalizeAgentMailPodId(input.podId) - : existing.podId; - - if (!apiKey) { - throw new Error('Enter an AgentMail API key to load the account inboxes.'); - } - - const client = createAgentMailApiClient(apiKey, podId); - - try { - const listed = await client.listInboxes(); - const inboxes = (listed.inboxes ?? []) - .map((inbox) => { - const inboxId = readAgentMailInboxAddress(inbox); - return inboxId - ? { inboxId, email: readAgentMailInboxEmail(inbox) ?? inboxId } - : null; - }) - .filter((entry): entry is { inboxId: string; email: string } => - Boolean(entry), - ); - - return { - inboxes, - proposedNewAddress: buildAgentMailProposedInboxAddress( - buildAgentMailInboxProposal(Env.R_APP_URL), - ), - }; - } catch (error) { - throw new Error( - classifyAgentMailSetupError(error, 'validating the API key'), - ); - } -} - type AgentMailReconcileResult = { - /** The pod the inbox and webhook were reconciled in, when pod-scoped. */ - podId: string | null; - /** 'inbox' when the key turned out to be inbox-scoped. */ - keyScope: AgentMailKeyScope; /** The routed inbox_id — persisted and used in API paths/webhook scoping. */ inboxAddress: string; /** The deliverable address, display only. */ @@ -1052,62 +914,24 @@ type AgentMailReconcileResult = { }; /** - * Create the deployment's proposed inbox (idempotent via the proposal client - * id) and return its normalized routing address plus the deliverable email. - * Shared by the blank-inbox provision path and the chooser's explicit - * "create new" path. - */ -async function createProposedAgentMailInbox( - client: AgentMailApiClient, - proposal: { username: string; clientId: string }, -): Promise<{ inboxAddress: string; inboxEmail: string }> { - try { - const inbox = await client.createInbox({ - username: proposal.username, - clientId: proposal.clientId, - displayName: PRODUCT_NAME, - }); - const createdAddress = readAgentMailInboxAddress(inbox); - if (!createdAddress) { - throw new Error('AgentMail created an inbox but returned no inbox id.'); - } - return { - inboxAddress: createdAddress, - inboxEmail: readAgentMailInboxEmail(inbox) ?? createdAddress, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (/\(409\)|already exists|already taken/iu.test(message)) { - throw new Error( - `The email address ${proposal.username} is already taken at AgentMail. Enter an inbox email address of your own in the Inbox Email Address field and save again.`, - ); - } - throw new Error(classifyAgentMailSetupError(error, 'creating an inbox')); - } -} - -/** - * Reconcile the AgentMail account against this deployment before persisting - * anything: validate the API key, adopt or provision the inbox, and converge - * the webhook registration on this deployment's URL. Every step is idempotent - * (inbox and webhook creation are keyed by client id), so a partial failure - * is fixed by saving again. Failures throw with admin-facing copy and abort - * the save so credentials are never persisted half-configured. + * Reconcile AgentMail against this deployment before persisting anything: + * validate the API key, resolve the one inbox it is for, and converge the + * webhook registration on this deployment's URL. The inbox is derived from + * the key, never entered: the intended key is inbox-scoped (it sees exactly + * its inbox), and an organization-level key is accepted only while the + * account has exactly one inbox, so there is never a wrong inbox to pick. + * Every step is idempotent (the webhook is keyed by client id), so a + * partial failure is fixed by saving again. Failures throw with + * admin-facing copy and abort the save so credentials are never persisted + * half-configured. */ async function reconcileAgentMailSetup(input: { enteredApiKey: string | null; - enteredInboxId: string | null; - /** Null clears a saved pod; undefined keeps it. */ - enteredPodId: string | null | undefined; }): Promise { assertEmailChannelEnabled(); invalidateAgentMailRuntimeCredentialsCache(); const existing = await resolveAgentMailRuntimeCredentials(); const apiKey = input.enteredApiKey ?? existing.apiKey; - const podId = - input.enteredPodId !== undefined - ? normalizeAgentMailPodId(input.enteredPodId) - : existing.podId; if (!apiKey) { throw new Error( @@ -1115,23 +939,17 @@ async function reconcileAgentMailSetup(input: { ); } - // A pod-scoped key can only reach its own pod, and an org key asked to - // work inside a pod keeps every resource it creates there: either way the - // management calls below are pod-addressed once a pod id is configured. - const client = createAgentMailApiClient(apiKey, podId); - - // Prove the key authenticates with the cheapest read before touching - // anything else, so a bad key fails with a clear message instead of a - // confusing inbox or webhook error. - const orgInboxes: string[] = []; + // Prove the key authenticates with the cheapest read, which also tells us + // which inbox the key is for. + const visibleInboxes: string[] = []; const inboxDisplayNames = new Map(); const inboxEmails = new Map(); try { - const listed = await client.listInboxes(); + const listed = await createAgentMailApiClient(apiKey, null).listInboxes(); for (const inbox of listed.inboxes ?? []) { const address = readAgentMailInboxAddress(inbox); if (!address) continue; - orgInboxes.push(address); + visibleInboxes.push(address); inboxDisplayNames.set( address, typeof inbox.display_name === 'string' ? inbox.display_name : null, @@ -1142,79 +960,45 @@ async function reconcileAgentMailSetup(input: { } } } catch (error) { - if (podId && error instanceof AgentMailApiError && error.status === 404) { - throw new Error( - `AgentMail could not find the pod ${podId} with this API key. Check the AgentMail Pod ID, or clear it to use the organization's inboxes.`, - ); - } throw new Error( classifyAgentMailSetupError(error, 'validating the API key'), ); } - const requestedInboxId = - normalizeAgentMailInboxAddress(input.enteredInboxId) ?? existing.inboxId; - let inboxAddress: string; - - if (requestedInboxId) { - try { - const inbox = await client.getInbox(requestedInboxId); - inboxAddress = readAgentMailInboxAddress(inbox) ?? requestedInboxId; - const email = readAgentMailInboxEmail(inbox); - if (email) { - inboxEmails.set(inboxAddress, email); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (/\(404\)|not found/iu.test(message)) { - const proposal = buildAgentMailInboxProposal(Env.R_APP_URL); - if (requestedInboxId === buildAgentMailProposedInboxAddress(proposal)) { - // The inbox chooser's "create new" option submits the deployment's - // proposal address explicitly, so a 404 here means it does not - // exist yet: create it instead of erroring. - const created = await createProposedAgentMailInbox(client, proposal); - inboxAddress = created.inboxAddress; - inboxEmails.set(created.inboxAddress, created.inboxEmail); - } else { - throw new Error( - `AgentMail could not find the inbox ${requestedInboxId} with this API key. Check the inbox email address, or clear it to let Roomote create one.`, - ); - } - } else { - throw new Error( - classifyAgentMailSetupError(error, 'reading the inbox'), - ); - } - } - } else if (orgInboxes.length === 1) { - // The org already has exactly one inbox (the console provisions one at - // signup): adopt it instead of trying to create a second — free-tier - // plans often cannot, and a surprise extra inbox helps nobody. - inboxAddress = orgInboxes[0]!; - } else if (orgInboxes.length > 1) { + // An env-var-pinned inbox wins, provided the key can actually see it. + const pinnedInboxId = process.env.R_AGENTMAIL_INBOX_ID?.trim() + ? existing.inboxId + : null; + if (pinnedInboxId && !visibleInboxes.includes(pinnedInboxId)) { throw new Error( - `This AgentMail account has ${orgInboxes.length} inboxes. Enter the one Roomote should use in the Inbox Email Address field: ${orgInboxes + `R_AGENTMAIL_INBOX_ID is set to ${pinnedInboxId}, but this API key cannot see that inbox. ${AGENTMAIL_KEY_GUIDANCE}, then save again: ${AGENTMAIL_REQUIRED_PERMISSIONS}.`, + ); + } + if (visibleInboxes.length === 0) { + throw new Error( + `This API key cannot see any inbox. ${AGENTMAIL_KEY_GUIDANCE}, then save again: ${AGENTMAIL_REQUIRED_PERMISSIONS}.`, + ); + } + if (!pinnedInboxId && visibleInboxes.length > 1) { + throw new Error( + `This API key can see ${visibleInboxes.length} inboxes (${visibleInboxes .map((address) => formatAgentMailInboxLabel(address, inboxEmails.get(address) ?? null), ) - .join(', ')}`, + .join( + ', ', + )}), so Roomote cannot tell which one is for this deployment. ${AGENTMAIL_KEY_GUIDANCE}, then save again: ${AGENTMAIL_REQUIRED_PERMISSIONS}.`, ); - } else { - const created = await createProposedAgentMailInbox( - client, - buildAgentMailInboxProposal(Env.R_APP_URL), - ); - inboxAddress = created.inboxAddress; - inboxEmails.set(created.inboxAddress, created.inboxEmail); } + const inboxAddress = pinnedInboxId ?? visibleInboxes[0]!; + const client = createAgentMailApiClient(apiKey, inboxAddress); // Prove message_read without side effects: fetching a sentinel message id // returns 404 when the permission exists and 403 when it does not. An - // already-converged inbox/webhook would otherwise let a key without - // message permissions reach the successful save path, deferring the - // failure to runtime (oversize-body re-fetches and every reply). - // message_send has no side-effect-free probe; it is exercised on the - // first reply. + // already-converged webhook would otherwise let a key without message + // permissions reach the successful save path, deferring the failure to + // runtime (oversize-body re-fetches and every reply). message_send has no + // side-effect-free probe; it is exercised on the first reply. try { await client.getMessage(inboxAddress, 'roomote-permission-probe'); } catch (error) { @@ -1242,15 +1026,14 @@ async function reconcileAgentMailSetup(input: { } // Converge the deployment's webhook (found by client id) on the current - // URL and inbox scope, so pointing the config at a different inbox re-scopes - // delivery instead of silently keeping the old inbox. The webhook secret - // only exists where AgentMail returns it, so a registration we can no - // longer verify deliveries for is recreated. + // URL and event types. The registration is pinned to the inbox by the + // endpoint it is created through. The webhook secret only exists where + // AgentMail returns it, so a registration we can no longer verify + // deliveries for is recreated. const webhookUrl = buildExpectedAgentMailWebhookUrl(); - const desiredInboxIds = [inboxAddress]; // Bounce/complaint events feed the outbound suppression list; a webhook // created by an earlier release only carries message.received, so event - // types are converged like the URL and inbox scope. + // types are converged like the URL. const desiredEventTypes = [ 'message.received', 'message.bounced', @@ -1258,54 +1041,13 @@ async function reconcileAgentMailSetup(input: { ]; let webhookSecret = existing.webhookSecret; - // Which endpoints the key can manage the webhook through. An inbox-scoped - // key passes every inbox check above and is then refused on the - // organization-level webhook endpoints, so on that refusal the same key is - // tried against the inbox's own webhook endpoints before failing the save. - // The detected scope is persisted so status and disconnect use the same - // endpoints without probing again. A newly entered key never inherits the - // recorded scope: an organization-level key replacing an inbox-scoped one - // must reconcile (and remove) the old inbox registration from the - // organization listing, which an inherited inbox scope could not see. - let keyScope: AgentMailKeyScope = - existing.keyScope === 'inbox' && !podId && input.enteredApiKey === null - ? 'inbox' - : 'organization'; - let webhookClient = - keyScope === 'inbox' - ? createAgentMailApiClient(apiKey, null, inboxAddress) - : client; - let webhooks: AgentMailWebhook[] | undefined; - try { - webhooks = (await webhookClient.listWebhooks()).webhooks; - } catch (error) { - const inboxScopedClient = - keyScope === 'organization' && !podId && isAgentMailPermissionError(error) - ? createAgentMailApiClient(apiKey, null, inboxAddress) - : null; - const inboxScoped = inboxScopedClient - ? await inboxScopedClient - .listWebhooks() - .then((listed) => listed.webhooks) - .catch(() => null) - : null; - if (!inboxScopedClient || inboxScoped === null) { - throw new Error( - classifyAgentMailSetupError(error, 'configuring the webhook'), - ); - } - keyScope = 'inbox'; - webhookClient = inboxScopedClient; - webhooks = inboxScoped; - } - try { + const { webhooks } = await client.listWebhooks(); const existingWebhook = findRoomoteAgentMailWebhook(webhooks); const createDeploymentWebhook = async (): Promise => { - const created = await webhookClient.createWebhook({ + const created = await client.createWebhook({ url: webhookUrl, clientId: buildAgentMailWebhookClientId(Env.R_APP_URL), - inboxIds: desiredInboxIds, eventTypes: desiredEventTypes, }); return typeof created.secret === 'string' && created.secret.trim() @@ -1314,13 +1056,6 @@ async function reconcileAgentMailSetup(input: { }; if (existingWebhook) { - const registeredInboxIds = readAgentMailWebhookInboxIds(existingWebhook); - const addInboxIds = desiredInboxIds.filter( - (id) => !registeredInboxIds.includes(id), - ); - const removeInboxIds = registeredInboxIds.filter( - (id) => !desiredInboxIds.includes(id), - ); const registeredEventTypes = readAgentMailWebhookEventTypes(existingWebhook); const eventTypesMatch = @@ -1336,19 +1071,13 @@ async function reconcileAgentMailSetup(input: { } // The URL is immutable on AgentMail's update, and a registration whose // secret we cannot verify deliveries for is useless: both cases mean a - // fresh registration. Scope and event drift converge in place. + // fresh registration. Event-type drift converges in place. if (existingWebhook.url !== webhookUrl || !webhookSecret) { - await webhookClient.deleteWebhook(existingWebhook.webhook_id); + await client.deleteWebhook(existingWebhook.webhook_id); webhookSecret = await createDeploymentWebhook(); - } else if ( - addInboxIds.length > 0 || - removeInboxIds.length > 0 || - !eventTypesMatch - ) { - await webhookClient.updateWebhook(existingWebhook.webhook_id, { - ...(addInboxIds.length ? { addInboxIds } : {}), - ...(removeInboxIds.length ? { removeInboxIds } : {}), - ...(eventTypesMatch ? {} : { eventTypes: desiredEventTypes }), + } else if (!eventTypesMatch) { + await client.updateWebhook(existingWebhook.webhook_id, { + eventTypes: desiredEventTypes, }); } } else { @@ -1361,8 +1090,6 @@ async function reconcileAgentMailSetup(input: { } return { - podId, - keyScope, inboxAddress, inboxEmail: inboxEmails.get(inboxAddress) ?? inboxAddress, webhookUrl, @@ -1377,8 +1104,7 @@ async function deleteAgentMailWebhookBestEffort(): Promise { if (!credentials.apiKey) return; const client = createAgentMailApiClient( credentials.apiKey, - credentials.podId, - webhookInboxForScope(credentials), + credentials.inboxId, ); const { webhooks } = await client.listWebhooks(); const webhook = findRoomoteAgentMailWebhook(webhooks); @@ -1713,11 +1439,7 @@ export async function getCommsStatusCommand( invocationIdentities, ] = await Promise.all([ getPersistedEnvironmentVariableNames(), - getPersistedEnvironmentVariableValues([ - ...NON_SECRET_AUTH_ENV_VAR_NAMES, - 'R_AGENTMAIL_INBOX_ID', - 'R_AGENTMAIL_POD_ID', - ]), + getPersistedEnvironmentVariableValues([...NON_SECRET_AUTH_ENV_VAR_NAMES]), getTelegramWebhookStatus(), getDiscordCommsStatus(), getAgentMailCommsStatus(), @@ -1781,13 +1503,6 @@ export async function saveCommsAuthConfigCommand( input.provider === 'agentmail' ? await reconcileAgentMailSetup({ enteredApiKey: input.values?.R_AGENTMAIL_API_KEY?.trim() || null, - enteredInboxId: input.values?.R_AGENTMAIL_INBOX_ID?.trim() || null, - // The pod field is part of the form: an omitted key keeps the saved - // pod, an empty string clears it (the form submits every field). - enteredPodId: - input.values && 'R_AGENTMAIL_POD_ID' in input.values - ? (input.values.R_AGENTMAIL_POD_ID?.trim() ?? null) - : undefined, }) : null; @@ -1905,15 +1620,9 @@ export async function saveCommsAuthConfigCommand( } if (input.provider === 'agentmail' && agentmailSetup) { - // Persist the reconciled inbox address (which may have just been - // provisioned) instead of whatever was typed, plus the webhook secret - // AgentMail issued for delivery verification. - const inboxIndex = valuesToSave.findIndex( - (value) => value.name === 'R_AGENTMAIL_INBOX_ID', - ); - if (inboxIndex >= 0) { - valuesToSave.splice(inboxIndex, 1); - } + // The inbox is derived from the key, never entered: persist the + // reconciled address (so runtime callers route without re-listing) + // plus the webhook secret AgentMail issued for delivery verification. valuesToSave.push({ name: 'R_AGENTMAIL_INBOX_ID', value: agentmailSetup.inboxAddress, @@ -1924,23 +1633,6 @@ export async function saveCommsAuthConfigCommand( value: agentmailSetup.webhookSecret, }); } - // Optional fields are only ever upserted by the generic path, so an - // emptied pod field would otherwise leave the old pod persisted and the - // next reconcile would silently address it again. - if ( - !agentmailSetup.podId && - persistedEnvVarNames.includes('R_AGENTMAIL_POD_ID') - ) { - await deleteDeploymentEnvVarsByNames(tx, ['R_AGENTMAIL_POD_ID']); - } - // The key scope is detected, never entered: record an inbox-scoped key - // so status and disconnect address the inbox's webhook endpoints, and - // drop the record once an organization-level key replaces it. - if (agentmailSetup.keyScope === 'inbox') { - valuesToSave.push({ name: 'R_AGENTMAIL_KEY_SCOPE', value: 'inbox' }); - } else if (persistedEnvVarNames.includes('R_AGENTMAIL_KEY_SCOPE')) { - await deleteDeploymentEnvVarsByNames(tx, ['R_AGENTMAIL_KEY_SCOPE']); - } } const hasConfiguredAuthEnvVar = (name: string) => @@ -2031,8 +1723,6 @@ export async function saveCommsAuthConfigCommand( ...(agentmailSetup ? { agentmail: { - podId: agentmailSetup.podId, - keyScope: agentmailSetup.keyScope, inboxAddress: agentmailSetup.inboxAddress, inboxEmail: agentmailSetup.inboxEmail, webhookUrl: agentmailSetup.webhookUrl, @@ -2061,9 +1751,14 @@ export async function clearCommsAuthConfigCommand( // The webhook secret is provisioned server-side rather than entered, so // it is not a field; remove it with the credentials, and best-effort // unregister the webhook while the API key is still available. + // The inbox and webhook secret are derived on save rather than entered, + // so they are not fields; remove them with the key, and best-effort + // unregister the webhook while the key is still available. The pod id + // is a retired field from an earlier build. fieldEnvVarNames.push( + 'R_AGENTMAIL_INBOX_ID', 'R_AGENTMAIL_WEBHOOK_SECRET', - 'R_AGENTMAIL_KEY_SCOPE', + 'R_AGENTMAIL_POD_ID', ); await deleteAgentMailWebhookBestEffort(); } diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index f73dfff419..e788a0611f 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -358,7 +358,6 @@ import { saveCommsAuthConfigCommand, clearCommsAuthConfigCommand, diagnoseDiscordPermissionsCommand, - listAgentMailInboxesCommand, listDiscordChannelsCommand, listDiscordGuildsCommand, registerDiscordCommandsCommand, @@ -2123,20 +2122,6 @@ export const appRouter = createRouter({ repairTelegramWebhookCommand(auth), ), - // A mutation, not a query: the input can carry a freshly typed API key, - // and query inputs serialize into the GET URL (browser history, proxy - // and access logs, tracing). Mutations POST the input in the body. - listAgentMailInboxes: protectedProcedure - .input( - z.object({ - apiKey: z.string().trim().optional(), - podId: z.string().trim().optional(), - }), - ) - .mutation(({ ctx: { auth }, input }) => - listAgentMailInboxesCommand(auth, input), - ), - listDiscordGuilds: protectedProcedure.query(({ ctx: { auth } }) => listDiscordGuildsCommand(auth), ), diff --git a/packages/communication/src/__tests__/agentmail-api-client.test.ts b/packages/communication/src/__tests__/agentmail-api-client.test.ts index 7dae4891e4..c7372c35aa 100644 --- a/packages/communication/src/__tests__/agentmail-api-client.test.ts +++ b/packages/communication/src/__tests__/agentmail-api-client.test.ts @@ -83,13 +83,12 @@ describe('AgentMailApiError', () => { }); }); -describe('AgentMailApiClient pod scoping', () => { - function recordingClient(podId?: string) { +describe('AgentMailApiClient webhook scoping', () => { + function recordingClient() { const calls: Array<{ method: string; url: string; body: unknown }> = []; const client = new AgentMailApiClient({ apiKey: 'am_test', apiBaseUrl: 'https://agentmail.test', - ...(podId ? { podId } : {}), fetch: (async (input: RequestInfo | URL, init?: RequestInit) => { calls.push({ method: init?.method ?? 'GET', @@ -102,40 +101,6 @@ describe('AgentMailApiClient pod scoping', () => { return { client, calls }; } - it('routes inbox and webhook management through the pod, messages through the inbox', async () => { - const { client, calls } = recordingClient('pod_acme'); - - await client.listInboxes(); - await client.createInbox({ username: 'roomote', clientId: 'roomote-1' }); - await client.getInbox('roomote@agentmail.to'); - await client.updateInbox('roomote@agentmail.to', { - displayName: 'Roomote', - }); - await client.listWebhooks(); - await client.createWebhook({ - url: 'https://app.example.com/api/webhooks/agentmail', - inboxIds: ['roomote@agentmail.to'], - }); - await client.updateWebhook('wh-1', { addInboxIds: ['a@agentmail.to'] }); - await client.deleteWebhook('wh-1'); - await client.getMessage('roomote@agentmail.to', 'm-1'); - await client.sendMessage('roomote@agentmail.to', { to: ['x@example.com'] }); - - expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([ - 'GET https://agentmail.test/v0/pods/pod_acme/inboxes', - 'POST https://agentmail.test/v0/pods/pod_acme/inboxes', - 'GET https://agentmail.test/v0/pods/pod_acme/inboxes/roomote%40agentmail.to', - 'PATCH https://agentmail.test/v0/pods/pod_acme/inboxes/roomote%40agentmail.to', - 'GET https://agentmail.test/v0/pods/pod_acme/webhooks', - 'POST https://agentmail.test/v0/pods/pod_acme/webhooks', - 'PATCH https://agentmail.test/v0/pods/pod_acme/webhooks/wh-1', - 'DELETE https://agentmail.test/v0/pods/pod_acme/webhooks/wh-1', - 'GET https://agentmail.test/v0/inboxes/roomote%40agentmail.to/messages/m-1', - 'POST https://agentmail.test/v0/inboxes/roomote%40agentmail.to/messages/send', - ]); - expect(client.podId).toBe('pod_acme'); - }); - it('manages webhooks under the inbox for an inbox-scoped key', async () => { const calls: Array<{ method: string; url: string; body: unknown }> = []; const client = new AgentMailApiClient({ @@ -160,7 +125,6 @@ describe('AgentMailApiClient pod scoping', () => { eventTypes: ['message.received'], }); await client.updateWebhook('wh-1', { - addInboxIds: ['x@roomote.me'], eventTypes: ['message.received', 'message.bounced'], }); await client.deleteWebhook('wh-1'); @@ -172,7 +136,7 @@ describe('AgentMailApiClient pod scoping', () => { 'PATCH https://agentmail.test/v0/inboxes/roomote%40roomote.me/webhooks/wh-1', 'DELETE https://agentmail.test/v0/inboxes/roomote%40roomote.me/webhooks/wh-1', ]); - // The path pins the inbox: no inbox list on create, no add/remove on update. + // The path pins the inbox: no inbox list on create. expect(calls[2]?.body).toEqual({ url: 'https://app.example.com/api/webhooks/agentmail', event_types: ['message.received'], @@ -183,29 +147,32 @@ describe('AgentMailApiClient pod scoping', () => { expect(client.webhookInboxId).toBe('roomote@roomote.me'); }); - it('stays at organization level without a pod', async () => { + it('stays at organization level without an inbox', async () => { const { client, calls } = recordingClient(); await client.listWebhooks(); - await client.createInbox({ username: 'roomote' }); + await client.createWebhook({ + url: 'https://app.example.com/api/webhooks/agentmail', + inboxIds: ['roomote@agentmail.to'], + }); expect(calls.map((call) => call.url)).toEqual([ 'https://agentmail.test/v0/webhooks', - 'https://agentmail.test/v0/inboxes', + 'https://agentmail.test/v0/webhooks', ]); - expect(client.podId).toBeNull(); + expect(calls[1]?.body).toEqual({ + url: 'https://app.example.com/api/webhooks/agentmail', + inbox_ids: ['roomote@agentmail.to'], + }); + expect(client.webhookInboxId).toBeNull(); }); - it("sends AgentMail's add/remove inbox lists and full event-type replacement on update", async () => { + it('sends only a full event-type replacement on update, never an empty list', async () => { const { client, calls } = recordingClient(); await client.updateWebhook('wh-1', { - addInboxIds: ['new@agentmail.to'], - removeInboxIds: ['old@agentmail.to'], eventTypes: ['message.received', 'message.bounced'], }); await client.updateWebhook('wh-1', { eventTypes: [] }); expect(calls[0]?.body).toEqual({ - add_inbox_ids: ['new@agentmail.to'], - remove_inbox_ids: ['old@agentmail.to'], event_types: ['message.received', 'message.bounced'], }); // An empty list must leave event types unchanged, never clear them. diff --git a/packages/communication/src/agentmail-provider.ts b/packages/communication/src/agentmail-provider.ts index a182d6d343..73700ebf79 100644 --- a/packages/communication/src/agentmail-provider.ts +++ b/packages/communication/src/agentmail-provider.ts @@ -415,18 +415,13 @@ export type AgentMailWebhook = { export type AgentMailApiClientOptions = { apiKey: string; - /** - * AgentMail pod the deployment's inbox and webhook live in. When set, inbox - * and webhook management goes through the pod-scoped endpoints - * (`/v0/pods/{pod_id}/...`), which is what a pod-scoped API key can reach; - * message endpoints are inbox-addressed either way. - */ - podId?: string; /** * Manage webhooks through the inbox-scoped endpoints * (`/v0/inboxes/{inbox_id}/webhooks`), which is all an inbox-scoped API - * key can reach. Such a webhook is fixed to the inbox: creation carries no - * inbox or pod scope and updates only change event types. + * key can reach and the shape Roomote deployments use. Such a webhook is + * fixed to the inbox: creation carries no inbox list and updates only + * change event types. Unset falls back to the organization-level + * endpoints. */ webhookInboxId?: string; apiBaseUrl?: string; @@ -435,8 +430,6 @@ export type AgentMailApiClientOptions = { }; export type AgentMailWebhookUpdate = { - addInboxIds?: string[]; - removeInboxIds?: string[]; /** A non-empty list REPLACES the subscription in full (AgentMail semantics). */ eventTypes?: string[]; }; @@ -460,12 +453,7 @@ export class AgentMailApiClient { private readonly apiBaseUrl: string; private readonly fetchImpl: typeof fetch; private readonly timeoutMs: number; - /** - * Prefix for inbox and webhook MANAGEMENT paths: the pod when configured, - * the organization otherwise. Message paths always hang off `/v0/inboxes`. - */ - private readonly managementPrefix: string; - /** Prefix for webhook paths: the pod, the inbox, or the organization. */ + /** Prefix for webhook paths: the inbox when configured, else the organization. */ private readonly webhookPrefix: string; constructor(private readonly options: AgentMailApiClientOptions) { @@ -474,25 +462,15 @@ export class AgentMailApiClient { ); this.fetchImpl = options.fetch ?? fetch; this.timeoutMs = options.timeoutMs ?? DEFAULT_AGENTMAIL_TIMEOUT_MS; - const podId = options.podId?.trim(); const webhookInboxId = options.webhookInboxId?.trim(); - this.managementPrefix = podId - ? `/v0/pods/${encodeURIComponent(podId)}` + this.webhookPrefix = webhookInboxId + ? `/v0/inboxes/${encodeURIComponent(webhookInboxId)}` : '/v0'; - this.webhookPrefix = podId - ? this.managementPrefix - : webhookInboxId - ? `/v0/inboxes/${encodeURIComponent(webhookInboxId)}` - : '/v0'; - } - - get podId(): string | null { - return this.options.podId?.trim() || null; } /** The inbox webhooks are managed under, when inbox-scoped. */ get webhookInboxId(): string | null { - return this.podId ? null : this.options.webhookInboxId?.trim() || null; + return this.options.webhookInboxId?.trim() || null; } /** @@ -516,7 +494,7 @@ export class AgentMailApiClient { inboxes?: AgentMailInbox[]; next_page_token?: string; } & Record - >('GET', `${this.managementPrefix}/inboxes${query}`); + >('GET', `/v0/inboxes${query}`); inboxes.push(...(result.inboxes ?? [])); pageToken = @@ -534,38 +512,17 @@ export class AgentMailApiClient { ); } - createInbox(input: { - username?: string; - domain?: string; - clientId?: string; - displayName?: string; - }): Promise { - return this.request('POST', `${this.managementPrefix}/inboxes`, { - ...(input.username ? { username: input.username } : {}), - ...(input.domain ? { domain: input.domain } : {}), - ...(input.clientId ? { client_id: input.clientId } : {}), - ...(input.displayName ? { display_name: input.displayName } : {}), - }); - } - getInbox(inboxId: string): Promise { - return this.request( - 'GET', - `${this.managementPrefix}/inboxes/${encodeURIComponent(inboxId)}`, - ); + return this.request('GET', `/v0/inboxes/${encodeURIComponent(inboxId)}`); } updateInbox( inboxId: string, input: { displayName?: string }, ): Promise { - return this.request( - 'PATCH', - `${this.managementPrefix}/inboxes/${encodeURIComponent(inboxId)}`, - { - ...(input.displayName ? { display_name: input.displayName } : {}), - }, - ); + return this.request('PATCH', `/v0/inboxes/${encodeURIComponent(inboxId)}`, { + ...(input.displayName ? { display_name: input.displayName } : {}), + }); } listWebhooks(): Promise< @@ -575,9 +532,8 @@ export class AgentMailApiClient { } /** - * Under a pod, the created webhook is scoped to that pod by the path and - * `inboxIds` narrows it further; under an inbox, the path fixes the scope - * and no inbox list is sent. + * Under an inbox the path fixes the webhook's scope and no inbox list is + * sent; at organization level `inboxIds` narrows delivery. */ createWebhook(input: { url: string; @@ -603,28 +559,19 @@ export class AgentMailApiClient { } /** - * AgentMail's webhook update is add/remove lists for the inbox scope plus - * a full replacement for event types; the URL is immutable, so a re-pointed - * webhook is deleted and recreated by the caller. + * Only event types move on update: a non-empty list REPLACES the + * subscription in full (AgentMail semantics) and an omitted/empty list + * leaves it unchanged. The URL is immutable, so a re-pointed webhook is + * deleted and recreated by the caller. */ updateWebhook( webhookId: string, input: AgentMailWebhookUpdate, ): Promise { - // An inbox-scoped webhook is fixed to its inbox: only event types move. - const inboxScoped = Boolean(this.webhookInboxId); return this.request( 'PATCH', `${this.webhookPrefix}/webhooks/${encodeURIComponent(webhookId)}`, { - ...(!inboxScoped && input.addInboxIds?.length - ? { add_inbox_ids: input.addInboxIds } - : {}), - ...(!inboxScoped && input.removeInboxIds?.length - ? { remove_inbox_ids: input.removeInboxIds } - : {}), - // A non-empty list REPLACES the subscription in full (AgentMail - // semantics); an omitted/empty list leaves it unchanged. ...(input.eventTypes?.length ? { event_types: input.eventTypes } : {}), }, ); diff --git a/packages/db/src/lib/agentmail-runtime-credentials.ts b/packages/db/src/lib/agentmail-runtime-credentials.ts index 56d3cc5cfa..4710435607 100644 --- a/packages/db/src/lib/agentmail-runtime-credentials.ts +++ b/packages/db/src/lib/agentmail-runtime-credentials.ts @@ -4,28 +4,8 @@ export type AgentMailRuntimeCredentials = { apiKey: string | null; webhookSecret: string | null; inboxId: string | null; - /** - * AgentMail pod the inbox lives in, when the deployment was set up with a - * pod-scoped key (or asked to keep its resources inside a pod). Null means - * organization-level resources. - */ - podId: string | null; - /** - * Whether the API key is an inbox-scoped key. Inbox-scoped keys manage the - * webhook through the inbox's own endpoints; detected at save time and - * persisted so status and disconnect use the same endpoints. - */ - keyScope: AgentMailKeyScope; }; -export type AgentMailKeyScope = 'organization' | 'inbox'; - -function normalizeKeyScope( - value: string | null | undefined, -): AgentMailKeyScope { - return value?.trim().toLowerCase() === 'inbox' ? 'inbox' : 'organization'; -} - const CACHE_TTL_MS = 30_000; let cachedCredentials: { @@ -43,8 +23,6 @@ function readProcessEnvCredentials(): AgentMailRuntimeCredentials { apiKey: process.env.R_AGENTMAIL_API_KEY?.trim() || null, webhookSecret: process.env.R_AGENTMAIL_WEBHOOK_SECRET?.trim() || null, inboxId: normalizeInboxId(process.env.R_AGENTMAIL_INBOX_ID), - podId: process.env.R_AGENTMAIL_POD_ID?.trim() || null, - keyScope: normalizeKeyScope(process.env.R_AGENTMAIL_KEY_SCOPE), }; } @@ -64,7 +42,7 @@ export async function resolveAgentMailRuntimeCredentials(): Promise = new Set([ 'GITEA_CLIENT_ID', 'SLACK_APP_ID', 'R_AGENTMAIL_INBOX_ID', - 'R_AGENTMAIL_POD_ID', - 'R_AGENTMAIL_KEY_SCOPE', 'ADO_CLIENT_ID', 'ADO_TENANT_ID', 'ADO_AUTH_MODE',