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 dbeddba413..7fff9905ef 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -362,10 +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_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 d935adbd57..9fa86b3084 100644 --- a/apps/docs/providers/communications/agentmail.mdx +++ b/apps/docs/providers/communications/agentmail.mdx @@ -29,53 +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 step, except `message_send`, which has no side-effect-free -check and is exercised on the first reply. - -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 c7f4fcf956..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,14 +296,6 @@ function AgentMailSetupStatus({ return (
- {status.podId ? ( -
- -

- Pod: {status.podId} -

-
- ) : null} {status.inboxAddress ? (
@@ -352,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; @@ -854,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 })); }; @@ -933,7 +642,7 @@ export function CommsProviderSection({ ) : (
@@ -978,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 619de99dd4..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,11 +45,9 @@ const { apiKey: null as string | null, webhookSecret: null as string | null, inboxId: null as string | null, - podId: null as string | null, })), mockAgentMailClientConstructor: vi.fn(), mockAgentMailListInboxes: vi.fn(), - mockAgentMailCreateInbox: vi.fn(), mockAgentMailGetInbox: vi.fn(), mockAgentMailGetMessage: vi.fn(), mockAgentMailListWebhooks: vi.fn(), @@ -196,7 +193,6 @@ vi.mock('@roomote/communication/agentmail-provider', () => ({ mockAgentMailClientConstructor(options); } listInboxes = mockAgentMailListInboxes; - createInbox = mockAgentMailCreateInbox; getInbox = mockAgentMailGetInbox; getMessage = mockAgentMailGetMessage; listWebhooks = mockAgentMailListWebhooks; @@ -270,7 +266,6 @@ import { classifyTelegramWebhookCheckError, clearCommsAuthConfigCommand, getCommsStatusCommand, - listAgentMailInboxesCommand, listDiscordChannelsCommand, listDiscordGuildsCommand, repairTelegramWebhookCommand, @@ -323,11 +318,9 @@ describe('comms commands', () => { apiKey: null, webhookSecret: null, inboxId: null, - podId: null, }); mockAgentMailListInboxes.mockResolvedValue({ inboxes: [] }); mockAgentMailListWebhooks.mockResolvedValue({ webhooks: [] }); - mockAgentMailCreateInbox.mockReset(); mockAgentMailGetInbox.mockReset(); mockAgentMailCreateWebhook.mockReset(); mockAgentMailUpdateWebhook.mockReset(); @@ -825,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) => @@ -837,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( @@ -855,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( @@ -885,94 +902,121 @@ describe('comms commands', () => { }), ).resolves.toMatchObject({ agentmail: { - inboxAddress: 'existing@agentmail.to', - inboxEmail: 'display-alias@agentmail.to', + inboxAddress: 'routing-id@roomote.example', + inboxEmail: 'pretty@roomote.example', }, }); + expect(mockAgentMailGetMessage).toHaveBeenCalledWith( + 'routing-id@roomote.example', + 'roomote-permission-probe', + ); }); - 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), - ); + it('refuses a key that sees no inbox', async () => { + mockAgentMailListInboxes.mockResolvedValue({ inboxes: [] }); await expect( saveCommsAuthConfigCommand(buildMockAuth(), { provider: 'agentmail', values: { R_AGENTMAIL_API_KEY: 'am-key' }, }), - ).rejects.toThrow(/permission|403/i); + ).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('fails the save when the message_read probe cannot complete (network error)', async () => { + it('refuses a key that sees several inboxes and names them', async () => { mockAgentMailListInboxes.mockResolvedValue({ - inboxes: [{ inbox_id: 'existing@agentmail.to' }], + inboxes: [ + { inbox_id: 'a@roomote.example' }, + { inbox_id: 'b@roomote.example', email: 'pretty-b@roomote.example' }, + ], }); - mockAgentMailGetMessage.mockRejectedValue( - new Error('fetch failed: socket hang up'), - ); await expect( saveCommsAuthConfigCommand(buildMockAuth(), { provider: 'agentmail', - values: { R_AGENTMAIL_API_KEY: 'am-key' }, + values: { R_AGENTMAIL_API_KEY: 'am-org-key' }, }), - ).rejects.toThrow(/Could not reach the AgentMail API/); - expect(mockAgentMailCreateWebhook).not.toHaveBeenCalled(); + ).rejects.toThrow( + /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('uses the created inbox email in the result when it differs from the id', async () => { - mockAgentMailListInboxes.mockResolvedValue({ inboxes: [] }); - mockAgentMailCreateInbox.mockResolvedValue({ - inbox_id: `${expectedUsername}@agentmail.to`, - email: `${expectedUsername}-alias@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_created', + secret: 'whsec_test', }); - - 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`, - }, + 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' }, + }); + + 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('asks the operator to choose when the org has several inboxes', async () => { - mockAgentMailListInboxes.mockResolvedValue({ - inboxes: [ - { inbox_id: 'one@agentmail.to' }, - { inbox_id: 'two@agentmail.to' }, - ], - }); + it('fails the save when the key lacks message_read', async () => { + mockAgentMailGetMessage.mockRejectedValue( + new AgentMailApiError( + 'AgentMail GET /v0/inboxes/x/messages/roomote-permission-probe failed (403): {"message":"Forbidden"}', + 403, + ), + ); 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(); + ).rejects.toThrow( + /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('names the failing step when a later call is refused', async () => { - mockAgentMailCreateInbox.mockRejectedValue( - new Error( - 'AgentMail POST /v0/inboxes failed (403): {"message":"Forbidden"}', - ), - ); + 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(), { @@ -980,68 +1024,36 @@ describe('comms commands', () => { values: { R_AGENTMAIL_API_KEY: 'am-key' }, }), ).rejects.toThrow( - /refused permission while creating an inbox \(403 Forbidden\)/, + 'Could not reach the AgentMail API (timed out). Check connectivity and save again.', ); + 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`, - }); - mockAgentMailCreateWebhook.mockResolvedValue({ - webhook_id: 'wh-1', - url: expectedWebhookUrl, - secret: 'whsec_test', - }); + 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, + ), + ); await expect( saveCommsAuthConfigCommand(buildMockAuth(), { provider: 'agentmail', values: { R_AGENTMAIL_API_KEY: 'am-key' }, }), - ).resolves.toMatchObject({ - agentmail: { - inboxAddress: `${expectedUsername}@agentmail.to`, - webhookUrl: expectedWebhookUrl, - }, - }); - - 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' }, - ]), - }), + ).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(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( @@ -1050,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(); }); @@ -1069,62 +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, - }); - 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: [ { @@ -1132,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( @@ -1160,43 +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, + 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', @@ -1207,97 +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', + 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', @@ -1309,71 +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, + 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], }, ], }); @@ -1385,199 +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, - }); - 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, - }); - 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/, ); }); }); @@ -1595,7 +1262,6 @@ describe('comms commands', () => { apiKey: 'am-key', webhookSecret: 'whsec_existing', inboxId: 'support@agentmail.to', - podId: null, }); mockAgentMailListWebhooks.mockResolvedValue({ webhooks: [ @@ -1633,7 +1299,6 @@ describe('comms commands', () => { apiKey: 'am-key', webhookSecret: 'whsec_existing', inboxId: 'support@agentmail.to', - podId: null, }); mockAgentMailListWebhooks.mockRejectedValue( new Error('AgentMail GET /v0/webhooks failed (500)'), @@ -1656,7 +1321,6 @@ describe('comms commands', () => { apiKey: 'am-key', webhookSecret: 'whsec_existing', inboxId: 'support@agentmail.to', - podId: null, }); }); diff --git a/apps/web/src/trpc/commands/comms/index.ts b/apps/web/src/trpc/commands/comms/index.ts index 5d6e667b08..90ae70c574 100644 --- a/apps/web/src/trpc/commands/comms/index.ts +++ b/apps/web/src/trpc/commands/comms/index.ts @@ -46,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, @@ -139,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 (optional)', - required: false, - }, - { - envVarName: 'R_AGENTMAIL_INBOX_ID', - acceptedEnvVarNames: ['R_AGENTMAIL_INBOX_ID'], - label: 'Inbox Email Address', - required: false, - }, ], }, }; @@ -212,9 +199,7 @@ type AgentMailWebhookStatus = { }; export type AgentMailCommsStatus = { - /** The AgentMail pod the inbox and webhook live in, when pod-scoped. */ - podId: string | null; - /** 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; @@ -656,25 +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) { +/** + * 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 } : {}), + ...(inboxId ? { webhookInboxId: inboxId } : {}), timeoutMs: AGENTMAIL_API_TIMEOUT_MS, }); } -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.'; @@ -684,22 +670,43 @@ function assertEmailChannelEnabled(): void { } } -/** Map AgentMail API / network failures into admin-facing setup copy. */ +/** + * 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}`; +} + /** * 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); @@ -735,24 +742,29 @@ 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; "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. ${AGENTMAIL_KEY_GUIDANCE}, then save again: ${AGENTMAIL_REQUIRED_PERMISSIONS}.${requestDetail}`; + } + 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 { @@ -782,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 { @@ -860,7 +846,7 @@ async function getAgentMailCommsStatus(): Promise { const expectedUrl = buildExpectedAgentMailWebhookUrl(); const client = createAgentMailApiClient( credentials.apiKey, - credentials.podId, + credentials.inboxId, ); try { @@ -891,7 +877,6 @@ async function getAgentMailCommsStatus(): Promise { registeredInboxIds.includes(credentials.inboxId); return { - podId: credentials.podId, inboxAddress: credentials.inboxId, inboxEmail, webhook: { @@ -907,7 +892,6 @@ async function getAgentMailCommsStatus(): Promise { }; } catch (error) { return { - podId: credentials.podId, inboxAddress: credentials.inboxId, inboxEmail: null, webhook: { @@ -920,68 +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; /** The routed inbox_id — persisted and used in API paths/webhook scoping. */ inboxAddress: string; /** The deliverable address, display only. */ @@ -991,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( @@ -1054,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, @@ -1081,90 +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'), ); } - // 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) { + // 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( - classifyAgentMailSetupError(error, 'configuring the webhook'), + `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}.`, ); } - - 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) { + 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 AgentMail account has ${orgInboxes.length} inboxes. Enter the one Roomote should use in the Inbox Email Address field: ${orgInboxes + `This API key can see ${visibleInboxes.length} inboxes (${visibleInboxes .map((address) => formatAgentMailInboxLabel(address, inboxEmails.get(address) ?? null), ) - .join(', ')}`, - ); - } else { - const created = await createProposedAgentMailInbox( - client, - buildAgentMailInboxProposal(Env.R_APP_URL), + .join( + ', ', + )}), so Roomote cannot tell which one is for this deployment. ${AGENTMAIL_KEY_GUIDANCE}, then save again: ${AGENTMAIL_REQUIRED_PERMISSIONS}.`, ); - 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) { @@ -1192,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', @@ -1215,7 +1048,6 @@ async function reconcileAgentMailSetup(input: { 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() @@ -1224,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 = @@ -1246,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 client.deleteWebhook(existingWebhook.webhook_id); webhookSecret = await createDeploymentWebhook(); - } else if ( - addInboxIds.length > 0 || - removeInboxIds.length > 0 || - !eventTypesMatch - ) { + } else if (!eventTypesMatch) { await client.updateWebhook(existingWebhook.webhook_id, { - ...(addInboxIds.length ? { addInboxIds } : {}), - ...(removeInboxIds.length ? { removeInboxIds } : {}), - ...(eventTypesMatch ? {} : { eventTypes: desiredEventTypes }), + eventTypes: desiredEventTypes, }); } } else { @@ -1271,7 +1090,6 @@ async function reconcileAgentMailSetup(input: { } return { - podId, inboxAddress, inboxEmail: inboxEmails.get(inboxAddress) ?? inboxAddress, webhookUrl, @@ -1286,7 +1104,7 @@ async function deleteAgentMailWebhookBestEffort(): Promise { if (!credentials.apiKey) return; const client = createAgentMailApiClient( credentials.apiKey, - credentials.podId, + credentials.inboxId, ); const { webhooks } = await client.listWebhooks(); const webhook = findRoomoteAgentMailWebhook(webhooks); @@ -1621,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(), @@ -1689,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; @@ -1813,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, @@ -1832,15 +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']); - } } const hasConfiguredAuthEnvVar = (name: string) => @@ -1931,7 +1723,6 @@ export async function saveCommsAuthConfigCommand( ...(agentmailSetup ? { agentmail: { - podId: agentmailSetup.podId, inboxAddress: agentmailSetup.inboxAddress, inboxEmail: agentmailSetup.inboxEmail, webhookUrl: agentmailSetup.webhookUrl, @@ -1960,7 +1751,15 @@ 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'); + // 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_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 ee074c7a82..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,63 +101,78 @@ 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'); + 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.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'], + inboxIds: ['roomote@roomote.me'], + eventTypes: ['message.received'], + }); + await client.updateWebhook('wh-1', { + eventTypes: ['message.received', 'message.bounced'], }); - 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', + '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', ]); - expect(client.podId).toBe('pod_acme'); + // 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'], + }); + 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 () => { + 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/__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..73700ebf79 100644 --- a/packages/communication/src/agentmail-provider.ts +++ b/packages/communication/src/agentmail-provider.ts @@ -416,20 +416,20 @@ 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. + * Manage webhooks through the inbox-scoped endpoints + * (`/v0/inboxes/{inbox_id}/webhooks`), which is all an inbox-scoped API + * 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. */ - podId?: string; + webhookInboxId?: string; apiBaseUrl?: string; fetch?: typeof fetch; timeoutMs?: number; }; export type AgentMailWebhookUpdate = { - addInboxIds?: string[]; - removeInboxIds?: string[]; /** A non-empty list REPLACES the subscription in full (AgentMail semantics). */ eventTypes?: string[]; }; @@ -453,11 +453,8 @@ 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 inbox when configured, else the organization. */ + private readonly webhookPrefix: string; constructor(private readonly options: AgentMailApiClientOptions) { this.apiBaseUrl = trimTrailingSlashes( @@ -465,14 +462,15 @@ export class AgentMailApiClient { ); this.fetchImpl = options.fetch ?? fetch; this.timeoutMs = options.timeoutMs ?? DEFAULT_AGENTMAIL_TIMEOUT_MS; - const podId = options.podId?.trim(); - this.managementPrefix = podId - ? `/v0/pods/${encodeURIComponent(podId)}` + const webhookInboxId = options.webhookInboxId?.trim(); + this.webhookPrefix = 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.options.webhookInboxId?.trim() || null; } /** @@ -496,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 = @@ -514,49 +512,28 @@ 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< { 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 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; @@ -564,10 +541,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,14 +554,15 @@ export class AgentMailApiClient { getWebhook(webhookId: string): Promise { return this.request( 'GET', - `${this.managementPrefix}/webhooks/${encodeURIComponent(webhookId)}`, + `${this.webhookPrefix}/webhooks/${encodeURIComponent(webhookId)}`, ); } /** - * 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, @@ -590,16 +570,8 @@ export class AgentMailApiClient { ): Promise { return this.request( 'PATCH', - `${this.managementPrefix}/webhooks/${encodeURIComponent(webhookId)}`, + `${this.webhookPrefix}/webhooks/${encodeURIComponent(webhookId)}`, { - ...(input.addInboxIds?.length - ? { add_inbox_ids: input.addInboxIds } - : {}), - ...(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 } : {}), }, ); @@ -608,7 +580,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..4710435607 100644 --- a/packages/db/src/lib/agentmail-runtime-credentials.ts +++ b/packages/db/src/lib/agentmail-runtime-credentials.ts @@ -4,12 +4,6 @@ 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; }; const CACHE_TTL_MS = 30_000; @@ -29,7 +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, }; } @@ -49,7 +42,7 @@ export async function resolveAgentMailRuntimeCredentials(): Promise = new Set([ 'GITEA_CLIENT_ID', 'SLACK_APP_ID', 'R_AGENTMAIL_INBOX_ID', - 'R_AGENTMAIL_POD_ID', 'ADO_CLIENT_ID', 'ADO_TENANT_ID', 'ADO_AUTH_MODE',