From b504e39b63f05ff061423f5b8ffdaf7fcfbaba09 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 01:53:39 +0100
Subject: [PATCH] feat: label mail from outside the organisation
---
.../__tests__/external-sender-chip.test.tsx | 97 +++++++++++++
components/email/email-viewer.tsx | 5 +
components/email/external-sender-chip.tsx | 50 +++++++
components/email/thread-conversation-view.tsx | 2 +
lib/__tests__/external-mail.test.ts | 129 ++++++++++++++++++
lib/admin/organisation-policy.ts | 6 +-
lib/external-mail.ts | 119 ++++++++++++++++
locales/de/common.json | 4 +
locales/en/common.json | 4 +
9 files changed, 415 insertions(+), 1 deletion(-)
create mode 100644 components/email/__tests__/external-sender-chip.test.tsx
create mode 100644 components/email/external-sender-chip.tsx
create mode 100644 lib/__tests__/external-mail.test.ts
create mode 100644 lib/external-mail.ts
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 (
+
+
+ {t('sender_label')}
+ {` (${tooltip})`}
+
+ );
+}
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",