From bb205c8c469e7ce87a7a5e8a41fdc39dce85021a Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:32:20 +0000 Subject: [PATCH 1/3] feat: show email accounts in personal settings --- .../providers/communications/agentmail.mdx | 6 + .../settings/LinkedAccounts.test.tsx | 99 ++++++++++++ .../components/settings/LinkedAccounts.tsx | 148 +++++++++++++++++- apps/web/src/hooks/linked-accounts/index.ts | 2 + .../linked-accounts/useLinkedEmailAccounts.ts | 9 ++ .../useResendEmailVerification.test.tsx | 59 +++++++ .../useResendEmailVerification.ts | 20 +++ apps/web/src/lib/auth-client.ts | 11 ++ .../linked-accounts/email-link.test.ts | 92 ++++++++++- .../commands/linked-accounts/email-link.ts | 45 +++++- apps/web/src/trpc/routers/_app.ts | 5 + 11 files changed, 491 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/hooks/linked-accounts/useLinkedEmailAccounts.ts create mode 100644 apps/web/src/hooks/linked-accounts/useResendEmailVerification.test.tsx create mode 100644 apps/web/src/hooks/linked-accounts/useResendEmailVerification.ts diff --git a/apps/docs/providers/communications/agentmail.mdx b/apps/docs/providers/communications/agentmail.mdx index 9fa86b3084..4cbc893e65 100644 --- a/apps/docs/providers/communications/agentmail.mdx +++ b/apps/docs/providers/communications/agentmail.mdx @@ -132,6 +132,12 @@ 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 email is enabled, use **Resend** to + request another verification message. 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..5725b0d6b2 100644 --- a/apps/web/src/components/settings/LinkedAccounts.test.tsx +++ b/apps/web/src/components/settings/LinkedAccounts.test.tsx @@ -46,6 +46,15 @@ const state = vi.hoisted(() => ({ deploymentEnablementsIsPending: false, userConnections: [] as Array<{ mcpId: string; authStatus: string }>, userConnectionsIsPending: false, + emailAccounts: null as { + emailEnabled: boolean; + primaryEmail: { emailAddress: string; verified: boolean } | null; + senderAddresses: string[]; + canViewInboxAddress: boolean; + inboxAddress: string | null; + } | null, + emailAccountsIsPending: false, + emailAccountsIsError: false, gitHubInstallations: [{ id: 'gh-1' }], gitHubInstallationsIsPending: false, githubAccount: null, @@ -161,6 +170,7 @@ const mutations = vi.hoisted(() => ({ unlinkDiscord: vi.fn(), connectMcp: vi.fn(), disconnectMcp: vi.fn(), + resendEmailVerification: vi.fn(), })); type AuthClientLinkedAccountTestCase = { @@ -388,6 +398,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 +565,8 @@ vi.mock('@/components/system', () => ({ Github: () => , LinearLogo: () => , LucideLink: () => , + Mail: () => , + RefreshCw: () => , Skeleton: ({ className }: { className?: string }) => (
loading @@ -593,6 +614,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 +704,81 @@ describe('LinkedAccounts settings', () => { } }); + it('shows a verified login email separately from linked sender addresses', () => { + state.emailAccounts = { + emailEnabled: true, + primaryEmail: { emailAddress: 'login@example.com', verified: true }, + senderAddresses: ['sender@example.com'], + canViewInboxAddress: true, + inboxAddress: '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, + primaryEmail: { emailAddress: 'login@example.com', verified: false }, + senderAddresses: ['login@example.com'], + canViewInboxAddress: false, + inboxAddress: 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( + 'login@example.com', + 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, + primaryEmail: { emailAddress: 'login@example.com', verified: false }, + senderAddresses: ['sender@example.com'], + canViewInboxAddress: true, + inboxAddress: 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('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..3238f98cc4 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, + inboxAddress, +}: { + canViewInboxAddress: boolean; + emailEnabled: boolean; + inboxAddress: 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 (inboxAddress) { + return ( +

+ To link another sender address, email{' '} + {inboxAddress} 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,87 @@ 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?.emailEnabled ? ( + + ) : 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..bf0c07da99 --- /dev/null +++ b/apps/web/src/hooks/linked-accounts/useResendEmailVerification.test.tsx @@ -0,0 +1,59 @@ +import { renderHook } from '@testing-library/react'; + +const { sendVerificationEmail, mutationOptionsRef } = vi.hoisted(() => ({ + sendVerificationEmail: vi.fn(), + mutationOptionsRef: { + current: null as { + mutationFn: (email: string) => Promise; + } | null, + }, +})); + +vi.mock('@tanstack/react-query', () => ({ + useMutation: (options: typeof mutationOptionsRef.current) => { + mutationOptionsRef.current = options; + return { + mutateAsync: async (email: string) => options?.mutationFn(email), + isPending: false, + }; + }, +})); + +vi.mock('@/lib/auth-client', () => ({ + authClient: { sendVerificationEmail }, +})); + +import { useResendEmailVerification } from './useResendEmailVerification'; + +describe('useResendEmailVerification', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('uses Better Auth verification with the personal settings callback', async () => { + sendVerificationEmail.mockResolvedValue({ + data: { status: true }, + error: null, + }); + const { result } = renderHook(() => useResendEmailVerification()); + + await result.current.mutateAsync('login@example.com'); + + expect(sendVerificationEmail).toHaveBeenCalledWith({ + email: 'login@example.com', + callbackURL: '/settings/personal', + }); + }); + + it('turns Better Auth response errors into mutation errors', async () => { + sendVerificationEmail.mockResolvedValue({ + data: null, + error: { message: 'Too many requests' }, + }); + const { result } = renderHook(() => useResendEmailVerification()); + + await expect( + result.current.mutateAsync('login@example.com'), + ).rejects.toThrow('Too many requests'); + }); +}); 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..f32f847c72 --- /dev/null +++ b/apps/web/src/hooks/linked-accounts/useResendEmailVerification.ts @@ -0,0 +1,20 @@ +import { useMutation } from '@tanstack/react-query'; + +import { authClient } from '@/lib/auth-client'; +import { SETTINGS_PATHS } from '@/lib/settings'; + +export const useResendEmailVerification = () => + useMutation({ + mutationFn: async (email: string) => { + const result = await authClient.sendVerificationEmail({ + email, + callbackURL: SETTINGS_PATHS.personal, + }); + + if (result.error) { + throw new Error( + result.error.message || 'Unable to send a verification email.', + ); + } + }, + }); diff --git a/apps/web/src/lib/auth-client.ts b/apps/web/src/lib/auth-client.ts index f29edb5029..0ae7cd58af 100644 --- a/apps/web/src/lib/auth-client.ts +++ b/apps/web/src/lib/auth-client.ts @@ -56,6 +56,14 @@ type ChangeEmailResult = { data?: { status: boolean; message?: string | null } | null; error?: { code?: string; message?: string; status?: number } | null; }; +type SendVerificationEmailInput = { + callbackURL?: string; + email: string; +}; +type SendVerificationEmailResult = { + data?: { status: boolean } | null; + error?: { code?: string; message?: string; status?: number } | null; +}; type RoomoteAuthClient = BaseAuthClient & { signIn: BaseAuthClient['signIn'] & { oauth2(input: OAuth2SignInInput): Promise; @@ -67,6 +75,9 @@ type RoomoteAuthClient = BaseAuthClient & { resetPassword(input: ResetPasswordInput): Promise; changePassword(input: ChangePasswordInput): Promise; changeEmail(input: ChangeEmailInput): Promise; + sendVerificationEmail( + input: SendVerificationEmailInput, + ): Promise; }; export const authClient: RoomoteAuthClient = createAuthClient({ 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..11f806b3b2 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,9 @@ const { mockVerifyAgentMailEmailLinkToken, mockRedispatchAgentMailEventsForSender, mockFindFirst, + mockFindMany, + mockAuthUserFindFirst, + mockIsEmailChannelEnabled, mockInsert, mockValues, mockOnConflictDoNothing, @@ -20,6 +23,9 @@ const { mockVerifyAgentMailEmailLinkToken: vi.fn(), mockRedispatchAgentMailEventsForSender: vi.fn(), mockFindFirst: vi.fn(), + mockFindMany: vi.fn(), + mockAuthUserFindFirst: vi.fn(), + mockIsEmailChannelEnabled: vi.fn(), mockInsert, mockValues, mockOnConflictDoNothing, @@ -30,13 +36,34 @@ 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('@/lib/server/env', () => ({ + isEmailChannelEnabled: mockIsEmailChannelEnabled, })); vi.mock('@roomote/sdk/server', () => ({ @@ -44,10 +71,71 @@ vi.mock('@roomote/sdk/server', () => ({ redispatchAgentMailEventsForSender: mockRedispatchAgentMailEventsForSender, })); -import { linkEmailAddressCommand, previewEmailLinkCommand } from './email-link'; +import { + getLinkedEmailAccountsCommand, + linkEmailAddressCommand, + previewEmailLinkCommand, +} 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' }]); + }); + + it('keeps login verification and explicit sender links distinct', async () => { + await expect( + getLinkedEmailAccountsCommand({ + ...mockAuth, + isAdmin: true, + }), + ).resolves.toEqual({ + emailEnabled: true, + primaryEmail: { + emailAddress: 'login@example.com', + verified: false, + }, + senderAddresses: ['sender@example.com'], + canViewInboxAddress: true, + inboxAddress: 'roomote@example.com', + }); + }); + + it('does not expose the deployment inbox address to non-admins', async () => { + await expect( + getLinkedEmailAccountsCommand({ + ...mockAuth, + isAdmin: false, + }), + ).resolves.toMatchObject({ + canViewInboxAddress: false, + inboxAddress: null, + }); + }); + + it('reports an email-disabled deployment without inbox information', async () => { + mockIsEmailChannelEnabled.mockReturnValue(false); + + await expect( + getLinkedEmailAccountsCommand({ + ...mockAuth, + isAdmin: true, + }), + ).resolves.toMatchObject({ + emailEnabled: false, + canViewInboxAddress: true, + inboxAddress: null, + }); + }); +}); + describe('previewEmailLinkCommand', () => { 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..2e4c201271 100644 --- a/apps/web/src/trpc/commands/linked-accounts/email-link.ts +++ b/apps/web/src/trpc/commands/linked-accounts/email-link.ts @@ -1,4 +1,12 @@ -import { agentmailUserMappings, db, eq } from '@roomote/db/server'; +import { + agentmailUserMappings, + and, + asc, + authUsers, + db, + eq, + resolveAgentMailRuntimeCredentials, +} from '@roomote/db/server'; import { redispatchAgentMailEventsForSender, verifyAgentMailEmailLinkToken, @@ -6,6 +14,7 @@ import { import { TRPCError } from '@trpc/server'; import type { UserAuthSuccess } from '@/types'; +import { isEmailChannelEnabled } from '@/lib/server/env'; const INVALID_EMAIL_LINK_TOKEN_MESSAGE = 'This link is invalid or has expired. Send another email to get a fresh link.'; @@ -23,6 +32,40 @@ 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 }, + }), + auth.isAdmin && emailEnabled + ? resolveAgentMailRuntimeCredentials() + : Promise.resolve(null), + ]); + + return { + emailEnabled, + primaryEmail: authUser + ? { + emailAddress: authUser.email, + verified: authUser.emailVerified, + } + : null, + senderAddresses: senderMappings.map(({ emailAddress }) => emailAddress), + canViewInboxAddress: auth.isAdmin, + inboxAddress: credentials?.inboxId ?? null, + }; +} + 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..a25af0a429 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -192,6 +192,7 @@ import { createDiscordLinkCodeCommand, unlinkLinkedDiscordAccountCommand, getLinkedMicrosoftTeamsAccountCommand, + getLinkedEmailAccountsCommand, previewEmailLinkCommand, linkEmailAddressCommand, } from '../commands/linked-accounts'; @@ -1508,6 +1509,10 @@ export const appRouter = createRouter({ }), linkedAccounts: createRouter({ + email: protectedProcedure.query(({ ctx: { auth } }) => + getLinkedEmailAccountsCommand(auth), + ), + github: protectedProcedure.query(({ ctx: { auth } }) => getLinkedGitHubAccountCommand(auth), ), From ecf5de77e94d846f8c75f784e379d8abadd220af Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:45:49 +0000 Subject: [PATCH 2/3] fix: report email delivery state accurately --- .../providers/communications/agentmail.mdx | 5 +- .../settings/LinkedAccounts.test.tsx | 32 ++++++++++-- .../components/settings/LinkedAccounts.tsx | 15 +++--- apps/web/src/lib/server/auth.test.ts | 49 ++++++++++++++++++- apps/web/src/lib/server/auth.ts | 12 ++++- .../linked-accounts/email-link.test.ts | 35 +++++++++++-- .../commands/linked-accounts/email-link.ts | 23 +++++++-- 7 files changed, 149 insertions(+), 22 deletions(-) diff --git a/apps/docs/providers/communications/agentmail.mdx b/apps/docs/providers/communications/agentmail.mdx index 4cbc893e65..9c3794e22b 100644 --- a/apps/docs/providers/communications/agentmail.mdx +++ b/apps/docs/providers/communications/agentmail.mdx @@ -134,8 +134,9 @@ channel enabled: 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 email is enabled, use **Resend** to - request another verification message. To connect another sender address, + 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 diff --git a/apps/web/src/components/settings/LinkedAccounts.test.tsx b/apps/web/src/components/settings/LinkedAccounts.test.tsx index 5725b0d6b2..0899afa520 100644 --- a/apps/web/src/components/settings/LinkedAccounts.test.tsx +++ b/apps/web/src/components/settings/LinkedAccounts.test.tsx @@ -48,10 +48,11 @@ const state = vi.hoisted(() => ({ userConnectionsIsPending: false, emailAccounts: null as { emailEnabled: boolean; + verificationDeliveryAvailable: boolean; primaryEmail: { emailAddress: string; verified: boolean } | null; senderAddresses: string[]; canViewInboxAddress: boolean; - inboxAddress: string | null; + inboxEmail: string | null; } | null, emailAccountsIsPending: false, emailAccountsIsError: false, @@ -707,10 +708,11 @@ 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, - inboxAddress: 'roomote@example.com', + inboxEmail: 'roomote@example.com', }; render(); @@ -733,10 +735,11 @@ describe('LinkedAccounts settings', () => { 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, - inboxAddress: null, + inboxEmail: null, }; render(); @@ -762,10 +765,11 @@ describe('LinkedAccounts settings', () => { 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, - inboxAddress: null, + inboxEmail: null, }; render(); @@ -779,6 +783,26 @@ describe('LinkedAccounts settings', () => { ).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 3238f98cc4..64d386eb46 100644 --- a/apps/web/src/components/settings/LinkedAccounts.tsx +++ b/apps/web/src/components/settings/LinkedAccounts.tsx @@ -427,11 +427,11 @@ function EmailAccountDetails({ function EmailLinkingGuidance({ canViewInboxAddress, emailEnabled, - inboxAddress, + inboxEmail, }: { canViewInboxAddress: boolean; emailEnabled: boolean; - inboxAddress: string | null; + inboxEmail: string | null; }) { if (!emailEnabled) { return ( @@ -442,12 +442,12 @@ function EmailLinkingGuidance({ ); } - if (inboxAddress) { + if (inboxEmail) { return (

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

); } @@ -926,7 +926,8 @@ export function LinkedAccounts() { /> } actions={ - !primaryEmail.verified && emailAccounts.data?.emailEnabled ? ( + !primaryEmail.verified && + emailAccounts.data?.verificationDeliveryAvailable ? (