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 }) => (
+ 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() {{emptyStateMessage}
) : null} + {emailAccounts.isError ? ( ++ Unable to load email account status. +
+ ) : null} + + {primaryEmail ? ( +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 ? ( ) : null} diff --git a/apps/web/src/lib/server/auth.test.ts b/apps/web/src/lib/server/auth.test.ts index dbab951ad2..7997180dc0 100644 --- a/apps/web/src/lib/server/auth.test.ts +++ b/apps/web/src/lib/server/auth.test.ts @@ -5,6 +5,8 @@ const { mockResolveAuthProviderConfig, mockSourceControlMappingValues, mockSourceControlMappingUpsert, + mockIsEmailChannelEnabled, + mockSendAgentMailSystemEmail, } = vi.hoisted(() => { const calls: Array<{ config: Array<{ @@ -29,6 +31,8 @@ const { mockResolveAuthProviderConfig: vi.fn(), mockSourceControlMappingValues: vi.fn(), mockSourceControlMappingUpsert: vi.fn(), + mockIsEmailChannelEnabled: vi.fn(), + mockSendAgentMailSystemEmail: vi.fn(), }; }); @@ -56,6 +60,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 +107,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', })); @@ -157,6 +165,8 @@ describe('getAuth', () => { slackClientId: undefined, slackClientSecret: undefined, }); + mockIsEmailChannelEnabled.mockReturnValue(false); + mockSendAgentMailSystemEmail.mockResolvedValue({ sent: true }); }); afterEach(() => { @@ -186,6 +196,43 @@ describe('getAuth', () => { expect(options.emailAndPassword.revokeSessionsOnPasswordReset).toBe(true); }); + it('reports explicit verification resend delivery failures without blocking signup', 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