diff --git a/apps/docs/providers/communications/agentmail.mdx b/apps/docs/providers/communications/agentmail.mdx index eae308d3b2..fa398ba0d7 100644 --- a/apps/docs/providers/communications/agentmail.mdx +++ b/apps/docs/providers/communications/agentmail.mdx @@ -11,16 +11,17 @@ provider. Roomote can receive email sent to a dedicated deployment inbox, start tasks from those messages, and reply in the same email thread. AgentMail delivers inbound mail through a `message.received` webhook, so Roomote must be reachable -at a stable public HTTPS URL. Each deployment brings its own AgentMail account -and API key. +at a stable public HTTPS URL. Self-hosted deployments bring their own AgentMail +account and API key; Roomote Cloud provisions managed credentials. ## Enable the email channel Email is off by default. Set `R_EMAIL_CHANNEL_ENABLED=true` in the deployment's environment and restart. Until then the provider does not appear in settings, inbound webhook deliveries are acknowledged and dropped, -and Roomote never sends email. On Roomote Cloud this is enabled per -deployment by the Roomote team. +and Roomote never sends email. New Roomote Cloud deployments receive a managed +`@roomote.me` inbox by default; older deployments remain +disabled until Roomote enables them. Enabling the channel gives the deployment an email sender for the first time, so new password sign-ups also receive a verification email (see @@ -44,6 +45,11 @@ then: 3. In the Roomote UI (**Settings > Communications > Email (AgentMail)**), paste the key and save. +Roomote Cloud manages these credentials and the webhook. Its Communications +settings show the inbox and connection status without exposing setup, removal, +or repair controls. The configuration steps below apply to self-hosted +deployments. + 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 diff --git a/apps/web/src/components/settings/CommsProviderSection.test.tsx b/apps/web/src/components/settings/CommsProviderSection.test.tsx index 14fa764cc6..60921da130 100644 --- a/apps/web/src/components/settings/CommsProviderSection.test.tsx +++ b/apps/web/src/components/settings/CommsProviderSection.test.tsx @@ -9,7 +9,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { CommsProviderSection } from './CommsProviderSection'; type CommsProviderStatus = { - id: 'slack' | 'microsoft' | 'telegram'; + id: 'slack' | 'microsoft' | 'telegram' | 'agentmail'; label: string; fields: Array<{ envVarName: string; @@ -37,9 +37,20 @@ type CommsProviderStatus = { lastErrorMessage: string | null; } | null; telegramBotUsername?: string | null; + agentmail?: { + inboxAddress: string | null; + inboxEmail: string | null; + webhook: { + status: 'connected' | 'mismatch' | 'unregistered' | 'error'; + registeredUrl: string | null; + expectedUrl: string; + errorMessage: string | null; + }; + } | null; }; const state = vi.hoisted(() => ({ + cloudEnabled: false, slackInstallation: null as null | { teamName?: string }, slackInstallationIsPending: false, connectSlackIsPending: false, @@ -91,6 +102,10 @@ const state = vi.hoisted(() => ({ createSlackAppIsPending: false, })); +vi.mock('@/hooks/useUser', () => ({ + useAuthorizedUser: () => ({ cloudEnabled: state.cloudEnabled }), +})); + const mutations = vi.hoisted(() => ({ connectSlack: vi.fn(), disconnectSlack: vi.fn(), @@ -333,6 +348,7 @@ vi.mock('@/components/system', () => ({ ), Label: ({ children }: { children: ReactNode }) => , + Mail: () => , Pencil: () => , Plug: () => , RefreshCw: () => , @@ -366,7 +382,9 @@ vi.mock('@/lib/slack-callback-paths', () => ({ SLACK_SIGN_IN_CALLBACK_PATH: '/api/slack/signin', })); vi.mock('@/app/(onboarding)/setup/providerSetupCopy', () => ({ - getProviderSetupCopy: (providerId: 'slack' | 'microsoft' | 'telegram') => + getProviderSetupCopy: ( + providerId: 'slack' | 'microsoft' | 'telegram' | 'agentmail', + ) => ({ slack: { creationHref: 'https://api.slack.com/apps?new_app=1', @@ -380,6 +398,10 @@ vi.mock('@/app/(onboarding)/setup/providerSetupCopy', () => ({ creationHref: 'https://t.me/BotFather', setupLabel: 'Telegram bot', }, + agentmail: { + creationHref: 'https://console.agentmail.to/dashboard/inboxes', + setupLabel: 'AgentMail API key', + }, })[providerId], })); vi.mock('@/lib/settings', () => ({ @@ -547,9 +569,44 @@ function buildTelegramProvider( }; } +function buildAgentMailProvider( + overrides: Partial = {}, +): CommsProviderStatus { + return { + id: 'agentmail', + label: 'Email (AgentMail)', + fields: [ + { + envVarName: 'R_AGENTMAIL_API_KEY', + acceptedEnvVarNames: ['R_AGENTMAIL_API_KEY'], + label: 'AgentMail API Key', + secret: true, + runtimeSatisfied: true, + savedSatisfied: false, + satisfiedByEnvVarName: 'R_AGENTMAIL_API_KEY', + }, + ], + runtimeSatisfied: true, + savedSatisfied: false, + setupSatisfied: true, + agentmail: { + inboxAddress: 'workspace@roomote.me', + inboxEmail: 'workspace@roomote.me', + webhook: { + status: 'connected', + registeredUrl: 'https://workspace.example/api/webhooks/agentmail', + expectedUrl: 'https://workspace.example/api/webhooks/agentmail', + errorMessage: null, + }, + }, + ...overrides, + }; +} + describe('CommsProviderSection', () => { beforeEach(() => { vi.clearAllMocks(); + state.cloudEnabled = false; state.slackInstallation = null; state.slackInstallationIsPending = false; state.connectSlackIsPending = false; @@ -1349,5 +1406,72 @@ describe('CommsProviderSection', () => { screen.queryByText(/doesn't look like an Entra app ID/), ).not.toBeInTheDocument(); }); + + it('shows Cloud-managed Email status without configuration controls', () => { + state.cloudEnabled = true; + render( + , + ); + + expect( + screen.getByText('Email is managed by Roomote Cloud.'), + ).toBeVisible(); + expect(screen.getByText('workspace@roomote.me')).toBeVisible(); + expect(screen.getByText(/Webhook connected/)).toBeVisible(); + expect(screen.queryByText('AgentMail API Key')).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Save' }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Remove' }), + ).not.toBeInTheDocument(); + }); + + it('keeps self-hosted Email configuration visible', () => { + render( + , + ); + + expect(screen.getByText('AgentMail API Key')).toBeVisible(); + expect( + screen.queryByText('Email is managed by Roomote Cloud.'), + ).not.toBeInTheDocument(); + }); + + it('shows when Cloud-managed Email has not been provisioned', () => { + state.cloudEnabled = true; + render( + , + ); + + expect( + screen.getByText( + 'Managed Email is unavailable. Roomote Cloud has not provisioned an inbox for this deployment.', + ), + ).toBeVisible(); + expect(screen.queryByText('AgentMail API Key')).not.toBeInTheDocument(); + }); }); }); diff --git a/apps/web/src/components/settings/CommsProviderSection.tsx b/apps/web/src/components/settings/CommsProviderSection.tsx index 2b35c40c86..69e18d9d8e 100644 --- a/apps/web/src/components/settings/CommsProviderSection.tsx +++ b/apps/web/src/components/settings/CommsProviderSection.tsx @@ -8,6 +8,7 @@ import type { SetupAuthProviderStatus } from '@roomote/types'; import type { AgentMailCommsStatus } from '@/trpc/commands/comms'; import { useTRPC } from '@/trpc/client'; +import { useAuthorizedUser } from '@/hooks/useUser'; import { useConnectSlack, useDisconnectSlack, @@ -353,6 +354,8 @@ export function CommsProviderSection({ savePending, clearPending, }: CommsProviderSectionProps) { + const { cloudEnabled } = useAuthorizedUser(); + const agentMailStatusOnly = cloudEnabled && provider.id === 'agentmail'; const trpc = useTRPC(); const queryClient = useQueryClient(); const repairTelegram = useMutation( @@ -628,7 +631,10 @@ export function CommsProviderSection({ ) : null } > - {!expanded && !provider.runtimeSatisfied && !provider.savedSatisfied ? ( + {!agentMailStatusOnly && + !expanded && + !provider.runtimeSatisfied && + !provider.savedSatisfied ? (

Not configured.{' '} - - - - + {agentMailStatusOnly ? null : ( +

+ + + Remove {provider.label} credentials? + + Saved {provider.label} credentials will be removed from the + database. Configured environment variables are not affected. + + + + + + + + + )} ); } diff --git a/apps/web/src/trpc/commands/comms/index.test.ts b/apps/web/src/trpc/commands/comms/index.test.ts index 14d24a82e9..c72aa1b80b 100644 --- a/apps/web/src/trpc/commands/comms/index.test.ts +++ b/apps/web/src/trpc/commands/comms/index.test.ts @@ -241,8 +241,15 @@ vi.mock('@roomote/communication/teams-credential-validation', () => ({ })); vi.mock('@/lib/server/env', () => ({ - Env: { R_APP_URL: 'https://app.example.com' }, + Env: { + R_APP_URL: 'https://app.example.com', + get R_CLOUD_ENABLED() { + return process.env.R_CLOUD_ENABLED; + }, + }, isEmailChannelEnabled: () => process.env.R_EMAIL_CHANNEL_ENABLED === 'true', + isRoomoteCloudEnabled: (value: string | boolean | undefined) => + value === true || value === 'true' || value === '1', })); vi.mock('../environment-variables', () => ({ @@ -305,6 +312,7 @@ describe('comms commands', () => { beforeEach(() => { vi.clearAllMocks(); + delete process.env.R_CLOUD_ENABLED; mockTxSelect.mockReset(); mockGetPersistedEnvironmentVariableNames.mockResolvedValue([]); mockGetPersistedEnvironmentVariableValues.mockResolvedValue({}); @@ -811,6 +819,27 @@ describe('comms commands', () => { process.env.R_EMAIL_CHANNEL_ENABLED = 'true'; } }); + + it('rejects Cloud-managed Email mutations before provider or database work', async () => { + process.env.R_CLOUD_ENABLED = 'true'; + + await expect( + saveCommsAuthConfigCommand(buildMockAuth(), { + provider: 'agentmail', + values: { R_AGENTMAIL_API_KEY: 'replacement-key' }, + }), + ).rejects.toThrow('Email configuration is managed by Roomote Cloud.'); + await expect( + clearCommsAuthConfigCommand(buildMockAuth(), { + provider: 'agentmail', + }), + ).rejects.toThrow('Email configuration is managed by Roomote Cloud.'); + + expect(mockAgentMailListInboxes).not.toHaveBeenCalled(); + expect(mockAgentMailDeleteWebhook).not.toHaveBeenCalled(); + expect(mockDbTransaction).not.toHaveBeenCalled(); + expect(mockUpsertDeploymentEnvironmentVariables).not.toHaveBeenCalled(); + }); }); describe('agentmail save reconcile', () => { diff --git a/apps/web/src/trpc/commands/comms/index.ts b/apps/web/src/trpc/commands/comms/index.ts index 90ae70c574..fd8d54e311 100644 --- a/apps/web/src/trpc/commands/comms/index.ts +++ b/apps/web/src/trpc/commands/comms/index.ts @@ -45,7 +45,11 @@ import { syncDiscordInstallationChannels, } from '@roomote/sdk/server'; -import { Env, isEmailChannelEnabled } from '@/lib/server/env'; +import { + Env, + isEmailChannelEnabled, + isRoomoteCloudEnabled, +} from '@/lib/server/env'; import { DISCORD_INSTALL_PERMISSIONS } from '@/lib/discord-install'; import { PRODUCT_NAME, @@ -670,6 +674,12 @@ function assertEmailChannelEnabled(): void { } } +function assertAgentMailMutationAllowed(provider: CommsProviderId): void { + if (provider === 'agentmail' && isRoomoteCloudEnabled(Env.R_CLOUD_ENABLED)) { + throw new Error('Email configuration is managed by Roomote Cloud.'); + } +} + /** * Pull "METHOD /path" plus AgentMail's response detail out of the client's * error message (`AgentMail GET /v0/webhooks failed (403): {...}`), trimmed @@ -1471,6 +1481,7 @@ export async function saveCommsAuthConfigCommand( }, ) { assertAdmin(auth); + assertAgentMailMutationAllowed(input.provider); const { userId } = auth; const provider = getCommsProviderDefinition(input.provider); @@ -1737,6 +1748,7 @@ export async function clearCommsAuthConfigCommand( input: { provider: CommsProviderId }, ) { assertAdmin(auth); + assertAgentMailMutationAllowed(input.provider); const provider = getCommsProviderDefinition(input.provider); const fieldEnvVarNames = provider.fields.flatMap((field) => [