diff --git a/apps/docs/providers/communications/agentmail.mdx b/apps/docs/providers/communications/agentmail.mdx index 9fa86b3084..9c3794e22b 100644 --- a/apps/docs/providers/communications/agentmail.mdx +++ b/apps/docs/providers/communications/agentmail.mdx @@ -132,6 +132,13 @@ channel enabled: channel was enabled) connect their address the first time they email Roomote: the refusal email carries a link that ties the address to their account. +- **Personal settings > Linked Accounts** shows the Email row's verification + status separately from sender addresses connected through that refusal link. + When the login email is not verified and AgentMail is ready to deliver, use + **Resend** to request another verification message. A delivery failure is + shown instead of reporting success. To connect another sender address, + send from that address to the deployment inbox and use the link in Roomote's + reply; this does not verify or change the login email. - Sign-ins through Slack, Microsoft, GitHub, and the other OAuth providers are unaffected; those providers assert a verified email. - Password reset links are emailed to the user as well as shown to the diff --git a/apps/web/src/components/settings/LinkedAccounts.test.tsx b/apps/web/src/components/settings/LinkedAccounts.test.tsx index cff1441200..eac35a63c2 100644 --- a/apps/web/src/components/settings/LinkedAccounts.test.tsx +++ b/apps/web/src/components/settings/LinkedAccounts.test.tsx @@ -46,6 +46,16 @@ const state = vi.hoisted(() => ({ deploymentEnablementsIsPending: false, userConnections: [] as Array<{ mcpId: string; authStatus: string }>, userConnectionsIsPending: false, + emailAccounts: null as { + emailEnabled: boolean; + verificationDeliveryAvailable: boolean; + primaryEmail: { emailAddress: string; verified: boolean } | null; + senderAddresses: string[]; + canViewInboxAddress: boolean; + inboxEmail: string | null; + } | null, + emailAccountsIsPending: false, + emailAccountsIsError: false, gitHubInstallations: [{ id: 'gh-1' }], gitHubInstallationsIsPending: false, githubAccount: null, @@ -161,6 +171,7 @@ const mutations = vi.hoisted(() => ({ unlinkDiscord: vi.fn(), connectMcp: vi.fn(), disconnectMcp: vi.fn(), + resendEmailVerification: vi.fn(), })); type AuthClientLinkedAccountTestCase = { @@ -388,6 +399,15 @@ vi.mock('@/hooks/linear', () => ({ })); vi.mock('@/hooks/linked-accounts', () => ({ + useLinkedEmailAccounts: () => ({ + data: state.emailAccounts, + isPending: state.emailAccountsIsPending, + isError: state.emailAccountsIsError, + }), + useResendEmailVerification: () => ({ + isPending: false, + mutate: mutations.resendEmailVerification, + }), useAuthenticateAdoAccount: () => ({ isPending: false, mutate: mutations.authenticateAdo, @@ -546,6 +566,8 @@ vi.mock('@/components/system', () => ({ Github: () => , LinearLogo: () => , LucideLink: () => , + Mail: () => , + RefreshCw: () => , Skeleton: ({ className }: { className?: string }) => (
loading @@ -593,6 +615,9 @@ describe('LinkedAccounts settings', () => { state.deploymentEnablementsIsPending = false; state.userConnections = []; state.userConnectionsIsPending = false; + state.emailAccounts = null; + state.emailAccountsIsPending = false; + state.emailAccountsIsError = false; state.gitHubInstallations = [{ id: 'gh-1' }]; state.gitHubInstallationsIsPending = false; state.githubAccount = null; @@ -680,6 +705,104 @@ describe('LinkedAccounts settings', () => { } }); + it('shows a verified login email separately from linked sender addresses', () => { + state.emailAccounts = { + emailEnabled: true, + verificationDeliveryAvailable: true, + primaryEmail: { emailAddress: 'login@example.com', verified: true }, + senderAddresses: ['sender@example.com'], + canViewInboxAddress: true, + inboxEmail: 'roomote@example.com', + }; + + render(); + + expect(screen.getByText('Email')).toBeInTheDocument(); + expect(screen.getByText('login@example.com')).toBeInTheDocument(); + expect(screen.getByText('Verified')).toBeInTheDocument(); + expect(screen.getByText('Email sender')).toBeInTheDocument(); + expect(screen.getByText('sender@example.com')).toBeInTheDocument(); + expect(screen.getByText('Linked')).toBeInTheDocument(); + expect(screen.getByText('roomote@example.com')).toBeInTheDocument(); + expect( + screen.getByText(/to link another sender address/i), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Resend verification email' }), + ).not.toBeInTheDocument(); + }); + + it('resends verification for the unverified login email through the existing auth flow', () => { + state.emailAccounts = { + emailEnabled: true, + verificationDeliveryAvailable: true, + primaryEmail: { emailAddress: 'login@example.com', verified: false }, + senderAddresses: ['login@example.com'], + canViewInboxAddress: false, + inboxEmail: null, + }; + + render(); + + expect(screen.getByText('Not verified')).toBeInTheDocument(); + expect(screen.getByText('Linked')).toBeInTheDocument(); + fireEvent.click( + screen.getByRole('button', { name: 'Resend verification email' }), + ); + + expect(mutations.resendEmailVerification).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ); + expect( + screen.getByText(/ask an admin for the inbox address/i), + ).toBeInTheDocument(); + }); + + it('truthfully disables email actions when the email channel is disabled', () => { + state.emailAccounts = { + emailEnabled: false, + verificationDeliveryAvailable: false, + primaryEmail: { emailAddress: 'login@example.com', verified: false }, + senderAddresses: ['sender@example.com'], + canViewInboxAddress: true, + inboxEmail: null, + }; + + render(); + + expect( + screen.getByText(/email is disabled for this deployment/i), + ).toBeInTheDocument(); + expect(screen.getByText('Linked')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Resend verification email' }), + ).not.toBeInTheDocument(); + }); + + it('does not offer resend before AgentMail delivery is configured', () => { + state.emailAccounts = { + emailEnabled: true, + verificationDeliveryAvailable: false, + primaryEmail: { emailAddress: 'login@example.com', verified: false }, + senderAddresses: [], + canViewInboxAddress: true, + inboxEmail: null, + }; + + render(); + + expect( + screen.getByText(/no AgentMail inbox is configured/i), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Resend verification email' }), + ).not.toBeInTheDocument(); + }); + it('renders an enabled MCP linked account with unlink actions when authenticated', () => { state.deploymentEnablements = [ ...createMcpEnablements(new Set([linkedIntegration.id])), diff --git a/apps/web/src/components/settings/LinkedAccounts.tsx b/apps/web/src/components/settings/LinkedAccounts.tsx index 390f20c6ff..02aab74c0a 100644 --- a/apps/web/src/components/settings/LinkedAccounts.tsx +++ b/apps/web/src/components/settings/LinkedAccounts.tsx @@ -32,10 +32,12 @@ import { useBitbucketLinkedAccount, useGiteaLinkedAccount, useGitHubLinkedAccount, + useLinkedEmailAccounts, useLinearLinkedAccount, useMicrosoftTeamsLinkedAccount, useSlackLinkedAccount, useTelegramLinkedAccount, + useResendEmailVerification, useUnlinkAdoLinkedAccount, useUnlinkGitLabLinkedAccount, useUnlinkBitbucketLinkedAccount, @@ -61,6 +63,7 @@ import { useAuthorizedUser } from '@/hooks/useUser'; import { BrandIcon, + Badge, Button, Dialog, DialogContent, @@ -70,6 +73,8 @@ import { Github, LinearLogo, LucideLink, + Mail, + RefreshCw, Skeleton, Slack, Spinner, @@ -402,6 +407,60 @@ function LinkedAccountRowSkeleton() { ); } +function EmailAccountDetails({ + emailAddress, + status, + variant, +}: { + emailAddress: string; + status: 'Linked' | 'Not verified' | 'Verified'; + variant: 'success' | 'warning'; +}) { + return ( + + {emailAddress} + {status} + + ); +} + +function EmailLinkingGuidance({ + canViewInboxAddress, + emailEnabled, + inboxEmail, +}: { + canViewInboxAddress: boolean; + emailEnabled: boolean; + inboxEmail: string | null; +}) { + if (!emailEnabled) { + return ( +

+ Email is disabled for this deployment. Verification messages and + sender-address linking are unavailable until an admin enables it. +

+ ); + } + + if (inboxEmail) { + return ( +

+ To link another sender address, email{' '} + {inboxEmail} from that + address, then use the link in Roomote's reply. +

+ ); + } + + return ( +

+ {canViewInboxAddress + ? 'Email is enabled, but no AgentMail inbox is configured. Configure it in Communications before linking sender addresses.' + : "To link another sender address, email your deployment's Roomote inbox, then use the link in Roomote's reply. Ask an admin for the inbox address."} +

+ ); +} + export function LinkedAccounts() { const pathname = usePathname(); const searchParams = useSearchParams(); @@ -411,6 +470,8 @@ export function LinkedAccounts() { const userConnections = useUserMcpConnections(); const connectMcp = useConnectMcp(); const disconnectMcp = useDisconnectMcp(); + const emailAccounts = useLinkedEmailAccounts(); + const resendEmailVerification = useResendEmailVerification(); const githubInstallations = useGitHubInstallations(); const githubAccount = useGitHubLinkedAccount(); @@ -810,8 +871,14 @@ export function LinkedAccounts() { }), ].filter(isLinkedAccountDescriptor); - const hasVisibleRows = linkedAccountDescriptors.length > 0; + const primaryEmail = emailAccounts.data?.primaryEmail; + const hasVisibleEmailRows = Boolean( + primaryEmail || emailAccounts.data?.senderAddresses.length, + ); + const hasVisibleRows = + hasVisibleEmailRows || linkedAccountDescriptors.length > 0; const isLoadingVisibleRows = + emailAccounts.isPending || githubInstallations.isPending || gitlabAccount.isPending || giteaAccount.isPending || @@ -837,10 +904,88 @@ export function LinkedAccounts() {
) : null} - {!showLoadingState && !hasVisibleRows ? ( + {!showLoadingState && !hasVisibleRows && !emailAccounts.isError ? (

{emptyStateMessage}

) : null} + {emailAccounts.isError ? ( +

+ Unable to load email account status. +

+ ) : null} + + {primaryEmail ? ( + } + name="Email" + details={ + + } + actions={ + !primaryEmail.verified && + emailAccounts.data?.verificationDeliveryAvailable ? ( + + ) : null + } + /> + ) : null} + + {emailAccounts.data?.senderAddresses.map((emailAddress) => ( + } + name="Email sender" + details={ + + } + /> + ))} + + {emailAccounts.data ? ( + + ) : null} + {[...linkedAccountDescriptors] .sort(sortLinkedAccountDescriptors) .map((descriptor) => ( diff --git a/apps/web/src/hooks/linked-accounts/index.ts b/apps/web/src/hooks/linked-accounts/index.ts index bee2e15f7f..08cb9b4cda 100644 --- a/apps/web/src/hooks/linked-accounts/index.ts +++ b/apps/web/src/hooks/linked-accounts/index.ts @@ -33,3 +33,5 @@ export * from './useCreateDiscordLinkCode'; export * from './useEmailLinkPreview'; export * from './useLinkEmailAddress'; +export * from './useLinkedEmailAccounts'; +export * from './useResendEmailVerification'; diff --git a/apps/web/src/hooks/linked-accounts/useLinkedEmailAccounts.ts b/apps/web/src/hooks/linked-accounts/useLinkedEmailAccounts.ts new file mode 100644 index 0000000000..c2a771f95f --- /dev/null +++ b/apps/web/src/hooks/linked-accounts/useLinkedEmailAccounts.ts @@ -0,0 +1,9 @@ +import { useQuery } from '@tanstack/react-query'; + +import { useTRPC } from '@/trpc/client'; + +export const useLinkedEmailAccounts = () => { + const trpc = useTRPC(); + + return useQuery(trpc.linkedAccounts.email.queryOptions()); +}; diff --git a/apps/web/src/hooks/linked-accounts/useResendEmailVerification.test.tsx b/apps/web/src/hooks/linked-accounts/useResendEmailVerification.test.tsx new file mode 100644 index 0000000000..f8ffbd5d9a --- /dev/null +++ b/apps/web/src/hooks/linked-accounts/useResendEmailVerification.test.tsx @@ -0,0 +1,37 @@ +import { renderHook } from '@testing-library/react'; + +const { mutationOptions, mutationResult, useMutationMock } = vi.hoisted(() => ({ + mutationOptions: { mutationKey: ['resend-email-verification'] }, + mutationResult: { isPending: false, mutate: vi.fn() }, + useMutationMock: vi.fn(), +})); + +vi.mock('@tanstack/react-query', () => ({ + useMutation: useMutationMock, +})); + +vi.mock('@/trpc/client', () => ({ + useTRPC: () => ({ + linkedAccounts: { + resendEmailVerification: { + mutationOptions: () => mutationOptions, + }, + }, + }), +})); + +import { useResendEmailVerification } from './useResendEmailVerification'; + +describe('useResendEmailVerification', () => { + beforeEach(() => { + vi.clearAllMocks(); + useMutationMock.mockReturnValue(mutationResult); + }); + + it('uses the protected linked-account resend mutation', () => { + const { result } = renderHook(() => useResendEmailVerification()); + + expect(useMutationMock).toHaveBeenCalledWith(mutationOptions); + expect(result.current).toBe(mutationResult); + }); +}); diff --git a/apps/web/src/hooks/linked-accounts/useResendEmailVerification.ts b/apps/web/src/hooks/linked-accounts/useResendEmailVerification.ts new file mode 100644 index 0000000000..801f7d7625 --- /dev/null +++ b/apps/web/src/hooks/linked-accounts/useResendEmailVerification.ts @@ -0,0 +1,11 @@ +import { useMutation } from '@tanstack/react-query'; + +import { useTRPC } from '@/trpc/client'; + +export const useResendEmailVerification = () => { + const trpc = useTRPC(); + + return useMutation( + trpc.linkedAccounts.resendEmailVerification.mutationOptions(), + ); +}; diff --git a/apps/web/src/lib/server/auth.test.ts b/apps/web/src/lib/server/auth.test.ts index dbab951ad2..a3dbf33b54 100644 --- a/apps/web/src/lib/server/auth.test.ts +++ b/apps/web/src/lib/server/auth.test.ts @@ -5,6 +5,9 @@ const { mockResolveAuthProviderConfig, mockSourceControlMappingValues, mockSourceControlMappingUpsert, + mockIsEmailChannelEnabled, + mockSendAgentMailSystemEmail, + mockAuthSendVerificationEmail, } = vi.hoisted(() => { const calls: Array<{ config: Array<{ @@ -18,6 +21,7 @@ const { mockBetterAuth: vi.fn((options) => ({ api: { getSession: vi.fn(), + sendVerificationEmail: mockAuthSendVerificationEmail, }, handler: vi.fn(), options, @@ -29,6 +33,9 @@ const { mockResolveAuthProviderConfig: vi.fn(), mockSourceControlMappingValues: vi.fn(), mockSourceControlMappingUpsert: vi.fn(), + mockIsEmailChannelEnabled: vi.fn(), + mockSendAgentMailSystemEmail: vi.fn(), + mockAuthSendVerificationEmail: vi.fn(), }; }); @@ -56,6 +63,10 @@ vi.mock('@better-auth/drizzle-adapter', () => ({ drizzleAdapter: vi.fn(() => ({ id: 'drizzle-adapter' })), })); +vi.mock('@roomote/sdk/server/agentmail-outbound', () => ({ + sendAgentMailSystemEmail: mockSendAgentMailSystemEmail, +})); + vi.mock('@roomote/db/server', () => ({ and: vi.fn(), authUsers: {}, @@ -99,7 +110,7 @@ vi.mock('./env', () => ({ R_ALLOWED_EMAILS: undefined, R_APP_URL: 'http://localhost:3000', }, - isEmailChannelEnabled: () => false, + isEmailChannelEnabled: mockIsEmailChannelEnabled, getEncryptionKey: () => 'test-encryption-key', getBetterAuthSecret: () => 'test-better-auth-secret', })); @@ -113,7 +124,7 @@ vi.mock('./canonical-forwarded-proto', () => ({ withCanonicalForwardedProto: vi.fn((request) => request), })); -import { getAuth } from './auth'; +import { getAuth, sendAuthenticatedVerificationEmail } from './auth'; function getAdoOAuthProvider() { const config = genericOAuthCalls.at(-1)?.config; @@ -157,6 +168,8 @@ describe('getAuth', () => { slackClientId: undefined, slackClientSecret: undefined, }); + mockIsEmailChannelEnabled.mockReturnValue(false); + mockSendAgentMailSystemEmail.mockResolvedValue({ sent: true }); }); afterEach(() => { @@ -186,6 +199,48 @@ describe('getAuth', () => { expect(options.emailAndPassword.revokeSessionsOnPasswordReset).toBe(true); }); + it('reports authenticated resend delivery failures without weakening public endpoint privacy', async () => { + mockIsEmailChannelEnabled.mockReturnValue(true); + mockSendAgentMailSystemEmail.mockResolvedValue({ + sent: false, + reason: 'send_failed', + }); + await getAuth(); + + const options = mockBetterAuth.mock.calls.at(-1)?.[0] as { + emailVerification?: { + sendVerificationEmail?: ( + input: { user: { email: string }; url: string }, + request?: Request, + ) => Promise; + }; + }; + const sendVerificationEmail = + options.emailVerification?.sendVerificationEmail; + const input = { + user: { email: 'person@example.com' }, + url: 'http://localhost:3000/api/auth/verify-email?token=token', + }; + + await expect( + sendVerificationEmail?.(input, new Request('http://localhost:3000')), + ).resolves.toBeUndefined(); + + mockAuthSendVerificationEmail.mockImplementation(async () => { + await sendVerificationEmail?.( + input, + new Request('http://localhost:3000/api/auth/send-verification-email'), + ); + }); + await expect( + sendAuthenticatedVerificationEmail({ + email: 'person@example.com', + callbackURL: '/settings/personal', + headers: new Headers({ cookie: 'session=valid' }), + }), + ).rejects.toThrow('Verification email could not be delivered'); + }); + it('keys the Entra linked-account identity on the normalized uniqueName', async () => { const fetchMock = vi.fn(async (url: string | URL | Request) => { const href = String(url); diff --git a/apps/web/src/lib/server/auth.ts b/apps/web/src/lib/server/auth.ts index 418f4fc5c2..033eb25264 100644 --- a/apps/web/src/lib/server/auth.ts +++ b/apps/web/src/lib/server/auth.ts @@ -59,6 +59,10 @@ type RoomoteAuth = { headers: Headers; query?: { disableRefresh?: boolean }; }): Promise; + sendVerificationEmail(input: { + body: { callbackURL: string; email: string }; + headers: Headers; + }): Promise; requestPasswordReset(input: { body: { email: string; @@ -82,6 +86,7 @@ let authSignature: string | null = null; const resetPasswordLinkCapture = new AsyncLocalStorage<{ url?: string; }>(); +const verificationEmailDeliveryRequired = new AsyncLocalStorage(); export const PASSWORD_RESET_TOKEN_EXPIRES_IN_SECONDS = 60 * 60; export async function capturePasswordResetLink( @@ -91,6 +96,20 @@ export async function capturePasswordResetLink( await resetPasswordLinkCapture.run(capture, callback); return capture.url ?? null; } + +export async function sendAuthenticatedVerificationEmail(input: { + callbackURL: string; + email: string; + headers: Headers; +}): Promise { + const roomoteAuth = await getAuth(); + await verificationEmailDeliveryRequired.run(true, () => + roomoteAuth.api.sendVerificationEmail({ + body: { email: input.email, callbackURL: input.callbackURL }, + headers: input.headers, + }), + ); +} type MicrosoftAuthAccountHookRow = { id?: unknown; userId?: unknown; @@ -1136,6 +1155,11 @@ async function createAuth(authProviderConfig: ResolvedAuthProviderConfig) { console.warn( `[auth] Could not send the verification email to ${user.email} (${result.reason}).`, ); + if (verificationEmailDeliveryRequired.getStore()) { + throw new Error( + 'Verification email could not be delivered. Check the address or ask an admin to check the email configuration.', + ); + } } }, }, diff --git a/apps/web/src/trpc/commands/linked-accounts/email-link.test.ts b/apps/web/src/trpc/commands/linked-accounts/email-link.test.ts index cfcc42698b..40c2254a1e 100644 --- a/apps/web/src/trpc/commands/linked-accounts/email-link.test.ts +++ b/apps/web/src/trpc/commands/linked-accounts/email-link.test.ts @@ -4,6 +4,13 @@ const { mockVerifyAgentMailEmailLinkToken, mockRedispatchAgentMailEventsForSender, mockFindFirst, + mockFindMany, + mockAuthUserFindFirst, + mockIsEmailChannelEnabled, + mockAgentMailGetInbox, + mockRedisEval, + mockHeaders, + mockSendAuthenticatedVerificationEmail, mockInsert, mockValues, mockOnConflictDoNothing, @@ -20,6 +27,13 @@ const { mockVerifyAgentMailEmailLinkToken: vi.fn(), mockRedispatchAgentMailEventsForSender: vi.fn(), mockFindFirst: vi.fn(), + mockFindMany: vi.fn(), + mockAuthUserFindFirst: vi.fn(), + mockIsEmailChannelEnabled: vi.fn(), + mockAgentMailGetInbox: vi.fn(), + mockRedisEval: vi.fn(), + mockHeaders: vi.fn(), + mockSendAuthenticatedVerificationEmail: vi.fn(), mockInsert, mockValues, mockOnConflictDoNothing, @@ -30,13 +44,52 @@ const { vi.mock('@roomote/db/server', () => ({ db: { insert: mockInsert, - query: { agentmailUserMappings: { findFirst: mockFindFirst } }, + query: { + agentmailUserMappings: { + findFirst: mockFindFirst, + findMany: mockFindMany, + }, + authUsers: { findFirst: mockAuthUserFindFirst }, + }, }, agentmailUserMappings: { id: 'agentmail_user_mappings.id', emailAddress: 'agentmail_user_mappings.email_address', + userId: 'agentmail_user_mappings.user_id', + source: 'agentmail_user_mappings.source', + createdAt: 'agentmail_user_mappings.created_at', }, + authUsers: { id: 'auth_users.id' }, + and: vi.fn((...conditions) => conditions), + asc: vi.fn((column) => column), eq: vi.fn(), + resolveAgentMailRuntimeCredentials: vi.fn(async () => ({ + apiKey: 'api-key', + webhookSecret: 'webhook-secret', + inboxId: 'roomote@example.com', + })), +})); + +vi.mock('@roomote/communication', () => ({ + AgentMailApiClient: class { + getInbox = mockAgentMailGetInbox; + }, +})); + +vi.mock('@roomote/redis', () => ({ + getRedis: () => ({ eval: mockRedisEval }), +})); + +vi.mock('next/headers', () => ({ + headers: mockHeaders, +})); + +vi.mock('@/lib/server/auth', () => ({ + sendAuthenticatedVerificationEmail: mockSendAuthenticatedVerificationEmail, +})); + +vi.mock('@/lib/server/env', () => ({ + isEmailChannelEnabled: mockIsEmailChannelEnabled, })); vi.mock('@roomote/sdk/server', () => ({ @@ -44,10 +97,93 @@ vi.mock('@roomote/sdk/server', () => ({ redispatchAgentMailEventsForSender: mockRedispatchAgentMailEventsForSender, })); -import { linkEmailAddressCommand, previewEmailLinkCommand } from './email-link'; +import { + getLinkedEmailAccountsCommand, + linkEmailAddressCommand, + previewEmailLinkCommand, + resendPrimaryEmailVerificationCommand, +} from './email-link'; const mockAuth = { userId: 'user-1' } as UserAuthSuccess; +describe('getLinkedEmailAccountsCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsEmailChannelEnabled.mockReturnValue(true); + mockAuthUserFindFirst.mockResolvedValue({ + email: 'login@example.com', + emailVerified: false, + }); + mockFindMany.mockResolvedValue([{ emailAddress: 'sender@example.com' }]); + mockAgentMailGetInbox.mockResolvedValue({ + inbox_id: 'routing-id', + email: 'Deliverable@Example.com', + }); + }); + + it('keeps login verification and explicit sender links distinct', async () => { + await expect( + getLinkedEmailAccountsCommand({ + ...mockAuth, + isAdmin: true, + }), + ).resolves.toEqual({ + emailEnabled: true, + verificationDeliveryAvailable: true, + primaryEmail: { + emailAddress: 'login@example.com', + verified: false, + }, + senderAddresses: ['sender@example.com'], + canViewInboxAddress: true, + inboxEmail: 'deliverable@example.com', + }); + }); + + it('does not expose the deployment inbox address to non-admins', async () => { + await expect( + getLinkedEmailAccountsCommand({ + ...mockAuth, + isAdmin: false, + }), + ).resolves.toMatchObject({ + canViewInboxAddress: false, + inboxEmail: null, + verificationDeliveryAvailable: true, + }); + }); + + it('reports an email-disabled deployment without inbox information', async () => { + mockIsEmailChannelEnabled.mockReturnValue(false); + + await expect( + getLinkedEmailAccountsCommand({ + ...mockAuth, + isAdmin: true, + }), + ).resolves.toMatchObject({ + emailEnabled: false, + verificationDeliveryAvailable: false, + canViewInboxAddress: true, + inboxEmail: null, + }); + }); + + it('omits the inbox address when AgentMail cannot resolve a deliverable email', async () => { + mockAgentMailGetInbox.mockResolvedValue({ inbox_id: 'routing-id' }); + + await expect( + getLinkedEmailAccountsCommand({ + ...mockAuth, + isAdmin: true, + }), + ).resolves.toMatchObject({ + verificationDeliveryAvailable: true, + inboxEmail: null, + }); + }); +}); + describe('previewEmailLinkCommand', () => { beforeEach(() => { vi.clearAllMocks(); @@ -74,6 +210,42 @@ describe('previewEmailLinkCommand', () => { }); }); +describe('resendPrimaryEmailVerificationCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockRedisEval.mockResolvedValue(1); + mockHeaders.mockResolvedValue(new Headers({ cookie: 'session=valid' })); + mockSendAuthenticatedVerificationEmail.mockResolvedValue(undefined); + }); + + it('resends only the authenticated user login email', async () => { + await expect( + resendPrimaryEmailVerificationCommand({ + ...mockAuth, + primaryEmail: 'login@example.com', + }), + ).resolves.toBeUndefined(); + + expect(mockSendAuthenticatedVerificationEmail).toHaveBeenCalledWith({ + email: 'login@example.com', + callbackURL: '/settings/personal', + headers: expect.any(Headers), + }); + }); + + it('limits authenticated resend attempts to three per minute', async () => { + mockRedisEval.mockResolvedValue(4); + + await expect( + resendPrimaryEmailVerificationCommand({ + ...mockAuth, + primaryEmail: 'login@example.com', + }), + ).rejects.toThrow('Too many verification requests'); + expect(mockSendAuthenticatedVerificationEmail).not.toHaveBeenCalled(); + }); +}); + describe('linkEmailAddressCommand', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/apps/web/src/trpc/commands/linked-accounts/email-link.ts b/apps/web/src/trpc/commands/linked-accounts/email-link.ts index 917edbd3a6..2743680cbf 100644 --- a/apps/web/src/trpc/commands/linked-accounts/email-link.ts +++ b/apps/web/src/trpc/commands/linked-accounts/email-link.ts @@ -1,14 +1,50 @@ -import { agentmailUserMappings, db, eq } from '@roomote/db/server'; +import { + agentmailUserMappings, + and, + asc, + authUsers, + db, + eq, + resolveAgentMailRuntimeCredentials, +} from '@roomote/db/server'; +import { AgentMailApiClient } from '@roomote/communication'; +import { getRedis } from '@roomote/redis'; import { redispatchAgentMailEventsForSender, verifyAgentMailEmailLinkToken, } from '@roomote/sdk/server'; import { TRPCError } from '@trpc/server'; +import { headers } from 'next/headers'; import type { UserAuthSuccess } from '@/types'; +import { sendAuthenticatedVerificationEmail } from '@/lib/server/auth'; +import { isEmailChannelEnabled } from '@/lib/server/env'; +import { SETTINGS_PATHS } from '@/lib/settings'; const INVALID_EMAIL_LINK_TOKEN_MESSAGE = 'This link is invalid or has expired. Send another email to get a fresh link.'; +const VERIFICATION_RESEND_WINDOW_SECONDS = 60; +const VERIFICATION_RESEND_MAX_ATTEMPTS = 3; + +async function enforceVerificationResendRateLimit(userId: string) { + const attempts = Number( + await getRedis().eval( + `local count = redis.call('INCR', KEYS[1]) +if count == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end +return count`, + 1, + `email-verification-resend:${userId}`, + String(VERIFICATION_RESEND_WINDOW_SECONDS), + ), + ); + + if (attempts > VERIFICATION_RESEND_MAX_ATTEMPTS) { + throw new TRPCError({ + code: 'TOO_MANY_REQUESTS', + message: 'Too many verification requests. Try again shortly.', + }); + } +} function verifyEmailLinkTokenOrThrow(token: string) { const verified = verifyAgentMailEmailLinkToken(token); @@ -23,6 +59,72 @@ function verifyEmailLinkTokenOrThrow(token: string) { return verified; } +export async function getLinkedEmailAccountsCommand(auth: UserAuthSuccess) { + const emailEnabled = isEmailChannelEnabled(); + const [authUser, senderMappings, credentials] = await Promise.all([ + db.query.authUsers.findFirst({ + where: eq(authUsers.id, auth.userId), + columns: { email: true, emailVerified: true }, + }), + db.query.agentmailUserMappings.findMany({ + where: and( + eq(agentmailUserMappings.userId, auth.userId), + eq(agentmailUserMappings.source, 'link_code'), + ), + orderBy: [asc(agentmailUserMappings.createdAt)], + columns: { emailAddress: true }, + }), + emailEnabled ? resolveAgentMailRuntimeCredentials() : Promise.resolve(null), + ]); + const verificationDeliveryAvailable = Boolean( + emailEnabled && credentials?.apiKey && credentials.inboxId, + ); + let inboxEmail: string | null = null; + + if (auth.isAdmin && credentials?.apiKey && credentials.inboxId) { + try { + const inbox = await new AgentMailApiClient({ + apiKey: credentials.apiKey, + }).getInbox(credentials.inboxId); + inboxEmail = inbox.email?.trim().toLowerCase() || null; + } catch { + inboxEmail = null; + } + } + + return { + emailEnabled, + verificationDeliveryAvailable, + primaryEmail: authUser + ? { + emailAddress: authUser.email, + verified: authUser.emailVerified, + } + : null, + senderAddresses: senderMappings.map(({ emailAddress }) => emailAddress), + canViewInboxAddress: auth.isAdmin, + inboxEmail, + }; +} + +export async function resendPrimaryEmailVerificationCommand( + auth: UserAuthSuccess, +) { + if (!auth.primaryEmail) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'No login email is available for this account.', + }); + } + + await enforceVerificationResendRateLimit(auth.userId); + await sendAuthenticatedVerificationEmail({ + email: auth.primaryEmail, + callbackURL: SETTINGS_PATHS.personal, + headers: await headers(), + }); +} + export async function previewEmailLinkCommand( _auth: UserAuthSuccess, token: string, diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index 02503631a8..31a452cdaa 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -192,6 +192,8 @@ import { createDiscordLinkCodeCommand, unlinkLinkedDiscordAccountCommand, getLinkedMicrosoftTeamsAccountCommand, + getLinkedEmailAccountsCommand, + resendPrimaryEmailVerificationCommand, previewEmailLinkCommand, linkEmailAddressCommand, } from '../commands/linked-accounts'; @@ -1508,6 +1510,14 @@ export const appRouter = createRouter({ }), linkedAccounts: createRouter({ + email: protectedProcedure.query(({ ctx: { auth } }) => + getLinkedEmailAccountsCommand(auth), + ), + + resendEmailVerification: protectedProcedure.mutation(({ ctx: { auth } }) => + resendPrimaryEmailVerificationCommand(auth), + ), + github: protectedProcedure.query(({ ctx: { auth } }) => getLinkedGitHubAccountCommand(auth), ),