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 ? (