fix: label mail as External when any sender is outside or has no domain, and show the domain on a tap

This commit is contained in:
Linus Rath
2026-10-28 05:09:35 +01:00
parent 836dcba66e
commit 60167f752f
9 changed files with 212 additions and 42 deletions
@@ -0,0 +1,64 @@
import { render, fireEvent, cleanup, screen } 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';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { usePolicyStore } from '@/stores/policy-store';
import { useAuthStore } from '@/stores/auth-store';
// Strings with their values, so the domain can be seen in names and text.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, vars?: Record<string, unknown>) =>
vars ? `${key}(${Object.values(vars).join(',')})` : key,
useLocale: () => 'en',
}));
const { ExternalSenderChip } = await import('../external-sender-chip');
function setLabel(on: boolean) {
usePolicyStore.setState({
policy: {
...DEFAULT_POLICY,
organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, senderLabel: on } },
},
});
}
beforeEach(() => {
setLabel(true);
useAuthStore.setState({ identities: [{ id: 'i1', name: 'Alice', email: 'alice@example.org' } as unknown as Identity] });
});
afterEach(() => cleanup());
describe('External chip on phones and keyboards', () => {
it('is a button whose name says the domain, and a tap shows it', () => {
render(<ExternalSenderChip from={[{ email: 'dave@org-two.test' }]} />);
const chip = screen.getByRole('button', { name: /org-two\.test/ });
expect(chip.getAttribute('aria-expanded')).toBe('false');
fireEvent.click(chip);
expect(chip.getAttribute('aria-expanded')).toBe('true');
expect(document.querySelector('[data-external-sender-detail]')?.textContent).toBe('sender_tooltip(org-two.test)');
// Escape and a tap elsewhere close it.
fireEvent.keyDown(chip, { key: 'Escape' });
expect(document.querySelector('[data-external-sender-detail]')).toBeNull();
fireEvent.click(chip);
fireEvent.pointerDown(document.body);
expect(document.querySelector('[data-external-sender-detail]')).toBeNull();
});
it('shows for any external one of several From addresses', () => {
render(<ExternalSenderChip from={[{ email: 'bob@example.org' }, { email: 'eve@evil.example' }]} />);
expect(screen.getByRole('button', { name: /evil\.example/ })).toBeTruthy();
});
it('shows for a From without a domain', () => {
render(<ExternalSenderChip from={[{ email: 'ceo' }]} />);
expect(screen.getByRole('button', { name: /sender_tooltip_no_domain/ })).toBeTruthy();
});
it('stays a plain label inside another button', () => {
const { container } = render(<button type="button"><ExternalSenderChip from={[{ email: 'dave@org-two.test' }]} interactive={false} /></button>);
expect(container.querySelectorAll('button')).toHaveLength(1);
expect(container.querySelector('[data-external-sender-chip]')?.getAttribute('title')).toBe('sender_tooltip(org-two.test)');
});
});
@@ -63,7 +63,7 @@ beforeEach(() => {
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 { container } = render(<ExternalSenderChip from={[{ email: "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');
@@ -79,7 +79,7 @@ describe('External sender chip', () => {
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" />);
const sub = render(<ExternalSenderChip from={[{ email: "someone@eu.example.org" }]} />);
expect(sub.container.querySelector('[data-external-sender-chip]')).toBeNull();
});
+4 -4
View File
@@ -856,7 +856,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" />
<ExternalSenderChip from={email.from} 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>
@@ -5051,7 +5051,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} />
<ExternalSenderChip from={email.from} />
<EmailIdentityBadge email={email} identities={identities} />
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
<UnsubscribeBanner
@@ -5341,7 +5341,7 @@ export function EmailViewer({
) : (
<span className="text-sm font-semibold text-foreground">{t('unknown_sender')}</span>
)}
<ExternalSenderChip fromEmail={sender?.email} />
<ExternalSenderChip from={email.from} />
<EmailIdentityBadge email={email} identities={identities} />
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
<UnsubscribeBanner
@@ -5529,7 +5529,7 @@ export function EmailViewer({
onViewContact={handleViewContactSidebar}
className="text-sm text-start"
/>
<ExternalSenderChip fromEmail={sender?.email} />
<ExternalSenderChip from={email.from} />
{senderVerification && (
<span
className={cn(
+92 -23
View File
@@ -1,50 +1,119 @@
'use client';
import { useMemo } from 'react';
import { useEffect, useId, useMemo, useRef, useState } 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 { externalSenders, internalDomains, type ExternalSender } from '@/lib/external-mail';
import { usePolicyStore } from '@/stores/policy-store';
import { useAuthStore } from '@/stores/auth-store';
type FromList = readonly { email?: string | null }[] | null | undefined;
/**
* 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.
* The From addresses outside the organisation when the admin turned the
* "External" label on (lib/external-mail.ts); empty otherwise.
*/
export function useExternalSenderDomain(fromEmail: string | null | undefined): string | null {
export function useExternalSenders(from: FromList): ExternalSender[] {
const externalMail = usePolicyStore((s) => s.policy.organisation?.externalMail);
const identities = useAuthStore((s) => s.identities);
return useMemo(() => {
if (!externalMail?.senderLabel) return null;
if (!externalMail?.senderLabel) return [];
const internal = internalDomains((identities ?? []).map((identity) => identity.email), externalMail.domains);
return externalSenderDomain(fromEmail, internal);
}, [externalMail, identities, fromEmail]);
return externalSenders(from, internal);
}, [externalMail, identities, from]);
}
/**
* "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.
*
* It is a button: a tap (phones have no hover) or Enter opens a line naming
* 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({ fromEmail, className }: { fromEmail: string | null | undefined; className?: string }) {
export function ExternalSenderChip({ from, className, interactive = true }: { from: FromList; className?: string; interactive?: boolean }) {
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,
)}
>
const senders = useExternalSenders(from);
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLSpanElement>(null);
const popoverId = useId();
useEffect(() => {
if (!open) return;
const close = (e: Event) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('pointerdown', close);
document.addEventListener('focusin', close);
return () => {
document.removeEventListener('pointerdown', close);
document.removeEventListener('focusin', close);
};
}, [open]);
if (senders.length === 0) return null;
const domains = [...new Set(senders.map((s) => s.domain))];
const named = domains.filter(Boolean).join(', ');
const description = named
? t('sender_tooltip', { domain: named })
: t('sender_tooltip_no_domain');
const chipClass = 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',
'border-warning/30 bg-warning/10 text-warning',
);
const content = (
<>
<Building className="h-3 w-3 flex-shrink-0" aria-hidden="true" />
{t('sender_label')}
<span className="sr-only">{` (${tooltip})`}</span>
</>
);
if (!interactive) {
return (
<span data-external-sender-chip="" title={description} className={cn(chipClass, 'cursor-help', className)}>
{content}
<span className="sr-only">{` (${description})`}</span>
</span>
);
}
return (
<span ref={wrapRef} className={cn('relative inline-flex flex-shrink-0', className)}>
<button
type="button"
data-external-sender-chip=""
title={description}
aria-label={`${t('sender_label')}: ${description}`}
aria-expanded={open}
aria-controls={open ? popoverId : undefined}
onClick={(e) => {
// The header around it folds or opens things on a click.
e.stopPropagation();
setOpen((v) => !v);
}}
onKeyDown={(e) => {
if (e.key === 'Escape' && open) {
e.stopPropagation();
setOpen(false);
}
}}
className={cn(chipClass, 'cursor-pointer hover:bg-warning/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring')}
>
{content}
</button>
{open && (
<span
id={popoverId}
role="status"
data-external-sender-detail=""
className="absolute start-0 top-full z-30 mt-1 w-max max-w-[16rem] whitespace-normal rounded-md border border-border bg-popover px-2.5 py-1.5 text-xs text-popover-foreground shadow-md"
>
{description}
</span>
)}
</span>
);
}
@@ -397,7 +397,7 @@ function EmailCard({
)}>
{sender?.name || (sender?.email && formatAddress(sender.email)) || "Unknown"}
</span>
<ExternalSenderChip fromEmail={sender?.email} />
<ExternalSenderChip from={email.from} interactive={false} />
{isStarred && (
<Star className="w-4 h-4 fill-amber-400 text-amber-400 flex-shrink-0" />
)}
+20 -6
View File
@@ -7,7 +7,7 @@ import {
currentInternalDomains,
domainMatches,
externalRecipients,
externalSenderDomain,
externalSenders,
internalDomains,
isExternalAddress,
} from '../external-mail';
@@ -114,11 +114,25 @@ describe('externalRecipients', () => {
});
});
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('externalSenders', () => {
const internal = ['example.org'];
it('names the external senders and nothing for internal ones', () => {
expect(externalSenders([{ email: 'dave@org-two.test' }], internal)).toEqual([{ address: 'dave@org-two.test', domain: 'org-two.test' }]);
expect(externalSenders([{ email: 'bob@example.org' }], internal)).toEqual([]);
expect(externalSenders(undefined, internal)).toEqual([]);
});
it('counts a message as external when any of several From addresses is', () => {
expect(externalSenders([{ email: 'bob@example.org' }, { email: 'eve@evil.example' }], internal))
.toEqual([{ address: 'eve@evil.example', domain: 'evil.example' }]);
});
it('counts a From without a domain as external', () => {
expect(externalSenders([{ email: 'ceo' }], internal)).toEqual([{ address: 'ceo', domain: '' }]);
});
it('calls nobody external when no internal domain is known', () => {
expect(externalSenders([{ email: 'ceo' }, { email: 'dave@org-two.test' }], [])).toEqual([]);
});
});
+25 -4
View File
@@ -101,10 +101,31 @@ export function externalRecipients(
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);
export interface ExternalSender {
address: string;
/** ASCII domain; '' for an address without one. */
domain: string;
}
/**
* The From addresses that are outside the organisation. A message counts as
* external when any of them is, and an address without a domain
* ("CEO <ceo>") counts too: no organisation sends like that. A forged
* internal From is not caught here; the sender check warns about that.
*/
export function externalSenders(
from: readonly { email?: string | null }[] | null | undefined,
internal: readonly string[],
): ExternalSender[] {
if (!from || internal.length === 0) return [];
const out: ExternalSender[] = [];
for (const sender of from) {
const address = String(sender?.email ?? '').trim();
const domain = addressDomain(address);
if (domain && domainMatches(domain, internal)) continue;
out.push({ address, domain });
}
return out;
}
// ─── The active account ──────────────────────────────────────
+2 -1
View File
@@ -661,7 +661,8 @@
},
"external_mail": {
"sender_label": "Extern",
"sender_tooltip": "Von außerhalb Ihrer Organisation: {domain}",
"sender_tooltip": "Von {domain}, außerhalb Ihrer Organisation",
"sender_tooltip_no_domain": "Von einer Adresse ohne Domain, außerhalb Ihrer Organisation",
"confirm_title": "Außerhalb Ihrer Organisation senden?",
"confirm_message": "{count, plural, one {Dieser Empfänger ist} other {Diese # Empfänger sind}} außerhalb Ihrer Organisation. Senden Sie nur, wenn das so gewollt ist.",
"field_to": "An",
+2 -1
View File
@@ -661,7 +661,8 @@
},
"external_mail": {
"sender_label": "External",
"sender_tooltip": "From outside your organisation: {domain}",
"sender_tooltip": "From {domain}, outside your organisation",
"sender_tooltip_no_domain": "From an address without a domain, outside your organisation",
"confirm_title": "Send outside your organisation?",
"confirm_message": "{count, plural, one {This recipient is} other {These # recipients are}} outside your organisation. Send only if you mean to.",
"field_to": "To",