diff --git a/components/email/external-sender-chip.tsx b/components/email/external-sender-chip.tsx index 1f82d9dd2..dc04516bf 100644 --- a/components/email/external-sender-chip.tsx +++ b/components/email/external-sender-chip.tsx @@ -31,7 +31,7 @@ function useAccountContext(accountId: string | null | undefined): ExternalMailCo useEffect(() => { if (!other || !accountId) return; let cancelled = false; - void externalMailContextFor(accountId).then((context) => { + void externalMailContextFor(accountId, 'senderLabel').then((context) => { if (!cancelled) setOtherContext({ accountId, context }); }); return () => { cancelled = true; }; diff --git a/components/mail/__tests__/blob-routing.test.tsx b/components/mail/__tests__/blob-routing.test.tsx index b0777c3db..1650bfe21 100644 --- a/components/mail/__tests__/blob-routing.test.tsx +++ b/components/mail/__tests__/blob-routing.test.tsx @@ -38,7 +38,10 @@ vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) })); vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} })); -vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] })); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ + useProMultiAccountIdentities: () => [], + cachedIdentitiesForAccount: () => undefined, +})); vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} })); vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false })); vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} })); diff --git a/lib/__tests__/external-mail.test.ts b/lib/__tests__/external-mail.test.ts index 6062c9a0a..4f2328fca 100644 --- a/lib/__tests__/external-mail.test.ts +++ b/lib/__tests__/external-mail.test.ts @@ -185,6 +185,37 @@ describe('the active account', () => { expect((await externalMailContextFor(undefined)).policy.senderLabel).toBe(true); }); + it("looks another account up only when the switch can be on for it", async () => { + const calls = { subject: 0, identities: 0 }; + const eveClient = { + getUsername: () => { calls.subject++; return 'org-eve@org-three.test'; }, + getSharedAccounts: () => [], + getAccountInfo: () => null, + getPrincipalTypes: async () => ({}), + getIdentities: async () => { calls.identities++; return [identity('org-eve@org-three.test')]; }, + }; + useAuthStore.setState({ + identities: [identity('org-alice@example.org')], + getClientForAccount: (id: string) => (id === 'acc-eve' ? eveClient : undefined), + } as never); + const scope = (externalMail: Record) => ({ id: 's', label: 'S', match: { domains: ['org-three.test'] }, policy: { organisation: { externalMail } } }); + + // Off for Everyone and in every scope: no request at all. + usePolicyStore.setState({ rawPolicy: { ...DEFAULT_POLICY, scopes: [scope({ recipientWarning: true })] } }); + expect((await externalMailContextFor('acc-eve', 'senderLabel')).policy.senderLabel).toBe(false); + expect(calls).toEqual({ subject: 0, identities: 0 }); + + // On in a scope that doesn't match eve: her subject is needed, her identities aren't. + usePolicyStore.setState({ rawPolicy: { ...DEFAULT_POLICY, scopes: [{ ...scope({ senderLabel: true }), match: { domains: ['elsewhere.test'] } }] } }); + expect((await externalMailContextFor('acc-eve', 'senderLabel')).identityEmails).toEqual([]); + expect(calls).toEqual({ subject: 1, identities: 0 }); + + // On for her domain: her identities count. + usePolicyStore.setState({ rawPolicy: { ...DEFAULT_POLICY, scopes: [scope({ senderLabel: true })] } }); + expect((await externalMailContextFor('acc-eve', 'senderLabel')).identityEmails).toEqual(['org-eve@org-three.test']); + expect(calls.identities).toBe(1); + }); + it('collects the addresses of every contact', () => { useContactStore.setState({ contacts: [ diff --git a/lib/external-mail.ts b/lib/external-mail.ts index 341fa59d7..364a3003e 100644 --- a/lib/external-mail.ts +++ b/lib/external-mail.ts @@ -158,6 +158,13 @@ type AccountClient = NonNullable>(); const identityLists = new WeakMap>(); +/** Whether Everyone or any domain or group scope turns this switch on. */ +function switchOnAnywhere(key: 'senderLabel' | 'recipientWarning'): boolean { + const raw = usePolicyStore.getState().rawPolicy; + if (raw.organisation?.externalMail?.[key]) return true; + return (raw.scopes ?? []).some((scope) => scope.policy.organisation?.externalMail?.[key] === true); +} + /** Whether `localAccountId` names another signed-in account than the active one. */ export function isOtherAccount(localAccountId: string | null | undefined): localAccountId is string { const auth = useAuthStore.getState(); @@ -170,15 +177,26 @@ export function isOtherAccount(localAccountId: string | null | undefined): local * sent from. Its policy is resolved for its own subject, so a domain or * group scope of that account applies, and its own identities count. The * active account (or none given) reads the stores directly. + * + * With `need`, another account is only looked up when that switch can be on + * for it: off everywhere in the policy costs no request, and off for that + * account skips its identities. */ -export async function externalMailContextFor(localAccountId?: string | null): Promise { +export async function externalMailContextFor( + localAccountId?: string | null, + need?: 'senderLabel' | 'recipientWarning', +): Promise { if (!isOtherAccount(localAccountId)) return activeExternalMailContext(); + if (need && !switchOnAnywhere(need)) return { policy: DEFAULT_ORGANISATION_POLICY.externalMail, identityEmails: [] }; const client = useAuthStore.getState().getClientForAccount(localAccountId)!; let subject = subjects.get(client); if (!subject) { subject = policySubjectForClient(client).catch(() => null); subjects.set(client, subject); } + const policy = usePolicyStore.getState().resolveFor(await subject).organisation?.externalMail + ?? DEFAULT_ORGANISATION_POLICY.externalMail; + if (need && !policy[need]) return { policy, identityEmails: [] }; let identities = identityLists.get(client); if (!identities) { const cached = cachedIdentitiesForAccount(localAccountId); @@ -187,10 +205,7 @@ export async function externalMailContextFor(localAccountId?: string | null): Pr : client.getIdentities().then((list) => list.map((identity) => identity.email)).catch(() => []); identityLists.set(client, identities); } - const [resolvedSubject, identityEmails] = await Promise.all([subject, identities]); - const policy = usePolicyStore.getState().resolveFor(resolvedSubject).organisation?.externalMail - ?? DEFAULT_ORGANISATION_POLICY.externalMail; - return { policy, identityEmails }; + return { policy, identityEmails: await identities }; } /** diff --git a/lib/outgoing/external-recipients.ts b/lib/outgoing/external-recipients.ts index 6fcc2f817..7188df613 100644 --- a/lib/outgoing/external-recipients.ts +++ b/lib/outgoing/external-recipients.ts @@ -53,7 +53,7 @@ export async function ensureContactsLoaded(waitMs = CONTACTS_WAIT_MS): Promise { // The sending account's policy and identities: an identity of another // signed-in account sends under that account's domain or group scopes. - const account = await externalMailContextFor(context.sender?.accountId); + const account = await externalMailContextFor(context.sender?.accountId, 'recipientWarning'); const { policy } = account; if (!policy.recipientWarning) return []; // The chosen identity's own address is internal. A From typed over it in