diff --git a/components/email/__tests__/external-sender-chip-detail.test.tsx b/components/email/__tests__/external-sender-chip-detail.test.tsx index bd6ba9a3b..fefccd89a 100644 --- a/components/email/__tests__/external-sender-chip-detail.test.tsx +++ b/components/email/__tests__/external-sender-chip-detail.test.tsx @@ -1,4 +1,4 @@ -import { render, fireEvent, cleanup, screen } from '@testing-library/react'; +import { render, fireEvent, cleanup, screen, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { Identity } from '@/lib/jmap/types'; import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; @@ -62,3 +62,45 @@ describe('External chip on phones and keyboards', () => { expect(container.querySelector('[data-external-sender-chip]')?.getAttribute('title')).toBe('sender_tooltip(org-two.test)'); }); }); + +describe('a message of another signed-in account (unified inbox)', () => { + function signInDave(daveScope: Record | null) { + usePolicyStore.setState({ + rawPolicy: { + ...DEFAULT_POLICY, + organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, senderLabel: true } }, + scopes: daveScope ? [{ id: 'two', label: 'Two', match: { domains: ['org-two.test'] }, policy: { organisation: { externalMail: daveScope } } }] : [], + }, + }); + const daveClient = { + getUsername: () => 'org-dave@org-two.test', + getSharedAccounts: () => [], + getAccountInfo: () => null, + getPrincipalTypes: async () => ({}), + getIdentities: async () => [{ id: 'd1', name: 'Dave', email: 'org-dave@org-two.test' }], + }; + useAuthStore.setState({ + activeAccountId: 'acc-alice', + getClientForAccount: (id: string) => (id === 'acc-dave' ? daveClient : undefined), + } as never); + } + + it("is judged with that account's identities", async () => { + signInDave(null); + // For dave, a colleague at org-two.test is internal and alice is not. + const colleague = render(); + const alice = render(); + await waitFor(() => expect(alice.container.querySelector('[data-external-sender-chip]')).not.toBeNull()); + expect(colleague.container.querySelector('[data-external-sender-chip]')).toBeNull(); + }); + + it("follows that account's scope, not the active one's", async () => { + signInDave({ senderLabel: false }); + const { container } = render(); + // The active account has the label on; dave's scope turns it off. + await new Promise((r) => setTimeout(r, 20)); + expect(container.querySelector('[data-external-sender-chip]')).toBeNull(); + const active = render(); + expect(active.container.querySelector('[data-external-sender-chip]')).not.toBeNull(); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 4f9441ff1..f50db562d 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -3292,6 +3292,7 @@ export function EmailComposer({ fromEmail: (fromOverrideEnabled && fromOverrideEmail.trim()) || currentIdentity?.email || undefined, identityId: currentIdentity?.id, identityEmail: currentIdentity?.email || undefined, + localAccountId: currentIdentityParts.localAccountId ?? undefined, mode, plainText: plainTextMode, }; diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 01fe12d50..fdb287677 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -856,7 +856,7 @@ export function MvExpandedConversationCard({ {!compact && sender?.name && sender.email && ( <{sender.email}> )} - + {compact ? formatDate(emailDisplayDate(email)) : mvSenderDate(emailDisplayDate(email), timeFormat)} @@ -5051,7 +5051,7 @@ export function EmailViewer({ {mvViewer && sender?.email && sender?.name && ( <{sender.email}> )} - + {shouldShowUnsubBanner && listHeaders?.listUnsubscribe && ( {t('unknown_sender')} )} - + {shouldShowUnsubBanner && listHeaders?.listUnsubscribe && ( - + {senderVerification && ( s.policy.organisation?.externalMail); + const rawPolicy = usePolicyStore((s) => s.rawPolicy); const identities = useAuthStore((s) => s.identities); + const other = useAuthStore(() => isOtherAccount(accountId)); + const [otherContext, setOtherContext] = useState<{ accountId: string; context: ExternalMailContext } | null>(null); + useEffect(() => { + if (!other || !accountId) return; + let cancelled = false; + void externalMailContextFor(accountId).then((context) => { + if (!cancelled) setOtherContext({ accountId, context }); + }); + return () => { cancelled = true; }; + }, [other, accountId, rawPolicy]); return useMemo(() => { - if (!externalMail?.senderLabel) return []; - const internal = internalDomains((identities ?? []).map((identity) => identity.email), externalMail.domains); - return externalSenders(from, internal); - }, [externalMail, identities, from]); + if (other) return otherContext && otherContext.accountId === accountId ? otherContext.context : null; + if (!externalMail) return null; + return { policy: externalMail, identityEmails: (identities ?? []).map((identity) => identity.email) }; + }, [other, otherContext, accountId, externalMail, identities]); +} + +/** + * The From addresses outside the organisation when the admin turned the + * "External" label on for the message's account (lib/external-mail.ts); + * empty otherwise. + */ +export function useExternalSenders(from: FromList, accountId?: string | null): ExternalSender[] { + const context = useAccountContext(accountId); + return useMemo(() => { + if (!context?.policy.senderLabel) return []; + return externalSenders(from, internalDomains(context.identityEmails, context.policy.domains)); + }, [context, from]); } /** @@ -33,9 +65,15 @@ export function useExternalSenders(from: FromList): ExternalSender[] { * the domain. Inside another button (a conversation card's header) it can't * be one, so `interactive={false}` keeps it a label with a tooltip. */ -export function ExternalSenderChip({ from, className, interactive = true }: { from: FromList; className?: string; interactive?: boolean }) { +export function ExternalSenderChip({ from, accountId, className, interactive = true }: { + from: FromList; + /** The signed-in account the message belongs to (Email.sourceClientAccountId); the active one when not set. */ + accountId?: string | null; + className?: string; + interactive?: boolean; +}) { const t = useTranslations('external_mail'); - const senders = useExternalSenders(from); + const senders = useExternalSenders(from, accountId); const [open, setOpen] = useState(false); const wrapRef = useRef(null); const popoverId = useId(); diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index fe3189ae0..7c5dbc5df 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -397,7 +397,7 @@ function EmailCard({ )}> {sender?.name || (sender?.email && formatAddress(sender.email)) || "Unknown"} - + {isStarred && ( )} diff --git a/hooks/use-pro-multi-account-identities.ts b/hooks/use-pro-multi-account-identities.ts index a714af72f..364b33cdb 100644 --- a/hooks/use-pro-multi-account-identities.ts +++ b/hooks/use-pro-multi-account-identities.ts @@ -21,6 +21,11 @@ const CROSS_ACCOUNT_IDENTITY_DELIMITER = '::'; // still refetches. const remoteIdentityCache = new Map(); +/** One non-active account's identities, if fetched already (raw ids). */ +export function cachedIdentitiesForAccount(localAccountId: string): Identity[] | undefined { + return remoteIdentityCache.get(localAccountId); +} + /** Every non-active account's identities fetched so far (raw ids). */ export function cachedRemoteIdentities(): Identity[] { return [...remoteIdentityCache.values()].flat(); diff --git a/lib/__tests__/external-mail.test.ts b/lib/__tests__/external-mail.test.ts index 06a408200..6062c9a0a 100644 --- a/lib/__tests__/external-mail.test.ts +++ b/lib/__tests__/external-mail.test.ts @@ -3,8 +3,9 @@ import { addressDomain, addressKey, contactAddresses, - coreExternalWarningsOn, - currentInternalDomains, + activeExternalMailContext, + contextInternalDomains, + externalMailContextFor, domainMatches, externalRecipients, externalSenders, @@ -139,22 +140,49 @@ describe('externalSenders', () => { describe('the active account', () => { beforeEach(() => { setExternalMail({}); - useAuthStore.setState({ identities: [] }); + useAuthStore.setState({ identities: [], activeAccountId: 'acc-alice' }); useContactStore.setState({ contacts: [], addressBooks: [], trustedSendersBookId: null }); }); it('reads the switches from the effective policy', () => { - expect(coreExternalWarningsOn()).toBe(false); + expect(activeExternalMailContext().policy.senderLabel).toBe(false); setExternalMail({ senderLabel: true }); - expect(coreExternalWarningsOn()).toBe(true); - setExternalMail({ recipientWarning: true }); - expect(coreExternalWarningsOn()).toBe(true); + expect(activeExternalMailContext().policy.senderLabel).toBe(true); }); it('combines the policy domains with the identity domains', () => { setExternalMail({ domains: ['partner.com'] }); useAuthStore.setState({ identities: [identity('org-alice@example.org')] }); - expect(currentInternalDomains()).toEqual(['example.org', 'partner.com']); + expect(contextInternalDomains(activeExternalMailContext())).toEqual(['example.org', 'partner.com']); + }); + + it('resolves another signed-in account with its own scopes and identities', async () => { + // The raw policy: the label on for everyone, off for org-two.test. + usePolicyStore.setState({ + rawPolicy: { + ...DEFAULT_POLICY, + organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, senderLabel: true } }, + scopes: [{ id: 'two', label: 'Two', match: { domains: ['org-two.test'] }, policy: { organisation: { externalMail: { senderLabel: false } } } }], + }, + }); + setExternalMail({ senderLabel: true }); + const daveClient = { + getUsername: () => 'org-dave@org-two.test', + getSharedAccounts: () => [], + getAccountInfo: () => null, + getPrincipalTypes: async () => ({}), + getIdentities: async () => [identity('org-dave@org-two.test')], + }; + useAuthStore.setState({ + identities: [identity('org-alice@example.org')], + getClientForAccount: (id: string) => (id === 'acc-dave' ? daveClient : undefined), + } as never); + const dave = await externalMailContextFor('acc-dave'); + expect(dave.policy.senderLabel).toBe(false); + expect(dave.identityEmails).toEqual(['org-dave@org-two.test']); + // The active account, and an unknown one, read the stores. + expect((await externalMailContextFor('acc-alice')).identityEmails).toEqual(['org-alice@example.org']); + expect((await externalMailContextFor(undefined)).policy.senderLabel).toBe(true); }); it('collects the addresses of every contact', () => { diff --git a/lib/external-mail.ts b/lib/external-mail.ts index 8ae1f5113..341fa59d7 100644 --- a/lib/external-mail.ts +++ b/lib/external-mail.ts @@ -10,8 +10,8 @@ * Domains are compared in ASCII (punycode) form on both sides, so an IDN * the admin typed in Unicode matches the xn-- form a server delivers. * - * The pure functions take everything as arguments; the `current*` helpers - * read the active account's effective policy and identities. + * The pure functions take everything as arguments; the context helpers + * read one signed-in account's effective policy and identities. */ import { DEFAULT_ORGANISATION_POLICY, normalizeDomainList, type OrganisationPolicy } from '@/lib/admin/organisation-policy'; @@ -19,6 +19,9 @@ import { toAsciiDomain, toAsciiEmail } from '@/lib/idn'; import { usePolicyStore } from '@/stores/policy-store'; import { useAuthStore } from '@/stores/auth-store'; import { TRUSTED_SENDERS_BOOK_NAME, useContactStore } from '@/stores/contact-store'; +import { policySubjectForClient } from '@/lib/admin/policy-subject'; +import type { PolicySubject } from '@/lib/admin/policy-scopes'; +import { cachedIdentitiesForAccount } from '@/hooks/use-pro-multi-account-identities'; export type ExternalMailPolicy = OrganisationPolicy['externalMail']; @@ -128,22 +131,66 @@ export function externalSenders( return out; } -// ─── The active account ────────────────────────────────────── +// ─── Per account ───────────────────────────────────────────── -/** The externalMail settings of the active account's effective policy. */ -export function currentExternalMailPolicy(): ExternalMailPolicy { - return usePolicyStore.getState().getOrganisation().externalMail ?? DEFAULT_ORGANISATION_POLICY.externalMail; +/** What decides who is external for one signed-in account. */ +export interface ExternalMailContext { + /** That account's effective externalMail policy (its domain and group scopes applied). */ + policy: ExternalMailPolicy; + /** That account's identities' addresses. */ + identityEmails: string[]; } -/** Either core switch is on: the label or the recipient warning. */ -export function coreExternalWarningsOn(policy: ExternalMailPolicy = currentExternalMailPolicy()): boolean { - return policy.senderLabel || policy.recipientWarning; +/** The active account: the effective policy and the identities in the stores. */ +export function activeExternalMailContext(): ExternalMailContext { + return { + policy: usePolicyStore.getState().getOrganisation().externalMail ?? DEFAULT_ORGANISATION_POLICY.externalMail, + identityEmails: (useAuthStore.getState().identities ?? []).map((identity) => identity.email), + }; } -/** The internal domains for the active account. */ -export function currentInternalDomains(policy: ExternalMailPolicy = currentExternalMailPolicy()): string[] { - const identities = useAuthStore.getState().identities ?? []; - return internalDomains(identities.map((identity) => identity.email), policy.domains); +/** The internal domains of a context. */ +export function contextInternalDomains(context: ExternalMailContext): string[] { + return internalDomains(context.identityEmails, context.policy.domains); +} + +type AccountClient = NonNullable['getClientForAccount']>>; +const subjects = new WeakMap>(); +const identityLists = new WeakMap>(); + +/** 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(); + return !!localAccountId && localAccountId !== auth.activeAccountId && !!auth.getClientForAccount(localAccountId); +} + +/** + * The context of a signed-in account by its login id (AccountEntry id): a + * message's own account in the unified inbox, or the account a message is + * 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. + */ +export async function externalMailContextFor(localAccountId?: string | null): Promise { + if (!isOtherAccount(localAccountId)) return activeExternalMailContext(); + const client = useAuthStore.getState().getClientForAccount(localAccountId)!; + let subject = subjects.get(client); + if (!subject) { + subject = policySubjectForClient(client).catch(() => null); + subjects.set(client, subject); + } + let identities = identityLists.get(client); + if (!identities) { + const cached = cachedIdentitiesForAccount(localAccountId); + identities = cached + ? Promise.resolve(cached.map((identity) => identity.email)) + : 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 }; } /** diff --git a/lib/outgoing/__tests__/external-recipients.test.ts b/lib/outgoing/__tests__/external-recipients.test.ts index 65e3b2c90..7f9e7ed08 100644 --- a/lib/outgoing/__tests__/external-recipients.test.ts +++ b/lib/outgoing/__tests__/external-recipients.test.ts @@ -183,3 +183,42 @@ describe('contacts that are not loaded yet', () => { expect((await result).map((r) => r.address)).toEqual(['org-dave@org-two.test']); }); }); + +describe('sending through another signed-in account', () => { + function signInDave(daveScope: Record | null) { + usePolicyStore.setState({ + rawPolicy: { + ...DEFAULT_POLICY, + organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, recipientWarning: true } }, + scopes: daveScope ? [{ id: 'two', label: 'Two', match: { domains: ['org-two.test'] }, policy: { organisation: { externalMail: daveScope } } }] : [], + }, + }); + const daveClient = { + getUsername: () => 'org-dave@org-two.test', + getSharedAccounts: () => [], + getAccountInfo: () => null, + getPrincipalTypes: async () => ({}), + getIdentities: async () => [{ id: 'd1', name: 'Dave', email: 'org-dave@org-two.test' }], + }; + useAuthStore.setState({ + activeAccountId: 'acc-alice', + getClientForAccount: (id: string) => (id === 'acc-dave' ? daveClient : undefined), + } as never); + } + const asDave = (to: string[]): OutgoingContext => ({ + ...context(to), fromEmail: 'org-dave@org-two.test', identityEmail: 'org-dave@org-two.test', localAccountId: 'acc-dave', + }); + + it("uses that account's identities: its domain is internal, the active account's is not", async () => { + signInDave(null); + expect(await recipientsToConfirm(asDave(['colleague@org-two.test']))).toEqual([]); + expect((await recipientsToConfirm(asDave(['org-bob@example.org']))).map((r) => r.address)).toEqual(['org-bob@example.org']); + }); + + it("uses that account's policy: a scope that turns the warning off for it applies", async () => { + signInDave({ recipientWarning: false }); + expect(await recipientsToConfirm(asDave(['someone@elsewhere.example']))).toEqual([]); + // The active account still asks. + expect((await recipientsToConfirm(context(['someone@elsewhere.example']))).map((r) => r.address)).toEqual(['someone@elsewhere.example']); + }); +}); diff --git a/lib/outgoing/external-recipients.ts b/lib/outgoing/external-recipients.ts index 91ef0503c..aa48de4e4 100644 --- a/lib/outgoing/external-recipients.ts +++ b/lib/outgoing/external-recipients.ts @@ -9,8 +9,8 @@ import { addressDomain, contactAddresses, - currentExternalMailPolicy, - currentInternalDomains, + contextInternalDomains, + externalMailContextFor, externalRecipients, type ExternalRecipient, } from '@/lib/external-mail'; @@ -51,12 +51,15 @@ export async function ensureContactsLoaded(waitMs = CONTACTS_WAIT_MS): Promise { - const policy = currentExternalMailPolicy(); + // 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.localAccountId); + const { policy } = account; if (!policy.recipientWarning) return []; // The chosen identity's own address is internal. A From typed over it in // the composer is not: anyone can type any domain there. const identityDomain = addressDomain(context.identityEmail); - const internal = currentInternalDomains(policy); + const internal = contextInternalDomains(account); if (identityDomain && !internal.includes(identityDomain)) internal.push(identityDomain); let trusted: ((address: string) => boolean) | undefined; if (policy.trustContacts) { diff --git a/lib/outgoing/send-pipeline.ts b/lib/outgoing/send-pipeline.ts index 40c8a0e26..e2f772165 100644 --- a/lib/outgoing/send-pipeline.ts +++ b/lib/outgoing/send-pipeline.ts @@ -27,6 +27,12 @@ export interface OutgoingContext { identityId?: string; /** The chosen identity's own address (never a typed From override). */ identityEmail?: string; + /** + * The signed-in account (AccountEntry id) the message goes out through, + * when it isn't the active one: an identity of another account picked in + * the composer. Checks use that account's policy and identities. + */ + localAccountId?: string; /** compose, reply, replyAll or forward; quick reply is a reply. */ mode: 'compose' | 'reply' | 'replyAll' | 'forward'; /** Plain-text-only message (no HTML part). */