diff --git a/components/email/__tests__/external-sender-chip.test.tsx b/components/email/__tests__/external-sender-chip.test.tsx new file mode 100644 index 000000000..a14269288 --- /dev/null +++ b/components/email/__tests__/external-sender-chip.test.tsx @@ -0,0 +1,97 @@ +import { render, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Email, Identity, Mailbox } from '@/lib/jmap/types'; +import { clearAllHooks } from '@/lib/plugin-hooks'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { usePolicyStore } from '@/stores/policy-store'; +import { useAuthStore } from '@/stores/auth-store'; + +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, +})); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), +})); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); + +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +vi.stubGlobal('ResizeObserver', NoopResizeObserver); + +const { EmailViewer } = await import('../email-viewer'); +const { ExternalSenderChip } = await import('../external-sender-chip'); + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +const message = (from: string) => ({ + id: 'm1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'Sender', email: from }], to: [{ name: 'Org Alice', email: 'org-alice@example.org' }], + subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100, + textBody: [{ partId: '1', type: 'text/html' }], htmlBody: [{ partId: '1', type: 'text/html' }], attachments: [], + bodyValues: { '1': { value: '

Body

' } }, +}) as unknown as Email; + +function setExternalMail(patch: Partial) { + usePolicyStore.setState({ + policy: { + ...DEFAULT_POLICY, + organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } }, + }, + }); +} + +async function openMessage(from: string) { + const view = render(); + await act(async () => { await new Promise((r) => setTimeout(r, 10)); }); + return view; +} + +beforeEach(() => { + clearAllHooks(); + setExternalMail({}); + useAuthStore.setState({ identities: [{ id: 'i1', name: 'Org Alice', email: 'org-alice@example.org' } as unknown as Identity] }); +}); + +describe('External sender chip', () => { + it('names the sender domain in its tooltip when the label is on', () => { + setExternalMail({ senderLabel: true }); + const { container } = render(); + const chip = container.querySelector('[data-external-sender-chip]'); + expect(chip?.textContent).toContain('sender_label'); + expect(chip?.getAttribute('title')).toBe('sender_tooltip'); + }); + + it('shows in the reader header for a sender outside the organisation', async () => { + setExternalMail({ senderLabel: true }); + const { container } = await openMessage('org-dave@org-two.test'); + expect(container.querySelectorAll('[data-external-sender-chip]').length).toBeGreaterThan(0); + }); + + it('stays away for a sender in an identity domain or its subdomain', async () => { + setExternalMail({ senderLabel: true }); + const { container } = await openMessage('org-bob@example.org'); + expect(container.querySelector('[data-external-sender-chip]')).toBeNull(); + const sub = render(); + expect(sub.container.querySelector('[data-external-sender-chip]')).toBeNull(); + }); + + it('stays away for a domain the admin listed', async () => { + setExternalMail({ senderLabel: true, domains: ['org-two.test'] }); + const { container } = await openMessage('org-dave@org-two.test'); + expect(container.querySelector('[data-external-sender-chip]')).toBeNull(); + }); + + it('stays away while the label is off, even with the recipient warning on', async () => { + setExternalMail({ senderLabel: false, recipientWarning: true }); + const { container } = await openMessage('org-dave@org-two.test'); + expect(container.querySelector('[data-external-sender-chip]')).toBeNull(); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 54a8409d6..573d48422 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -114,6 +114,7 @@ import { useAccountStore } from "@/stores/account-store"; import { useEmailStore, resolveEmailBlobContext, emailWriteSource, emailLinkSource, type EmailBlobContext } from "@/stores/email-store"; import { useThemeStore } from "@/stores/theme-store"; import { EmailIdentityBadge } from "./email-identity-badge"; +import { ExternalSenderChip } from "./external-sender-chip"; import { UnsubscribeBanner } from "./unsubscribe-banner"; import { CalendarInvitationBanner } from "./calendar-invitation-banner"; import { ReadReceiptBanner } from "./read-receipt-banner"; @@ -854,6 +855,7 @@ export function MvExpandedConversationCard({ {!compact && sender?.name && sender.email && ( <{sender.email}> )} + {compact ? formatDate(emailDisplayDate(email)) : mvSenderDate(emailDisplayDate(email), timeFormat)} @@ -5045,6 +5047,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 identities = useAuthStore((s) => s.identities); + return useMemo(() => { + if (!externalMail?.senderLabel) return null; + const internal = internalDomains(externalMail.domains, (identities ?? []).map((identity) => identity.email)); + return externalSenderDomain(fromEmail, internal); + }, [externalMail, identities, fromEmail]); +} + +/** + * "External" next to the sender's name. A chip and not a banner, so it sits + * beside the unverified-sender warning without adding a second banner, and + * it keeps to one short word so the narrow phone header doesn't wrap more. + */ +export function ExternalSenderChip({ fromEmail, className }: { fromEmail: string | null | undefined; className?: string }) { + const t = useTranslations('external_mail'); + const domain = useExternalSenderDomain(fromEmail); + if (!domain) return null; + const tooltip = t('sender_tooltip', { domain }); + return ( + + + ); +} diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 4cde880b6..26c19ad7f 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -11,6 +11,7 @@ import { emailDisplayDate } from "@/lib/email-date"; import { getRenderableHtmlBody } from "@/lib/email-body-selection"; import { findVerificationCode, verificationCodeBodyText } from "@/lib/verification-code"; import { VerificationCodeChip } from "./verification-code-chip"; +import { ExternalSenderChip } from "./external-sender-chip"; import { collectReferencedCids, isEmbeddedInBody, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility"; import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse"; import { fitEmailBodyWidth } from "@/lib/email-fit-width"; @@ -396,6 +397,7 @@ function EmailCard({ )}> {sender?.name || (sender?.email && formatAddress(sender.email)) || "Unknown"} + {isStarred && ( )} diff --git a/lib/__tests__/external-mail.test.ts b/lib/__tests__/external-mail.test.ts new file mode 100644 index 000000000..5f07e76ca --- /dev/null +++ b/lib/__tests__/external-mail.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + addressDomain, + contactAddresses, + coreExternalWarningsOn, + currentInternalDomains, + domainMatches, + externalRecipients, + externalSenderDomain, + internalDomains, + isExternalAddress, +} from '../external-mail'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { usePolicyStore } from '@/stores/policy-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useContactStore } from '@/stores/contact-store'; +import type { ContactCard, Identity } from '@/lib/jmap/types'; + +const identity = (email: string) => ({ id: email, name: '', email }) as unknown as Identity; + +function setExternalMail(patch: Partial) { + usePolicyStore.setState({ + policy: { + ...DEFAULT_POLICY, + organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } }, + }, + }); +} + +describe('addressDomain', () => { + it('takes the domain of bare and named addresses, lowercased', () => { + expect(addressDomain('Bob@Example.COM')).toBe('example.com'); + expect(addressDomain('Bob ')).toBe('eu.example.com'); + expect(addressDomain('no-at-sign')).toBe(''); + expect(addressDomain(undefined)).toBe(''); + }); +}); + +describe('domainMatches', () => { + it('matches the domain itself and its subdomains, nothing else', () => { + expect(domainMatches('example.com', ['example.com'])).toBe(true); + expect(domainMatches('eu.example.com', ['example.com'])).toBe(true); + expect(domainMatches('a.b.example.com', ['example.com'])).toBe(true); + // A suffix that isn't a subdomain doesn't count. + expect(domainMatches('badexample.com', ['example.com'])).toBe(false); + expect(domainMatches('example.com.evil.net', ['example.com'])).toBe(false); + // A subdomain in the list doesn't cover its parent. + expect(domainMatches('example.com', ['eu.example.com'])).toBe(false); + }); +}); + +describe('internalDomains', () => { + it('always counts the identity domains and adds the admin list', () => { + expect(internalDomains([], ['me@example.org'])).toEqual(['example.org']); + expect(internalDomains(['partner.com'], ['me@example.org', 'alias@Example.org'])).toEqual(['partner.com', 'example.org']); + }); +}); + +describe('isExternalAddress', () => { + const internal = internalDomains(['partner.com'], ['me@example.org']); + it('is external only outside the identity domains and the list, subdomains included', () => { + expect(isExternalAddress('bob@example.org', internal)).toBe(false); + expect(isExternalAddress('x@eu.partner.com', internal)).toBe(false); + expect(isExternalAddress('dave@org-two.test', internal)).toBe(true); + }); + it('calls nobody external when no internal domain is known', () => { + expect(isExternalAddress('dave@org-two.test', [])).toBe(false); + }); +}); + +describe('externalRecipients', () => { + const internal = ['example.org']; + it('lists only the external recipients, each once, with the field it is in', () => { + const result = externalRecipients( + { to: ['bob@example.org', 'dave@org-two.test'], cc: ['DAVE@org-two.test', 'eve@other.net'], bcc: ['x@sub.example.org'] }, + internal, + ); + expect(result).toEqual([ + { address: 'dave@org-two.test', domain: 'org-two.test', field: 'to' }, + { address: 'eve@other.net', domain: 'other.net', field: 'cc' }, + ]); + }); + it('leaves out trusted addresses (contacts)', () => { + const trusted = new Set(['dave@org-two.test']); + const result = externalRecipients({ to: ['Dave@org-two.test', 'eve@other.net'], cc: [], bcc: [] }, internal, (a) => trusted.has(a)); + expect(result.map((r) => r.address)).toEqual(['eve@other.net']); + }); +}); + +describe('externalSenderDomain', () => { + it('names the domain of an external sender and nothing for internal ones', () => { + expect(externalSenderDomain('dave@org-two.test', ['example.org'])).toBe('org-two.test'); + expect(externalSenderDomain('bob@example.org', ['example.org'])).toBeNull(); + expect(externalSenderDomain(undefined, ['example.org'])).toBeNull(); + }); +}); + +describe('the active account', () => { + beforeEach(() => { + setExternalMail({}); + useAuthStore.setState({ identities: [] }); + useContactStore.setState({ contacts: [] }); + }); + + it('reads the switches from the effective policy', () => { + expect(coreExternalWarningsOn()).toBe(false); + setExternalMail({ senderLabel: true }); + expect(coreExternalWarningsOn()).toBe(true); + setExternalMail({ recipientWarning: true }); + expect(coreExternalWarningsOn()).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(['partner.com', 'example.org']); + }); + + it('collects the addresses of every contact', () => { + useContactStore.setState({ + contacts: [ + { id: 'c1', emails: { e1: { address: 'Dave@org-two.test' }, e2: { address: 'second@x.test' } } }, + { id: 'c2' }, + ] as unknown as ContactCard[], + }); + expect([...contactAddresses()].sort()).toEqual(['dave@org-two.test', 'second@x.test']); + }); +}); diff --git a/lib/admin/organisation-policy.ts b/lib/admin/organisation-policy.ts index 55769a4e3..77562da19 100644 --- a/lib/admin/organisation-policy.ts +++ b/lib/admin/organisation-policy.ts @@ -25,7 +25,11 @@ export interface OrganisationPolicy { senderLabel: boolean; /** Confirm before sending to recipients outside the organisation domains. */ recipientWarning: boolean; - /** Organisation domains; empty means the domains of the user's identities. Subdomains match. */ + /** + * More organisation domains. The domains of the user's own identities + * always count as internal; these are added to them. A domain covers its + * subdomains (example.com covers eu.example.com). See lib/external-mail.ts. + */ domains: string[]; /** Recipients in the user's contacts don't trigger the warning. */ trustContacts: boolean; diff --git a/lib/external-mail.ts b/lib/external-mail.ts new file mode 100644 index 000000000..d81add2b0 --- /dev/null +++ b/lib/external-mail.ts @@ -0,0 +1,119 @@ +/** + * Who is outside the organisation, for the "External" sender chip and the + * confirm before sending outside it (policy.json organisation.externalMail). + * + * Internal are the domains of the user's own identities plus the domains + * the admin lists; a listed domain covers its subdomains (example.com + * covers eu.example.com). With no internal domain known at all nobody can + * be told apart, so nothing counts as external. + * + * The pure functions take everything as arguments; the `current*` helpers + * read the active account's effective policy and identities. + */ + +import { DEFAULT_ORGANISATION_POLICY, normalizeDomainList, type OrganisationPolicy } from '@/lib/admin/organisation-policy'; +import { usePolicyStore } from '@/stores/policy-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useContactStore } from '@/stores/contact-store'; + +export type ExternalMailPolicy = OrganisationPolicy['externalMail']; + +/** The lowercased domain of an address ("Name " or "a@b"); '' without one. */ +export function addressDomain(address: string | null | undefined): string { + const raw = String(address ?? '').trim(); + const bracket = raw.match(/<([^<>]*)>\s*$/); + const bare = (bracket ? bracket[1] : raw).trim(); + const at = bare.lastIndexOf('@'); + if (at < 0) return ''; + return bare.slice(at + 1).trim().toLowerCase().replace(/\.+$/, ''); +} + +/** True when `domain` is one of `internal` or a subdomain of one. */ +export function domainMatches(domain: string, internal: readonly string[]): boolean { + if (!domain) return false; + const d = domain.toLowerCase(); + return internal.some((entry) => d === entry || d.endsWith(`.${entry}`)); +} + +/** The admin's domains plus the domains of the user's identities, normalised and deduplicated. */ +export function internalDomains(policyDomains: readonly string[], identityEmails: readonly string[]): string[] { + const own = identityEmails.map(addressDomain).filter(Boolean); + return normalizeDomainList([...policyDomains, ...own]) ?? []; +} + +/** Whether mail from or to this address leaves the organisation. */ +export function isExternalAddress(address: string, internal: readonly string[]): boolean { + if (internal.length === 0) return false; + const domain = addressDomain(address); + return !!domain && !domainMatches(domain, internal); +} + +export type RecipientField = 'to' | 'cc' | 'bcc'; + +export interface ExternalRecipient { + address: string; + domain: string; + field: RecipientField; +} + +/** + * The recipients outside the organisation, each address once (in the first + * field it appears in). `trusted` is asked for the rest: with "trust + * contacts" on, an address in the user's contacts doesn't count. + */ +export function externalRecipients( + recipients: { to: readonly string[]; cc: readonly string[]; bcc: readonly string[] }, + internal: readonly string[], + trusted?: (address: string) => boolean, +): ExternalRecipient[] { + const out: ExternalRecipient[] = []; + const seen = new Set(); + for (const field of ['to', 'cc', 'bcc'] as const) { + for (const raw of recipients[field]) { + const address = String(raw ?? '').trim(); + const key = address.toLowerCase(); + if (!address || seen.has(key)) continue; + seen.add(key); + if (!isExternalAddress(address, internal)) continue; + if (trusted?.(key)) continue; + out.push({ address, domain: addressDomain(address), field }); + } + } + return out; +} + +/** The sender's domain when it is outside the organisation, else null. */ +export function externalSenderDomain(fromEmail: string | null | undefined, internal: readonly string[]): string | null { + if (!fromEmail || !isExternalAddress(fromEmail, internal)) return null; + return addressDomain(fromEmail); +} + +// ─── The active account ────────────────────────────────────── + +/** The externalMail settings of the active account's effective policy. */ +export function currentExternalMailPolicy(): ExternalMailPolicy { + return usePolicyStore.getState().getOrganisation().externalMail ?? DEFAULT_ORGANISATION_POLICY.externalMail; +} + +/** Either core switch is on: the label or the recipient warning. */ +export function coreExternalWarningsOn(policy: ExternalMailPolicy = currentExternalMailPolicy()): boolean { + return policy.senderLabel || policy.recipientWarning; +} + +/** The internal domains for the active account. */ +export function currentInternalDomains(policy: ExternalMailPolicy = currentExternalMailPolicy()): string[] { + const identities = useAuthStore.getState().identities ?? []; + return internalDomains(policy.domains, identities.map((identity) => identity.email)); +} + +/** Lowercased addresses of every loaded contact (all address books). */ +export function contactAddresses(): Set { + const out = new Set(); + for (const contact of useContactStore.getState().contacts) { + for (const entry of Object.values(contact.emails ?? {})) { + const address = entry?.address?.trim().toLowerCase(); + if (address) out.add(address); + } + } + return out; +} diff --git a/locales/de/common.json b/locales/de/common.json index ffdd16235..73f54a823 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -659,6 +659,10 @@ "plugin_panel_hide": "Plugin-Bereich ausblenden", "copy": "Kopieren" }, + "external_mail": { + "sender_label": "Extern", + "sender_tooltip": "Von außerhalb Ihrer Organisation: {domain}" + }, "email_composer": { "select_from_directory": "Aus Verzeichnis auswählen", "minimize": "Minimieren", diff --git a/locales/en/common.json b/locales/en/common.json index 95ec87fa3..60734aa9d 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -659,6 +659,10 @@ "plugin_panel_show": "Show plugin panel", "plugin_panel_hide": "Hide plugin panel" }, + "external_mail": { + "sender_label": "External", + "sender_tooltip": "From outside your organisation: {domain}" + }, "email_composer": { "select_from_directory": "Select from directory", "minimize": "Minimize",