Date: Wed, 28 Oct 2026 04:20:26 +0100
Subject: [PATCH 11/17] fix: don't count senders whose images the user allowed
as contacts for the external recipient confirm
---
.../composer-external-recipients.test.tsx | 2 ++
lib/__tests__/external-mail.test.ts | 21 ++++++++++++++++++-
lib/external-mail.ts | 15 ++++++++++---
3 files changed, 34 insertions(+), 4 deletions(-)
diff --git a/components/email/__tests__/composer-external-recipients.test.tsx b/components/email/__tests__/composer-external-recipients.test.tsx
index 4cdf10cd4..b2a9ddd76 100644
--- a/components/email/__tests__/composer-external-recipients.test.tsx
+++ b/components/email/__tests__/composer-external-recipients.test.tsx
@@ -118,6 +118,8 @@ vi.mock('@/stores/settings-store', () => {
vi.mock('@/stores/contact-store', () => {
const state = {
contacts: [],
+ addressBooks: [],
+ trustedSendersBookId: null,
getAutocomplete: async () => [],
addToTrustedSendersBook: async () => {},
};
diff --git a/lib/__tests__/external-mail.test.ts b/lib/__tests__/external-mail.test.ts
index be3449906..57ba06cfd 100644
--- a/lib/__tests__/external-mail.test.ts
+++ b/lib/__tests__/external-mail.test.ts
@@ -126,7 +126,7 @@ describe('the active account', () => {
beforeEach(() => {
setExternalMail({});
useAuthStore.setState({ identities: [] });
- useContactStore.setState({ contacts: [] });
+ useContactStore.setState({ contacts: [], addressBooks: [], trustedSendersBookId: null });
});
it('reads the switches from the effective policy', () => {
@@ -152,4 +152,23 @@ describe('the active account', () => {
});
expect([...contactAddresses()].sort()).toEqual(['dave@org-two.test', 'second@x.test']);
});
+
+ it('leaves out cards that are only in the Trusted Senders book', () => {
+ useContactStore.setState({
+ addressBooks: [
+ { id: 'book-main', name: 'Contacts' },
+ { id: 'acc1::book-trusted', name: 'Trusted Senders' },
+ ] as never,
+ trustedSendersBookId: 'book-trusted-raw',
+ contacts: [
+ // Only "always load images" for this sender: not a contact.
+ { id: 'c1', addressBookIds: { 'acc1::book-trusted': true }, emails: { e: { address: 'newsletter@shop.example' } } },
+ { id: 'c2', addressBookIds: { 'book-trusted-raw': true }, emails: { e: { address: 'promo@shop.example' } } },
+ // In a real book too: a contact.
+ { id: 'c3', addressBookIds: { 'book-main': true, 'acc1::book-trusted': true }, emails: { e: { address: 'dave@org-two.test' } } },
+ { id: 'c4', addressBookIds: { 'book-main': true }, emails: { e: { address: 'carol@partner.example' } } },
+ ] as unknown as ContactCard[],
+ });
+ expect([...contactAddresses()].sort()).toEqual(['carol@partner.example', 'dave@org-two.test']);
+ });
});
diff --git a/lib/external-mail.ts b/lib/external-mail.ts
index 0fe756a9b..f3ecc101f 100644
--- a/lib/external-mail.ts
+++ b/lib/external-mail.ts
@@ -18,7 +18,7 @@ import { DEFAULT_ORGANISATION_POLICY, normalizeDomainList, type OrganisationPoli
import { toAsciiDomain, toAsciiEmail } from '@/lib/idn';
import { usePolicyStore } from '@/stores/policy-store';
import { useAuthStore } from '@/stores/auth-store';
-import { useContactStore } from '@/stores/contact-store';
+import { TRUSTED_SENDERS_BOOK_NAME, useContactStore } from '@/stores/contact-store';
export type ExternalMailPolicy = OrganisationPolicy['externalMail'];
@@ -125,10 +125,19 @@ export function currentInternalDomains(policy: ExternalMailPolicy = currentExter
return internalDomains(identities.map((identity) => identity.email), policy.domains);
}
-/** The addresses of every loaded contact (all address books), as addressKey makes them. */
+/**
+ * The addresses of the loaded contacts, as addressKey makes them. A card
+ * that sits only in the "Trusted Senders" book is left out: that book is
+ * the list of senders whose images load, not people the user writes to.
+ */
export function contactAddresses(): Set
{
+ const { contacts, addressBooks, trustedSendersBookId } = useContactStore.getState();
+ const trustedBooks = new Set(addressBooks.filter((book) => book.name === TRUSTED_SENDERS_BOOK_NAME).map((book) => book.id));
+ if (trustedSendersBookId) trustedBooks.add(trustedSendersBookId);
const out = new Set();
- for (const contact of useContactStore.getState().contacts) {
+ for (const contact of contacts) {
+ const books = Object.keys(contact.addressBookIds ?? {}).filter((id) => contact.addressBookIds?.[id]);
+ if (books.length > 0 && books.every((id) => trustedBooks.has(id))) continue;
for (const entry of Object.values(contact.emails ?? {})) {
const address = entry?.address?.trim();
if (address) out.add(addressKey(address));
From 60167f752f791bb9fa0747a9f5f819eea9ef5894 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 04:26:50 +0100
Subject: [PATCH 12/17] fix: label mail as External when any sender is outside
or has no domain, and show the domain on a tap
---
.../external-sender-chip-detail.test.tsx | 64 ++++++++++
.../__tests__/external-sender-chip.test.tsx | 4 +-
components/email/email-viewer.tsx | 8 +-
components/email/external-sender-chip.tsx | 115 ++++++++++++++----
components/email/thread-conversation-view.tsx | 2 +-
lib/__tests__/external-mail.test.ts | 26 +++-
lib/external-mail.ts | 29 ++++-
locales/de/common.json | 3 +-
locales/en/common.json | 3 +-
9 files changed, 212 insertions(+), 42 deletions(-)
create mode 100644 components/email/__tests__/external-sender-chip-detail.test.tsx
diff --git a/components/email/__tests__/external-sender-chip-detail.test.tsx b/components/email/__tests__/external-sender-chip-detail.test.tsx
new file mode 100644
index 000000000..bd6ba9a3b
--- /dev/null
+++ b/components/email/__tests__/external-sender-chip-detail.test.tsx
@@ -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) =>
+ 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();
+ 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();
+ expect(screen.getByRole('button', { name: /evil\.example/ })).toBeTruthy();
+ });
+
+ it('shows for a From without a domain', () => {
+ render();
+ expect(screen.getByRole('button', { name: /sender_tooltip_no_domain/ })).toBeTruthy();
+ });
+
+ it('stays a plain label inside another button', () => {
+ const { container } = render();
+ expect(container.querySelectorAll('button')).toHaveLength(1);
+ expect(container.querySelector('[data-external-sender-chip]')?.getAttribute('title')).toBe('sender_tooltip(org-two.test)');
+ });
+});
diff --git a/components/email/__tests__/external-sender-chip.test.tsx b/components/email/__tests__/external-sender-chip.test.tsx
index a14269288..00e271fe2 100644
--- a/components/email/__tests__/external-sender-chip.test.tsx
+++ b/components/email/__tests__/external-sender-chip.test.tsx
@@ -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();
+ const { container } = render();
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();
+ const sub = render();
expect(sub.container.querySelector('[data-external-sender-chip]')).toBeNull();
});
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index 504ff96a7..01fe12d50 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 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 (
-
+ const senders = useExternalSenders(from);
+ const [open, setOpen] = useState(false);
+ const wrapRef = useRef(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 = (
+ <>
{t('sender_label')}
- {` (${tooltip})`}
+ >
+ );
+
+ if (!interactive) {
+ return (
+
+ {content}
+ {` (${description})`}
+
+ );
+ }
+
+ return (
+
+
+ {open && (
+
+ {description}
+
+ )}
);
}
diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx
index 26c19ad7f..fe3189ae0 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/lib/__tests__/external-mail.test.ts b/lib/__tests__/external-mail.test.ts
index 57ba06cfd..06a408200 100644
--- a/lib/__tests__/external-mail.test.ts
+++ b/lib/__tests__/external-mail.test.ts
@@ -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([]);
});
});
diff --git a/lib/external-mail.ts b/lib/external-mail.ts
index f3ecc101f..8ae1f5113 100644
--- a/lib/external-mail.ts
+++ b/lib/external-mail.ts
@@ -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 ") 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 ──────────────────────────────────────
diff --git a/locales/de/common.json b/locales/de/common.json
index 729bb80ca..61f24c102 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -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",
diff --git a/locales/en/common.json b/locales/en/common.json
index b8c4ecb2c..dd844e682 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -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",
From 1202a1372077d298aace412508f4da5e10b899e9 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 04:38:17 +0100
Subject: [PATCH 13/17] fix: judge external mail by the message's own account
and the sending account in several-account setups
---
.../external-sender-chip-detail.test.tsx | 44 ++++++++++-
components/email/email-composer.tsx | 1 +
components/email/email-viewer.tsx | 8 +-
components/email/external-sender-chip.tsx | 58 ++++++++++++---
components/email/thread-conversation-view.tsx | 2 +-
hooks/use-pro-multi-account-identities.ts | 5 ++
lib/__tests__/external-mail.test.ts | 44 +++++++++--
lib/external-mail.ts | 73 +++++++++++++++----
.../__tests__/external-recipients.test.ts | 39 ++++++++++
lib/outgoing/external-recipients.ts | 11 ++-
lib/outgoing/send-pipeline.ts | 6 ++
11 files changed, 250 insertions(+), 41 deletions(-)
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). */
From 9dab8955dde8d186942a6cd4a975351dadb1dbcf Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 04:44:06 +0100
Subject: [PATCH 14/17] feat: confirm before inviting people outside the
organisation to a meeting
---
.../event-modal-external-invitees.test.tsx | 105 ++++++++++++++++++
components/calendar/event-modal.tsx | 9 ++
lib/outgoing/external-recipients.ts | 31 +++++-
lib/outgoing/send-pipeline.ts | 2 +-
4 files changed, 145 insertions(+), 2 deletions(-)
create mode 100644 components/calendar/__tests__/event-modal-external-invitees.test.tsx
diff --git a/components/calendar/__tests__/event-modal-external-invitees.test.tsx b/components/calendar/__tests__/event-modal-external-invitees.test.tsx
new file mode 100644
index 000000000..0c3108d49
--- /dev/null
+++ b/components/calendar/__tests__/event-modal-external-invitees.test.tsx
@@ -0,0 +1,105 @@
+import { render, screen, fireEvent, waitFor, cleanup, act } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { EventModal } from '../event-modal';
+import { ExternalRecipientConfirmHost } from '@/components/email/external-recipient-confirm';
+import type { Calendar, CalendarEvent, 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';
+import { useContactStore } from '@/stores/contact-store';
+import { useExternalRecipientConfirmStore } from '@/stores/external-recipient-confirm-store';
+import { resetSendPipeline } from '@/lib/outgoing/send-pipeline';
+
+// Saving a meeting sends its invitations (the server does, on save), so a
+// newly invited person outside the organisation gets the same confirm as a
+// message. People already invited aren't asked about again.
+
+vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null }));
+
+const calendars = [{
+ id: 'cal', name: 'Personal', color: '#123456', isDefault: true,
+ myRights: {
+ mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
+ mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
+ },
+}] as unknown as Calendar[];
+
+const party = (address: string, owner: boolean) => ({
+ '@type': 'Participant', calendarAddress: `mailto:${address}`, email: address,
+ roles: owner ? { owner: true } : { attendee: true },
+ participationStatus: owner ? 'accepted' : 'needs-action', scheduleAgent: 'server', expectReply: !owner, kind: 'individual',
+});
+
+// The user's meeting, already sent to a partner outside.
+const meeting = {
+ id: 'e1', uid: 'u1', '@type': 'Event', title: 'Planning', description: '', start: '2026-10-12T10:00:00',
+ duration: 'PT1H', timeZone: 'Europe/Berlin', showWithoutTime: false, calendarIds: { cal: true },
+ organizerCalendarAddress: 'mailto:me@example.com', alerts: null, recurrenceRules: null,
+ participants: { org: party('me@example.com', true), partner: party('partner@partner.example', false) },
+} as unknown as CalendarEvent;
+
+function setWarning(on: boolean) {
+ usePolicyStore.setState({
+ policy: {
+ ...DEFAULT_POLICY,
+ organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, recipientWarning: on } },
+ },
+ });
+}
+
+beforeEach(() => {
+ setWarning(true);
+ useAuthStore.setState({ identities: [{ id: 'i1', name: 'Me', email: 'me@example.com' } as unknown as Identity] });
+ useContactStore.setState({ contacts: [], contactsLoaded: true });
+ useExternalRecipientConfirmStore.setState({ pending: null, queue: [] });
+});
+afterEach(() => {
+ cleanup();
+ resetSendPipeline();
+});
+
+function editWithNewInvitee(onSave: ReturnType, invitee: string) {
+ render(
+ <>
+
+
+ >,
+ );
+ fireEvent.click(screen.getByText('events.edit'));
+ fireEvent.change(screen.getByPlaceholderText('email_placeholder'), { target: { value: invitee } });
+ fireEvent.click(screen.getByText('form.save'));
+}
+
+describe('invitations to people outside the organisation', () => {
+ it('asks about the new external invitee only, and Back saves nothing', async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ editWithNewInvitee(onSave, 'org-dave@org-two.test');
+ await waitFor(() => expect(useExternalRecipientConfirmStore.getState().pending).not.toBeNull());
+ expect(useExternalRecipientConfirmStore.getState().pending?.recipients.map((r) => r.address)).toEqual(['org-dave@org-two.test']);
+ act(() => useExternalRecipientConfirmStore.getState().answer(false));
+ await new Promise((r) => setTimeout(r, 20));
+ expect(onSave).not.toHaveBeenCalled();
+ });
+
+ it('saves and sends after Send anyway', async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ editWithNewInvitee(onSave, 'org-dave@org-two.test');
+ await waitFor(() => expect(useExternalRecipientConfirmStore.getState().pending).not.toBeNull());
+ act(() => useExternalRecipientConfirmStore.getState().answer(true));
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
+ expect(onSave.mock.calls[0][1]).toBe(true);
+ });
+
+ it('saves without asking for an internal invitee or with the warning off', async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ editWithNewInvitee(onSave, 'colleague@example.com');
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
+ cleanup();
+ setWarning(false);
+ const second = vi.fn().mockResolvedValue(undefined);
+ editWithNewInvitee(second, 'org-dave@org-two.test');
+ await waitFor(() => expect(second).toHaveBeenCalledTimes(1));
+ expect(useExternalRecipientConfirmStore.getState().pending).toBeNull();
+ });
+});
diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx
index 3f45cc010..9a1a5096a 100644
--- a/components/calendar/event-modal.tsx
+++ b/components/calendar/event-modal.tsx
@@ -36,6 +36,7 @@ import { buildDuplicateEventData } from "@/lib/calendar-duplicate";
import { useFormatEventDate } from "@/hooks/use-format-event-date";
import { useIsPaneScoped } from "@/hooks/use-pane-context";
import { useContactNameResolver } from "@/hooks/use-contact-name-resolver";
+import { confirmInvitationRecipients } from "@/lib/outgoing/external-recipients";
import {
calendarFormHooks,
calendarHooks,
@@ -836,6 +837,14 @@ export function EventModal({
}
const shouldSendScheduling = userIsOrganizer && effectiveAttendees.length > 0 && sendInvitations;
+ // Invitations are mail too: newly invited people outside the
+ // organisation get the same confirm as a message (Back keeps the
+ // editor open, nothing saved).
+ if (shouldSendScheduling) {
+ const known = new Set(initialAttendees.map((a) => a.email.trim().toLowerCase()));
+ const invited = effectiveAttendees.map((a) => a.email).filter((email) => email && !known.has(email.trim().toLowerCase()));
+ if (!(await confirmInvitationRecipients(invited))) return;
+ }
await onSave(data, shouldSendScheduling, {
initialAttendees,
attendees: effectiveAttendees,
diff --git a/lib/outgoing/external-recipients.ts b/lib/outgoing/external-recipients.ts
index aa48de4e4..ac64d68e9 100644
--- a/lib/outgoing/external-recipients.ts
+++ b/lib/outgoing/external-recipients.ts
@@ -80,7 +80,36 @@ export async function externalRecipientCheck(
return ask(recipients);
}
+/** Dialog hosts mounted now; without one nobody could answer, so nothing asks. */
+let hosts = 0;
+
/** Called by the dialog host once it is mounted; returns the unregister function. */
export function registerExternalRecipientCheck(): () => void {
- return registerPreSendCheck(EXTERNAL_RECIPIENT_CHECK_ID, ORDER, (context) => externalRecipientCheck(context));
+ hosts += 1;
+ const unregister = registerPreSendCheck(EXTERNAL_RECIPIENT_CHECK_ID, ORDER, (context) => externalRecipientCheck(context));
+ let done = false;
+ return () => {
+ if (done) return;
+ done = true;
+ hosts -= 1;
+ unregister();
+ };
+}
+
+/**
+ * The same question for calendar invitations, which the server sends when an
+ * event is saved: true to save and send, false to go back to the event.
+ * Only the newly invited attendees are asked about; people already invited
+ * know of the event.
+ */
+export async function confirmInvitationRecipients(invited: string[]): Promise {
+ if (hosts === 0 || invited.length === 0) return true;
+ return externalRecipientCheck({
+ surface: 'calendar-invitation',
+ to: invited,
+ cc: [],
+ bcc: [],
+ mode: 'compose',
+ plainText: false,
+ });
}
diff --git a/lib/outgoing/send-pipeline.ts b/lib/outgoing/send-pipeline.ts
index e2f772165..ab4c2ce54 100644
--- a/lib/outgoing/send-pipeline.ts
+++ b/lib/outgoing/send-pipeline.ts
@@ -14,7 +14,7 @@
import { debug } from '@/lib/debug';
-export type SendSurface = 'composer' | 'quick-reply';
+export type SendSurface = 'composer' | 'quick-reply' | 'calendar-invitation';
export interface OutgoingContext {
surface: SendSurface;
From 3be3ad5ddf7410e89e149097bb5e1400702a4dec Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 04:56:48 +0100
Subject: [PATCH 15/17] test: cover quick reply Back, the domain on a tap and
the plugin hand-over per switch against the test servers
---
integration/tests/19-org-external.spec.ts | 44 +++++++++++++++++++----
1 file changed, 38 insertions(+), 6 deletions(-)
diff --git a/integration/tests/19-org-external.spec.ts b/integration/tests/19-org-external.spec.ts
index 34ad98a9f..4ac21961b 100644
--- a/integration/tests/19-org-external.spec.ts
+++ b/integration/tests/19-org-external.spec.ts
@@ -152,6 +152,9 @@ test('mail from org-dave is labelled External, mail from org-bob is not', async
await expect(chip(alice).first()).toBeVisible();
await expect(chip(alice).first()).toHaveAttribute('title', /org-two\.test/);
await shot(alice, 'external-sender-chip');
+ // A click (a tap on phones) names the domain.
+ await chip(alice).first().click();
+ await expect(alice.locator('[data-external-sender-detail]')).toContainText('org-two.test');
await openMessage(alice, FROM_BOB);
await expect(chip(alice)).toHaveCount(0);
@@ -178,13 +181,24 @@ test('sending to org-dave asks first, sending to org-bob does not', async ({ bro
await alice.context().close();
});
-test('a quick reply to org-dave asks too, and Send anyway sends it', async ({ browser }) => {
+test('a quick reply to org-dave asks too: Back keeps the text, Send anyway sends it', async ({ browser }) => {
const alice = await userPage(browser, ORG.alice);
await openMessage(alice, FROM_DAVE);
const box = alice.getByPlaceholder('Write a quick reply...');
await box.fill(`Quick reply ${RUN}`);
await box.press('Control+Enter');
await expect(confirm(alice)).toBeVisible();
+ await confirm(alice).locator('[data-external-recipient-back]').click();
+ await expect(confirm(alice)).toHaveCount(0);
+ await expect(box).toHaveValue(`Quick reply ${RUN}`);
+ await box.press('Control+Enter');
+ await expect(confirm(alice)).toBeVisible();
+ // Keys stay in the dialog: Escape answers Back and leaves the message open.
+ await alice.keyboard.press('Escape');
+ await expect(confirm(alice)).toHaveCount(0);
+ await expect(box).toHaveValue(`Quick reply ${RUN}`);
+ await box.press('Control+Enter');
+ await expect(confirm(alice)).toBeVisible();
await confirm(alice).locator('[data-external-recipient-send]').click();
await expect(confirm(alice)).toHaveCount(0);
await waitForSubject(ORG.dave, `Re: ${FROM_DAVE}`);
@@ -269,7 +283,7 @@ test.describe('an installed External Mail Warning 1.5.1', () => {
await admin.close();
});
- test('is skipped while core warnings are on, and its safe domains import', async ({ browser }) => {
+ test('steps back one switch at a time, and its safe domains import', async ({ browser }) => {
const admin = await adminContext(browser);
// next dev compiles the sandbox page on first use, which can outlast the plugin's init timeout.
await admin.request.get('/plugin-sandbox', { timeout: 120000 });
@@ -310,13 +324,16 @@ test.describe('an installed External Mail Warning 1.5.1', () => {
await expect(page.getByText('Saved.')).toBeVisible();
expect(((await getPolicy(admin)).organisation as { externalMail: { domains: string[] } }).externalMail.domains)
.toEqual(['partner.example', 'other-partner.example']);
- await admin.close();
+ await page.close();
- // Core on: one question, Bulwark's own, and no plugin banner.
+ // Only the recipient warning on: one question on send, Bulwark's own;
+ // the plugin keeps its banner.
+ await putPolicy(admin, { ...(await getPolicy(admin)), organisation: { externalMail: { senderLabel: false, recipientWarning: true } } });
alice = await userPage(browser, ORG.alice);
+ await expect(alice.locator('iframe[src*="/plugin-sandbox"]').first()).toBeAttached({ timeout: 30000 });
await openMessage(alice, FROM_DAVE_2);
- await expect(chip(alice).first()).toBeVisible();
- await expect(alice.locator('[data-plugin-slot="email-banner"]')).toHaveCount(0);
+ await expect(chip(alice)).toHaveCount(0);
+ await expect(alice.locator('[data-plugin-slot="email-banner"]')).toHaveCount(1);
await composeTo(alice, ORG.dave, `Core asks ${RUN}`);
await expect(confirm(alice)).toBeVisible();
await confirm(alice).locator('[data-external-recipient-send]').click();
@@ -324,5 +341,20 @@ test.describe('an installed External Mail Warning 1.5.1', () => {
await expect(alice.locator('[data-plugin-dialog="external-mail-warning"]')).toHaveCount(0);
await waitForSubject(ORG.dave, `Core asks ${RUN}`);
await alice.context().close();
+
+ // Only the label on: Bulwark's chip instead of the plugin's banner; the
+ // plugin still asks on send, Bulwark doesn't.
+ await putPolicy(admin, { ...(await getPolicy(admin)), organisation: { externalMail: { senderLabel: true, recipientWarning: false } } });
+ alice = await userPage(browser, ORG.alice);
+ await expect(alice.locator('iframe[src*="/plugin-sandbox"]').first()).toBeAttached({ timeout: 30000 });
+ await openMessage(alice, FROM_DAVE_2);
+ await expect(chip(alice).first()).toBeVisible();
+ await expect(alice.locator('[data-plugin-slot="email-banner"]')).toHaveCount(0);
+ await composeTo(alice, ORG.dave, `Plugin still asks ${RUN}`);
+ await expect(alice.locator('[data-plugin-dialog="external-mail-warning"]')).toBeVisible({ timeout: 30000 });
+ await expect(confirm(alice)).toHaveCount(0);
+ await alice.locator('[data-plugin-dialog="external-mail-warning"]').getByRole('button', { name: 'Cancel' }).click();
+ await alice.context().close();
+ await admin.close();
});
});
From 2a90437fbc694bcb014622e2e4637aa6fcf7fa88 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 05:02:13 +0100
Subject: [PATCH 16/17] test: give the domain list test time under load
---
components/admin/org/__tests__/external-mail-section.test.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/components/admin/org/__tests__/external-mail-section.test.tsx b/components/admin/org/__tests__/external-mail-section.test.tsx
index efa4739f7..29a1ff1b3 100644
--- a/components/admin/org/__tests__/external-mail-section.test.tsx
+++ b/components/admin/org/__tests__/external-mail-section.test.tsx
@@ -92,7 +92,7 @@ describe('External mail section', () => {
fireEvent.blur(field);
expect(latest!.orgValue('externalMail', 'domains').value).toEqual(['xn--bcher-kva.de']);
expect(screen.getByTestId('org-external-domains-rejected').textContent).toContain('not_a_domain, x');
- await waitFor(() => expect(field.value).toBe('bücher.de'));
+ await waitFor(() => expect(field.value).toBe('bücher.de'), { timeout: 5000 });
});
it('offers no import and no note without the plugin', async () => {
From 9f230ca8fcbbde11eec25031b5b695230dc389eb Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 05:23:25 +0100
Subject: [PATCH 17/17] fix: keep a quick reply as a draft when an automatic
sign-out comes while the external recipient question is open
---
.../viewer-quick-reply-back.test.tsx | 28 +++++++++++++++++++
components/email/email-viewer.tsx | 12 ++++++--
2 files changed, 38 insertions(+), 2 deletions(-)
diff --git a/components/email/__tests__/viewer-quick-reply-back.test.tsx b/components/email/__tests__/viewer-quick-reply-back.test.tsx
index c9f478d82..39fae9a39 100644
--- a/components/email/__tests__/viewer-quick-reply-back.test.tsx
+++ b/components/email/__tests__/viewer-quick-reply-back.test.tsx
@@ -62,3 +62,31 @@ describe('quick reply', () => {
expect(box.value).toBe('');
});
});
+
+describe('automatic sign-out while the external recipient question is open', () => {
+ it('answers Back and keeps the typed reply as a draft, without asking again', async () => {
+ const { useExternalRecipientConfirmStore } = await import('@/stores/external-recipient-confirm-store');
+ const { flushComposers, resetComposeFlushRegistry } = await import('@/lib/compose-flush-registry');
+ resetComposeFlushRegistry();
+ useExternalRecipientConfirmStore.setState({ pending: null, queue: [] });
+ const onQuickReply = vi.fn((body: string) => {
+ void body;
+ return useExternalRecipientConfirmStore.getState().ask([{ address: 'org-dave@org-two.test', domain: 'org-two.test', field: 'to' }]);
+ });
+ const onSaveQuickReplyDraft = vi.fn(async () => {});
+ render();
+ await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
+ const box = screen.getByPlaceholderText('quick_reply_placeholder') as HTMLTextAreaElement;
+ fireEvent.focus(box);
+ fireEvent.change(box, { target: { value: 'My answer' } });
+ fireEvent.click(screen.getByRole('button', { name: 'send' }));
+ await waitFor(() => expect(useExternalRecipientConfirmStore.getState().pending).not.toBeNull());
+
+ await act(async () => { await flushComposers(2000); });
+ expect(onSaveQuickReplyDraft).toHaveBeenCalledWith('My answer');
+ expect(useExternalRecipientConfirmStore.getState().pending).toBeNull();
+ expect(box.value).toBe('My answer');
+ expect(onQuickReply).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index bac5a0b13..d0f6099ab 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -103,6 +103,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { useShallow } from "zustand/react/shallow";
import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout";
import { registerComposeFlush } from "@/lib/compose-flush-registry";
+import { useExternalRecipientConfirmStore } from "@/stores/external-recipient-confirm-store";
import { useUIStore } from "@/stores/ui-store";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { toast } from "@/stores/toast-store";
@@ -1377,7 +1378,9 @@ export function EmailViewer({
const [isQuickReplyFocused, setIsQuickReplyFocused] = useState(false);
const [isSendingQuickReply, setIsSendingQuickReply] = useState(false);
// An automatic sign-out keeps typed quick-reply text as a draft; text on
- // its way out as a reply needs none.
+ // its way out as a reply needs none. A reply still waiting on the
+ // "Send outside your organisation?" question hasn't gone out: the question
+ // is answered with Back and the text kept as a draft.
const quickReplyDraftRef = useRef({ text: quickReplyText, sending: isSendingQuickReply, save: onSaveQuickReplyDraft });
quickReplyDraftRef.current = { text: quickReplyText, sending: isSendingQuickReply, save: onSaveQuickReplyDraft };
const hasQuickReplyDraft = quickReplyText.trim().length > 0 && !!onSaveQuickReplyDraft;
@@ -1385,7 +1388,12 @@ export function EmailViewer({
if (!hasQuickReplyDraft) return;
return registerComposeFlush(async () => {
const { text, sending, save } = quickReplyDraftRef.current;
- if (sending || !text.trim() || !save) return;
+ if (!text.trim() || !save) return;
+ if (sending) {
+ const confirm = useExternalRecipientConfirmStore.getState();
+ if (!confirm.pending) return;
+ confirm.cancelAll();
+ }
await save(text);
});
}, [hasQuickReplyDraft]);