feat: label mail from outside the organisation

This commit is contained in:
Linus Rath
2026-10-28 01:53:39 +01:00
parent 90e6e10464
commit b504e39b63
9 changed files with 415 additions and 1 deletions
@@ -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: '<p>Body</p>' } },
}) as unknown as Email;
function setExternalMail(patch: Partial<typeof DEFAULT_ORGANISATION_POLICY.externalMail>) {
usePolicyStore.setState({
policy: {
...DEFAULT_POLICY,
organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } },
},
});
}
async function openMessage(from: string) {
const view = render(<EmailViewer email={message(from)} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
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(<ExternalSenderChip fromEmail="org-dave@org-two.test" />);
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(<ExternalSenderChip fromEmail="someone@eu.example.org" />);
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();
});
});
+5
View File
@@ -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 && (
<span className="text-xs text-muted-foreground truncate">&lt;{sender.email}&gt;</span>
)}
<ExternalSenderChip fromEmail={sender?.email} className="self-center" />
<span className="ms-auto ps-2 text-xs text-muted-foreground whitespace-nowrap">
{compact ? formatDate(emailDisplayDate(email)) : mvSenderDate(emailDisplayDate(email), timeFormat)}
</span>
@@ -5045,6 +5047,7 @@ export function EmailViewer({
{mvViewer && sender?.email && sender?.name && (
<span className="text-xs text-muted-foreground truncate">&lt;{sender.email}&gt;</span>
)}
<ExternalSenderChip fromEmail={sender?.email} />
<EmailIdentityBadge email={email} identities={identities} />
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
<UnsubscribeBanner
@@ -5334,6 +5337,7 @@ export function EmailViewer({
) : (
<span className="text-sm font-semibold text-foreground">{t('unknown_sender')}</span>
)}
<ExternalSenderChip fromEmail={sender?.email} />
<EmailIdentityBadge email={email} identities={identities} />
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
<UnsubscribeBanner
@@ -5521,6 +5525,7 @@ export function EmailViewer({
onViewContact={handleViewContactSidebar}
className="text-sm text-start"
/>
<ExternalSenderChip fromEmail={sender?.email} />
{senderVerification && (
<span
className={cn(
+50
View File
@@ -0,0 +1,50 @@
'use client';
import { useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { Building } from '@/components/icons';
import { cn } from '@/lib/utils';
import { externalSenderDomain, internalDomains } from '@/lib/external-mail';
import { usePolicyStore } from '@/stores/policy-store';
import { useAuthStore } from '@/stores/auth-store';
/**
* The sender's domain when the admin turned the "External" label on and the
* sender is outside the organisation (lib/external-mail.ts), else null.
*/
export function useExternalSenderDomain(fromEmail: string | null | undefined): string | null {
const externalMail = usePolicyStore((s) => 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 (
<span
data-external-sender-chip=""
title={tooltip}
className={cn(
'inline-flex flex-shrink-0 items-center gap-1 whitespace-nowrap rounded-md border px-1.5 py-0.5 text-xs font-medium leading-none cursor-help',
'border-warning/30 bg-warning/10 text-warning',
className,
)}
>
<Building className="h-3 w-3 flex-shrink-0" aria-hidden="true" />
{t('sender_label')}
<span className="sr-only">{` (${tooltip})`}</span>
</span>
);
}
@@ -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"}
</span>
<ExternalSenderChip fromEmail={sender?.email} />
{isStarred && (
<Star className="w-4 h-4 fill-amber-400 text-amber-400 flex-shrink-0" />
)}
+129
View File
@@ -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<typeof DEFAULT_ORGANISATION_POLICY.externalMail>) {
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 <bob@eu.example.com>')).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']);
});
});
+5 -1
View File
@@ -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;
+119
View File
@@ -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 <a@b>" 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<string>();
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<string> {
const out = new Set<string>();
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;
}
+4
View File
@@ -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",
+4
View File
@@ -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",