Merge branch 'feat/org-external' into feat/org-features

# Conflicts:
#	lib/outgoing/quick-reply.ts
#	lib/outgoing/send-pipeline.ts
This commit is contained in:
Linus Rath
2026-10-28 05:46:11 +01:00
40 changed files with 3143 additions and 35 deletions
+2
View File
@@ -10,6 +10,7 @@ import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-la
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler";
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
import { ExternalRecipientConfirmHost } from "@/components/email/external-recipient-confirm";
import { CommandPaletteHost } from "@/components/command-palette/command-palette";
import { PluginConnectDialog } from "@/components/plugins/plugin-connect-dialog";
import { QuickRuleHost } from "@/components/filters/quick-rule-host";
@@ -65,6 +66,7 @@ export default async function LocaleLayout({
<SessionTimeoutGuard />
{children}
<PluginDialogHost />
<ExternalRecipientConfirmHost />
<CommandPaletteHost />
<PluginConnectDialog />
<QuickRuleHost />
@@ -0,0 +1,128 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
// Organisation > Security > External mail: the switches and the domain list
// go into the draft at the chosen scope, and the External Mail Warning
// plugin's safe domains can be copied in as an unsaved change.
const puts: Array<Record<string, unknown>> = [];
let stored: Record<string, unknown> = {};
let plugins: Array<Record<string, unknown>> = [];
let pluginConfig: Record<string, unknown> = {};
vi.mock('@/lib/browser-navigation', () => ({
apiFetch: vi.fn(async (url: string, init?: RequestInit) => {
if (url === '/api/admin/plugins') return new Response(JSON.stringify(plugins));
if (url === '/api/admin/plugins/external-mail-warning/config') return new Response(JSON.stringify(pluginConfig));
if (init?.method === 'PUT') {
const body = JSON.parse(String(init.body));
puts.push(body);
stored = body;
return new Response('{"ok":true}');
}
return new Response(JSON.stringify(stored));
}),
withBasePath: (p: string) => p,
getPathPrefix: () => '',
}));
import { ExternalMailSection } from '../sections/external-mail-section';
import { useOrgPolicyDraft, useOrgScopeStore, EVERYONE } from '../use-org-policy-draft';
const POLICY = {
restrictions: {}, features: {}, defaults: {}, themePolicy: {},
forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [],
organisation: { externalMail: { domains: ['partner.com'] } },
scopes: [{ id: 'two', label: 'Org two', match: { domains: ['org-two.test'] }, policy: {} }],
};
let latest: ReturnType<typeof useOrgPolicyDraft> | null = null;
function Harness() {
const draft = useOrgPolicyDraft('test');
latest = draft;
if (draft.loading) return null;
return <ExternalMailSection draft={draft} />;
}
async function renderSection() {
render(<Harness />);
await waitFor(() => expect(screen.getByTestId('org-external-sender-label')).toBeInTheDocument());
}
beforeEach(() => {
puts.length = 0;
stored = JSON.parse(JSON.stringify(POLICY));
plugins = [];
pluginConfig = {};
latest = null;
useOrgScopeStore.setState({ scopeId: EVERYONE });
});
describe('External mail section', () => {
it('starts with both switches off and saves what the admin turns on', async () => {
await renderSection();
expect(screen.getByTestId('org-external-sender-label').getAttribute('aria-checked')).toBe('false');
expect(screen.getByTestId('org-external-recipient-warning').getAttribute('aria-checked')).toBe('false');
fireEvent.click(screen.getByTestId('org-external-sender-label'));
fireEvent.click(screen.getByTestId('org-external-recipient-warning'));
await act(async () => { await latest!.save(); });
expect((puts[0].organisation as { externalMail: unknown }).externalMail).toMatchObject({ senderLabel: true, recipientWarning: true, domains: ['partner.com'] });
});
it('writes a scope override only into that scope', async () => {
await renderSection();
act(() => latest!.setScopeId('two'));
fireEvent.click(screen.getByTestId('org-external-sender-label'));
expect(latest!.policy?.scopes?.[0].policy.organisation).toEqual({ externalMail: { senderLabel: true } });
expect(latest!.policy?.organisation?.externalMail.senderLabel).toBe(false);
});
it('takes the typed domain list apart when the field is left', async () => {
await renderSection();
const field = screen.getByTestId('org-external-domains');
fireEvent.change(field, { target: { value: 'partner.com, Sister.example\n@third.test' } });
fireEvent.blur(field);
expect(latest!.orgValue('externalMail', 'domains').value).toEqual(['partner.com', 'sister.example', 'third.test']);
expect(screen.queryByTestId('org-external-domains-rejected')).toBeNull();
});
it('stores an IDN in ASCII, shows it in Unicode and names entries that are not domains', async () => {
await renderSection();
const field = screen.getByTestId('org-external-domains') as HTMLTextAreaElement;
fireEvent.change(field, { target: { value: 'bücher.de\nnot_a_domain, x' } });
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'), { timeout: 5000 });
});
it('offers no import and no note without the plugin', async () => {
await renderSection();
fireEvent.click(screen.getByTestId('org-external-sender-label'));
expect(screen.queryByTestId('org-external-import-plugin')).toBeNull();
expect(screen.queryByTestId('org-external-plugin-note')).toBeNull();
});
it('asks to update an old plugin while the warnings are on and imports its safe domains as a draft change', async () => {
plugins = [{ id: 'external-mail-warning', version: '1.5.1' }];
pluginConfig = { safeDomains: 'partner.com, acme.example\nsub.acme.example' };
await renderSection();
await waitFor(() => expect(screen.getByTestId('org-external-import-plugin')).toBeInTheDocument());
expect(screen.queryByTestId('org-external-plugin-note')).toBeNull();
fireEvent.click(screen.getByTestId('org-external-recipient-warning'));
expect(screen.getByTestId('org-external-plugin-note').textContent).toContain('Update or remove the External Mail Warning plugin');
await act(async () => { fireEvent.click(screen.getByTestId('org-external-import-plugin')); });
await waitFor(() => expect(latest!.orgValue('externalMail', 'domains').value).toEqual(['partner.com', 'acme.example', 'sub.acme.example']));
expect(latest!.dirty).toBe(true);
expect(puts).toHaveLength(0);
});
it('only suggests removing a plugin that steps back by itself', async () => {
plugins = [{ id: 'external-mail-warning', version: '1.6.0' }];
await renderSection();
await waitFor(() => expect(screen.getByTestId('org-external-import-plugin')).toBeInTheDocument());
fireEvent.click(screen.getByTestId('org-external-sender-label'));
expect(screen.getByTestId('org-external-plugin-note').textContent).toContain('You can remove the External Mail Warning plugin');
});
});
@@ -1,14 +1,184 @@
'use client';
import { SettingsSection } from '@/components/settings/settings-section';
import { useEffect, useState } from 'react';
import { Download, Info } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { SettingItem, SettingsSection, ToggleSwitch } from '@/components/settings/settings-section';
import { AdminLink } from '@/components/admin/admin-link';
import { parseDomainList } from '@/lib/admin/organisation-policy';
import { toUnicodeDomain } from '@/lib/idn';
import { apiFetch } from '@/lib/browser-navigation';
import { EXTERNAL_MAIL_WARNING_PLUGIN_ID } from '@/lib/plugin-sandbox/host-features';
import { InheritNote } from '../org-page';
import type { OrgPolicyDraft } from '../use-org-policy-draft';
/** Organisation feature 2; filled in by its own step (local-data/org-features-plan.md). */
/** The first release of the plugin core took over from that steps back on its own. */
export const PLUGIN_STEPS_BACK_FROM = '1.6.0';
function older(version: string, than: string): boolean {
const a = version.split(/[-+]/)[0].split('.').map(Number);
const b = than.split(/[-+]/)[0].split('.').map(Number);
for (let i = 0; i < 3; i++) if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) < (b[i] || 0);
return false;
}
type InstalledPlugin = { version: string };
/** The External Mail Warning plugin when the admin installed it, else null. */
function useInstalledPlugin(): InstalledPlugin | null {
const [plugin, setPlugin] = useState<InstalledPlugin | null>(null);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const res = await apiFetch('/api/admin/plugins');
const list = res.ok ? ((await res.json()) as Array<{ id: string; version?: string }>) : [];
const found = Array.isArray(list) ? list.find((p) => p.id === EXTERNAL_MAIL_WARNING_PLUGIN_ID) : undefined;
if (!cancelled) setPlugin(found ? { version: found.version ?? '' } : null);
} catch {
if (!cancelled) setPlugin(null);
}
})();
return () => { cancelled = true; };
}, []);
return plugin;
}
const onOff = (v: boolean) => (v ? 'on' : 'off');
/**
* Organisation > Security > External mail: the "External" sender label,
* the confirm before sending outside, which domains are internal, and the
* hand-over from the External Mail Warning plugin.
*/
export function ExternalMailSection({ draft }: { draft: OrgPolicyDraft }) {
void draft;
const senderLabel = draft.orgValue('externalMail', 'senderLabel');
const recipientWarning = draft.orgValue('externalMail', 'recipientWarning');
const domains = draft.orgValue('externalMail', 'domains');
const trustContacts = draft.orgValue('externalMail', 'trustContacts');
const plugin = useInstalledPlugin();
const anyOn = senderLabel.value || recipientWarning.value;
// The list is typed as text and taken apart when the field is left, so a
// half-typed domain isn't thrown away on every key. It is stored in ASCII
// (xn--) form and shown in Unicode; entries that aren't domains are named.
const stored = domains.value.join('\n');
const joined = domains.value.map(toUnicodeDomain).join('\n');
const [text, setText] = useState(joined);
const [rejected, setRejected] = useState<string[]>([]);
useEffect(() => { setText(joined); }, [joined]);
const commitDomains = (raw: string) => {
const parsed = parseDomainList(raw) ?? { domains: [], rejected: [] };
setRejected(parsed.rejected);
if (parsed.domains.join('\n') !== stored) draft.setOrgValue('externalMail', 'domains', parsed.domains);
else setText(joined);
};
const [importing, setImporting] = useState(false);
const importFromPlugin = async () => {
setImporting(true);
try {
const res = await apiFetch(`/api/admin/plugins/${EXTERNAL_MAIL_WARNING_PLUGIN_ID}/config`);
if (!res.ok) throw new Error(String(res.status));
const config = (await res.json()) as Record<string, unknown>;
const parsed = parseDomainList(config.safeDomains) ?? { domains: [], rejected: [] };
const fromPlugin = parsed.domains;
setRejected(parsed.rejected);
const added = fromPlugin.filter((d) => !domains.value.includes(d));
if (added.length > 0) draft.setOrgValue('externalMail', 'domains', [...domains.value, ...added]);
draft.setMessage({
type: 'success',
text: added.length > 0
? `Added ${added.length} ${added.length === 1 ? 'domain' : 'domains'} from the plugin. Save to apply them.`
: fromPlugin.length > 0 ? 'The list already has every domain the plugin had.' : 'The plugin has no safe domains set.',
});
} catch {
draft.setMessage({ type: 'error', text: 'Could not read the plugin settings' });
} finally {
setImporting(false);
}
};
const outdated = !!plugin && older(plugin.version, PLUGIN_STEPS_BACK_FROM);
return (
<SettingsSection id="external" title="External mail" description="Label mail from senders outside the organisation and confirm before sending outside it.">
<p className="text-sm text-muted-foreground">Nothing to set here yet.</p>
<SettingsSection
id="external"
title="External mail"
description="Label mail from senders outside the organisation and confirm before sending outside it. Both are off until you turn them on."
>
<SettingItem label="Label external senders" description="Shows an “External” chip next to the sender of mail from outside the organisation.">
<div className="flex flex-col items-start gap-1 sm:items-end">
<ToggleSwitch checked={senderLabel.value} onChange={(v) => draft.setOrgValue('externalMail', 'senderLabel', v)} testId="org-external-sender-label" />
<InheritNote draft={draft} own={senderLabel.own} everyone={onOff(senderLabel.everyone)} onReset={() => draft.clearOrgValue('externalMail', 'senderLabel')} />
</div>
</SettingItem>
<SettingItem label="Confirm external recipients" description="Before a message goes out, lists the recipients outside the organisation and asks “Send anyway” or “Back”. Quick replies too.">
<div className="flex flex-col items-start gap-1 sm:items-end">
<ToggleSwitch checked={recipientWarning.value} onChange={(v) => draft.setOrgValue('externalMail', 'recipientWarning', v)} testId="org-external-recipient-warning" />
<InheritNote draft={draft} own={recipientWarning.own} everyone={onOff(recipientWarning.everyone)} onReset={() => draft.clearOrgValue('externalMail', 'recipientWarning')} />
</div>
</SettingItem>
<SettingItem label="Trust contacts" description="Recipients in the user's contacts are sent to without asking.">
<div className="flex flex-col items-start gap-1 sm:items-end">
<ToggleSwitch checked={trustContacts.value} onChange={(v) => draft.setOrgValue('externalMail', 'trustContacts', v)} testId="org-external-trust-contacts" />
<InheritNote draft={draft} own={trustContacts.own} everyone={onOff(trustContacts.everyone)} onReset={() => draft.clearOrgValue('externalMail', 'trustContacts')} />
</div>
</SettingItem>
<div className="space-y-2 py-3">
<label htmlFor="org-external-domains" className="text-sm font-medium text-foreground">More organisation domains</label>
<p className="text-xs text-muted-foreground">
Your users&apos; own domains always count as internal. Add partner or sister-company domains here, one per line or separated by commas. A domain covers its subdomains: example.com also covers eu.example.com.
</p>
<textarea
id="org-external-domains"
data-testid="org-external-domains"
rows={4}
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={(e) => commitDomains(e.target.value)}
placeholder="partner.com"
spellCheck={false}
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
{rejected.length > 0 && (
<p className="text-xs text-destructive" role="status" data-testid="org-external-domains-rejected">
Left out, not a domain: {rejected.join(', ')}
</p>
)}
<InheritNote
draft={draft}
own={domains.own}
everyone={domains.everyone.length ? domains.everyone.map(toUnicodeDomain).join(', ') : 'none'}
onReset={() => draft.clearOrgValue('externalMail', 'domains')}
/>
{plugin && (
<Button type="button" variant="outline" size="sm" onClick={() => void importFromPlugin()} disabled={importing} className="gap-1.5" data-testid="org-external-import-plugin">
<Download className="h-4 w-4" /> Import from the plugin
</Button>
)}
</div>
{plugin && anyOn && (
<div className="flex items-start gap-2 rounded-md border border-border bg-muted/40 p-3 text-sm" data-testid="org-external-plugin-note">
<Info className="mt-0.5 h-4 w-4 flex-shrink-0 text-muted-foreground" />
<div className="min-w-0 space-y-1">
<p className="font-medium text-foreground">
{outdated ? 'Update or remove the External Mail Warning plugin' : 'You can remove the External Mail Warning plugin'}
</p>
<p className="text-muted-foreground">
{outdated
? `Version ${plugin.version || 'unknown'} is installed. For each switch that is on, Bulwark skips the matching part of the plugin (its send confirm, or its banner and new-mail toast) so nobody is warned twice; version ${PLUGIN_STEPS_BACK_FROM} and later step back by themselves.`
: 'It turns off its own send confirm, banner and toast for each switch that is on here.'}{' '}
&ldquo;Import from the plugin&rdquo; copies its safe domains into the list above.{' '}
<AdminLink href={`/admin/plugins/${EXTERNAL_MAIL_WARNING_PLUGIN_ID}`} className="underline underline-offset-2 hover:text-foreground">Open the plugin</AdminLink>
</p>
</div>
</div>
)}
</SettingsSection>
);
}
@@ -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<typeof vi.fn>, invitee: string) {
render(
<>
<EventModal event={meeting} calendars={calendars} onSave={onSave as never} onClose={vi.fn()} currentUserEmails={["me@example.com"]} />
<ExternalRecipientConfirmHost />
</>,
);
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();
});
});
+9
View File
@@ -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,
@@ -0,0 +1,308 @@
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
import { resetSendPipeline } from '@/lib/outgoing/send-pipeline';
import { ExternalRecipientConfirmHost } from '../external-recipient-confirm';
import { useExternalRecipientConfirmStore } from '@/stores/external-recipient-confirm-store';
import { usePolicyStore } from '@/stores/policy-store';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { useAuthStore } from '@/stores/auth-store';
import { useIdentityStore } from '@/stores/identity-store';
// ─── Heavy component mocks (mirrors composer-reopened-draft-signature.test.tsx) ─
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
vi.mock('@/hooks/use-focus-trap', () => ({
useFocusTrap: () => ({ current: null }),
}));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
}));
// ─── Store mocks ──────────────────────────────────────────────────────────────
vi.mock('@/stores/auth-store', () => {
const state = {
client: null,
identities: [],
primaryIdentity: null,
isAuthenticated: false,
isDemoMode: false,
activeAccountId: null,
connectionLost: false,
getClientForAccount: () => undefined,
getAllConnectedClients: () => new Map(),
syncIdentities: () => {},
refreshIdentities: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAuthStore: hook };
});
vi.mock('@/stores/identity-store', () => {
const state = {
identities: [
{ id: 'id-me', email: 'me@example.com', name: 'Me' },
{ id: 'id-sales', email: 'sales@example.com', name: 'Sales' },
] as Array<Record<string, unknown>>,
defaultIdentityId: 'id-me',
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useIdentityStore: hook };
});
vi.mock('@/stores/account-store', () => {
const state = { accounts: [], getAccountById: () => undefined };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAccountStore: hook };
});
vi.mock('@/stores/email-store', () => {
const state = {
draftSaveEnabled: false,
sendRawEmail: async () => ({ sent: true }),
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useEmailStore: hook };
});
vi.mock('@/stores/settings-store', () => {
const state = {
timeFormat: '24h',
plainTextMode: false,
subAddressDelimiter: '+',
autoSelectReplyIdentity: true,
attachmentReminderEnabled: false,
attachmentReminderKeywords: [],
emptySubjectWarningEnabled: true,
sendDelaySeconds: 0,
// The default position: replies/forwards get the signature appended at
// send time rather than embedded above the quote.
signaturePosition: 'below_quote',
signatureSeparatorEnabled: true,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
trustedSendersAddressBook: null,
updateSetting: () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useSettingsStore: hook };
});
vi.mock('@/stores/contact-store', () => {
const state = {
contacts: [],
addressBooks: [],
trustedSendersBookId: null,
getAutocomplete: async () => [],
addToTrustedSendersBook: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useContactStore: hook };
});
vi.mock('@/stores/template-store', () => {
const state = { templates: [], addTemplate: async () => {} };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useTemplateStore: hook };
});
// ─── Misc dependency mocks ────────────────────────────────────────────────────
vi.mock('@/stores/toast-store', () => ({
toast: { info: () => {}, error: () => {}, success: () => {} },
}));
vi.mock('@/lib/plugin-hooks', () => ({
identityHooks: {
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
onIdentitySelect: { size: 0, emit: async () => {} },
},
templateHooks: {
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
onTemplateApply: { size: 0, emit: async () => {} },
},
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
getRecipientSuggestions: { call: async () => [] },
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
contactHooks: {
search: { call: async () => [] },
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
},
}));
vi.mock('@/lib/email-sanitization', () => ({
sanitizeSignatureHtml: (v: string) => v,
sanitizeSignatureHtmlForDisplay: (v: string) => v,
sanitizeEmailHtml: (v: string) => v,
sanitizePluginBodyHtml: (v: string) => v,
escapeHtml: (v: string) => v,
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
}));
vi.mock('@/lib/email-threading', () => ({
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
}));
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
vi.mock('@/components/email/quoted-html', () => ({
buildQuotedHtmlBlock: () => '',
serializeEditorContent: () => '',
}));
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
// ─── Tests ────────────────────────────────────────────────────────────────────
// The external-recipient confirm in the real composer: the check is
// registered by the dialog host, asks only about the recipients outside the
// organisation, and the composer sends or not by the user's answer.
const DRAFT = {
to: 'bob@example.com, dave@org-two.test',
cc: 'carol@example.com',
bcc: '',
subject: 'Hello',
body: '<p>Hi</p>',
showCc: true,
showBcc: false,
selectedIdentityId: 'id-me',
subAddressTag: '',
mode: 'compose' as const,
draftId: null,
};
function setExternalMail(patch: Partial<typeof DEFAULT_ORGANISATION_POLICY.externalMail>) {
usePolicyStore.setState({
policy: {
...DEFAULT_POLICY,
organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } },
},
});
}
function clickSend() {
fireEvent.click(screen.getAllByTestId('composer-send')[0]);
}
function renderComposer(onSend: () => void) {
return render(
<>
<EmailComposer initialData={DRAFT} onSend={onSend} />
<ExternalRecipientConfirmHost />
</>,
);
}
describe('composer: confirm before sending outside the organisation', () => {
beforeEach(() => {
resetSendPipeline();
useExternalRecipientConfirmStore.setState({ pending: null, queue: [] });
setExternalMail({ recipientWarning: true });
// The user's identities, as the auth store holds them after sign-in:
// example.com is internal because of them.
const identities = [
{ id: 'id-me', email: 'me@example.com', name: 'Me' },
{ id: 'id-sales', email: 'sales@example.com', name: 'Sales' },
];
useAuthStore.setState({ identities } as never);
useIdentityStore.setState({ identities } as never);
});
afterEach(() => {
cleanup();
resetSendPipeline();
vi.clearAllMocks();
});
it('lists only the external recipient and sends nothing on Back', async () => {
const onSend = vi.fn();
const { container } = renderComposer(onSend);
clickSend();
const list = await waitFor(() => {
const el = container.querySelector('[data-external-recipients]');
expect(el).not.toBeNull();
return el!;
});
expect(list.textContent).toContain('dave@org-two.test');
expect(list.textContent).not.toContain('bob@example.com');
expect(list.textContent).not.toContain('carol@example.com');
fireEvent.click(container.querySelector('[data-external-recipient-back]')!);
await waitFor(() => expect(container.querySelector('[data-external-recipient-confirm]')).toBeNull());
expect(onSend).not.toHaveBeenCalled();
});
it('sends after Send anyway', async () => {
const onSend = vi.fn();
const { container } = renderComposer(onSend);
clickSend();
await waitFor(() => expect(container.querySelector('[data-external-recipient-send]')).not.toBeNull());
fireEvent.click(container.querySelector('[data-external-recipient-send]')!);
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
});
it('leaves the identity\'s automatic Bcc out of the question', async () => {
useIdentityStore.setState({
identities: [
{ id: 'id-me', email: 'me@example.com', name: 'Me', bcc: [{ name: '', email: 'archive@archive-service.example' }] },
{ id: 'id-sales', email: 'sales@example.com', name: 'Sales' },
],
} as never);
const { container } = renderComposer(vi.fn());
clickSend();
const list = await waitFor(() => {
const el = container.querySelector('[data-external-recipients]');
expect(el).not.toBeNull();
return el!;
});
expect(list.textContent).toContain('dave@org-two.test');
expect(list.textContent).not.toContain('archive@archive-service.example');
});
it('sends without asking while the warning is off', async () => {
setExternalMail({ recipientWarning: false, senderLabel: true });
const onSend = vi.fn();
const { container } = renderComposer(onSend);
clickSend();
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
expect(container.querySelector('[data-external-recipient-confirm]')).toBeNull();
});
});
@@ -0,0 +1,64 @@
import { render, act, fireEvent, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ExternalRecipientConfirmHost } from '../external-recipient-confirm';
import { useExternalRecipientConfirmStore } from '@/stores/external-recipient-confirm-store';
import { resetSendPipeline } from '@/lib/outgoing/send-pipeline';
// The confirm dialog keeps its keys: the mail view's shortcuts listen on the
// window, and must not see Escape (close the message), j/k (move the list)
// or Enter (open a message) while the question is open. Real focus trap.
const RECIPIENTS = [{ address: 'org-dave@org-two.test', domain: 'org-two.test', field: 'to' as const }];
const pageKeys = vi.fn();
beforeEach(() => {
useExternalRecipientConfirmStore.setState({ pending: null, queue: [] });
window.addEventListener('keydown', pageKeys);
});
afterEach(() => {
window.removeEventListener('keydown', pageKeys);
pageKeys.mockReset();
cleanup();
resetSendPipeline();
});
function open() {
const view = render(<ExternalRecipientConfirmHost />);
let answer!: Promise<boolean>;
act(() => { answer = useExternalRecipientConfirmStore.getState().ask(RECIPIENTS); });
const back = view.container.querySelector('[data-external-recipient-back]') as HTMLButtonElement;
const send = view.container.querySelector('[data-external-recipient-send]') as HTMLButtonElement;
return { answer, back, send };
}
describe('keys in the external recipient confirm', () => {
it('puts the focus on Back and keeps shortcut keys from the page', () => {
const { back } = open();
expect(document.activeElement).toBe(back);
for (const key of ['j', 'k', 'Enter', 'Escape']) {
act(() => { fireEvent.keyDown(document.activeElement!, { key }); });
// Re-open after Escape and Enter answered it.
if (!useExternalRecipientConfirmStore.getState().pending) act(() => { void useExternalRecipientConfirmStore.getState().ask(RECIPIENTS); });
}
expect(pageKeys).not.toHaveBeenCalled();
});
it('answers Back on Escape', async () => {
const { answer } = open();
act(() => { fireEvent.keyDown(document.activeElement!, { key: 'Escape' }); });
expect(await answer).toBe(false);
});
it('presses the focused button on Enter and on Space', async () => {
const first = open();
first.send.focus();
act(() => { fireEvent.keyDown(first.send, { key: 'Enter' }); });
expect(await first.answer).toBe(true);
let second!: Promise<boolean>;
act(() => { second = useExternalRecipientConfirmStore.getState().ask(RECIPIENTS); });
act(() => { fireEvent.keyDown(document.activeElement!, { key: ' ' }); });
// The focus went back to the first button, Back.
expect(await second).toBe(false);
});
});
@@ -0,0 +1,106 @@
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';
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)');
});
});
describe('a message of another signed-in account (unified inbox)', () => {
function signInDave(daveScope: Record<string, unknown> | 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(<ExternalSenderChip from={[{ email: 'colleague@org-two.test' }]} accountId="acc-dave" />);
const alice = render(<ExternalSenderChip from={[{ email: 'alice@example.org' }]} accountId="acc-dave" />);
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(<ExternalSenderChip from={[{ email: 'someone@elsewhere.example' }]} accountId="acc-dave" />);
// 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(<ExternalSenderChip from={[{ email: 'someone@elsewhere.example' }]} />);
expect(active.container.querySelector('[data-external-sender-chip]')).not.toBeNull();
});
});
@@ -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 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');
});
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 from={[{ email: "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();
});
});
@@ -0,0 +1,92 @@
import { render, act, fireEvent, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
import { clearAllHooks } from '@/lib/plugin-hooks';
// A quick reply that a pre-send question stopped (Back on the external
// recipient confirm) must keep the typed text; one that went out clears it.
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 mailboxes = [
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
] as unknown as Mailbox[];
const message = {
id: 'm1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
from: [{ name: 'Dave', email: 'org-dave@org-two.test' }], to: [{ name: '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/plain' }], htmlBody: [{ partId: '1', type: 'text/plain' }], attachments: [],
bodyValues: { '1': { value: 'Plain body' } },
} as unknown as Email;
beforeEach(() => clearAllHooks());
async function typeAndSend(onQuickReply: (body: string) => Promise<boolean | void>) {
render(<EmailViewer email={message} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onQuickReply={onQuickReply} />);
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(onQuickReply).toHaveBeenCalledWith('My answer'));
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
return box;
}
describe('quick reply', () => {
it('keeps the text when nothing was sent', async () => {
const box = await typeAndSend(vi.fn(async () => false));
expect(box.value).toBe('My answer');
});
it('clears the text after a send', async () => {
const box = await typeAndSend(vi.fn(async () => true));
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(<EmailViewer email={message} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox"
onQuickReply={onQuickReply} onSaveQuickReplyDraft={onSaveQuickReplyDraft} />);
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);
});
});
+5 -1
View File
@@ -3348,9 +3348,13 @@ export function EmailComposer({
surface: 'composer',
to: toAddresses.map(r => r.email),
cc: ccAddresses.map(r => r.email),
bcc: bccAddresses.map(r => r.email),
// The identity's automatic Bcc (Identity.bcc) is the user's own setup,
// not a recipient they picked: checks see only the typed Bcc.
bcc: expandRecipients(withInput(bcc, bccInput)).map(r => r.email),
fromEmail: (fromOverrideEnabled && fromOverrideEmail.trim()) || currentIdentity?.email || undefined,
identityId: currentIdentity?.id,
identityEmail: currentIdentity?.email || undefined,
localAccountId: currentIdentityParts.localAccountId ?? undefined,
mode,
plainText: plainTextMode,
forwardedSeparator: tQuote('forwarded_separator'),
+23 -6
View File
@@ -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";
@@ -115,6 +116,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";
@@ -175,7 +177,8 @@ interface EmailViewerProps {
onMarkAsRead?: (emailId: string, read: boolean, opts?: { automatic?: boolean }) => void;
onSetTag?: (emailId: string, tagId: string | null) => void;
onDownloadAttachment?: (blobId: string, name: string, type?: string, forceDownload?: boolean, source?: EmailBlobContext) => void;
onQuickReply?: (body: string) => Promise<void>;
/** Resolves false when nothing was sent (the user went back), so the text stays. */
onQuickReply?: (body: string) => Promise<boolean | void>;
/** Keeps the typed quick reply as a draft before an automatic sign-out (lib/compose-flush-registry.ts). */
onSaveQuickReplyDraft?: (body: string) => Promise<void>;
onMarkAsSpam?: () => void;
@@ -857,6 +860,7 @@ export function MvExpandedConversationCard({
{!compact && sender?.name && sender.email && (
<span className="text-xs text-muted-foreground truncate">&lt;{sender.email}&gt;</span>
)}
<ExternalSenderChip from={email.from} accountId={email.sourceClientAccountId} 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>
@@ -1374,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;
@@ -1382,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]);
@@ -1390,9 +1401,12 @@ export function EmailViewer({
if (!quickReplyText.trim() || !onQuickReply || isSendingQuickReply) return;
setIsSendingQuickReply(true);
try {
await onQuickReply(quickReplyText);
setQuickReplyText("");
setIsQuickReplyFocused(false);
const sent = await onQuickReply(quickReplyText);
// Back on a pre-send question keeps what the user typed.
if (sent !== false) {
setQuickReplyText("");
setIsQuickReplyFocused(false);
}
} catch (error) {
console.error("Failed to send quick reply:", error);
// The text stays in the box, so say why it did not go out.
@@ -5062,6 +5076,7 @@ export function EmailViewer({
{mvViewer && sender?.email && sender?.name && (
<span className="text-xs text-muted-foreground truncate">&lt;{sender.email}&gt;</span>
)}
<ExternalSenderChip from={email.from} accountId={email.sourceClientAccountId} />
<EmailIdentityBadge email={email} identities={identities} />
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
<UnsubscribeBanner
@@ -5351,6 +5366,7 @@ export function EmailViewer({
) : (
<span className="text-sm font-semibold text-foreground">{t('unknown_sender')}</span>
)}
<ExternalSenderChip from={email.from} accountId={email.sourceClientAccountId} />
<EmailIdentityBadge email={email} identities={identities} />
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
<UnsubscribeBanner
@@ -5538,6 +5554,7 @@ export function EmailViewer({
onViewContact={handleViewContactSidebar}
className="text-sm text-start"
/>
<ExternalSenderChip from={email.from} accountId={email.sourceClientAccountId} />
{senderVerification && (
<span
className={cn(
@@ -0,0 +1,101 @@
'use client';
import { useEffect, useId, type KeyboardEvent } from 'react';
import { useTranslations } from 'next-intl';
import { Building } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { useFocusTrap } from '@/hooks/use-focus-trap';
import { registerExternalRecipientCheck } from '@/lib/outgoing/external-recipients';
import { useExternalRecipientConfirmStore } from '@/stores/external-recipient-confirm-store';
/**
* "Send outside your organisation?", asked once per send with every
* external recipient listed. Mounted once in the app layout; while it is
* mounted the pre-send check is registered, so the composer and quick reply
* both ask through it. Sits above the composer like the plugin dialogs.
*/
export function ExternalRecipientConfirmHost() {
const t = useTranslations('external_mail');
const pending = useExternalRecipientConfirmStore((s) => s.pending);
const answer = useExternalRecipientConfirmStore((s) => s.answer);
const id = useId();
useEffect(() => registerExternalRecipientCheck(), []);
// A send can't be waiting on a dialog that is gone.
useEffect(() => () => useExternalRecipientConfirmStore.getState().cancelAll(), []);
// Escape is handled below with the other keys, not by the trap: one key, one answer.
const dialogRef = useFocusTrap({ isActive: !!pending, restoreFocus: true });
// Keys stay in the dialog: Escape must not also close the message behind
// it, j/k must not move the list, Enter must not open a message. Enter and
// Space press the focused button here, whatever a page listener did.
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
e.stopPropagation();
if (e.key === 'Escape') {
e.preventDefault();
answer(false);
return;
}
if ((e.key === 'Enter' || e.key === ' ') && e.target instanceof HTMLButtonElement) {
e.preventDefault();
e.target.click();
}
};
if (!pending) return null;
const { recipients } = pending;
const field = { to: t('field_to'), cc: t('field_cc'), bcc: t('field_bcc') };
return (
<div
className="fixed inset-0 z-[100000] flex items-center justify-center bg-black/50 p-4 backdrop-blur-[1px] animate-in fade-in duration-150"
onMouseDown={(e) => {
if (e.target === e.currentTarget) answer(false);
}}
onKeyDown={onKeyDown}
>
<div
ref={dialogRef}
role="alertdialog"
aria-modal="true"
aria-labelledby={`${id}-title`}
aria-describedby={`${id}-message`}
data-external-recipient-confirm=""
className="flex max-h-[90vh] w-full max-w-md flex-col overflow-hidden rounded-lg border border-border bg-background text-foreground shadow-xl animate-in zoom-in-95 duration-200"
>
<div className="flex min-h-0 flex-1 flex-col px-5 pt-5 sm:px-6 sm:pt-6">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-warning/15 text-warning">
<Building className="h-5 w-5" aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<h2 id={`${id}-title`} className="text-lg font-semibold text-foreground">{t('confirm_title')}</h2>
<p id={`${id}-message`} className="mt-1 text-sm text-muted-foreground">
{t('confirm_message', { count: recipients.length })}
</p>
</div>
</div>
<ul className="mt-3 min-h-0 flex-1 space-y-1 overflow-y-auto rounded-md border border-border bg-muted/30 p-2 text-sm" data-external-recipients="">
{recipients.map((recipient) => (
<li key={recipient.address} className="flex items-baseline gap-2">
<span className="w-8 flex-shrink-0 text-xs text-muted-foreground">{field[recipient.field]}</span>
<span className="min-w-0 [overflow-wrap:anywhere]">{recipient.address}</span>
</li>
))}
</ul>
</div>
<div className="flex flex-wrap items-center justify-end gap-2 px-5 py-4 sm:px-6">
{/* Back comes first, so the focus trap puts the focus on it: sending outside takes a deliberate click. */}
<Button variant="outline" onClick={() => answer(false)} data-external-recipient-back="">
{t('back')}
</Button>
<Button onClick={() => answer(true)} data-external-recipient-send="">
{t('send_anyway')}
</Button>
</div>
</div>
</div>
);
}
+157
View File
@@ -0,0 +1,157 @@
'use client';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Building } from '@/components/icons';
import { cn } from '@/lib/utils';
import {
externalMailContextFor,
externalSenders,
internalDomains,
isOtherAccount,
type ExternalMailContext,
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 context of the account a message belongs to: the active one from the
* stores, another signed-in one (the unified inbox) resolved for that
* account, so its own scopes and identities decide.
*/
function useAccountContext(accountId: string | null | undefined): ExternalMailContext | null {
const externalMail = usePolicyStore((s) => 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 (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]);
}
/**
* "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({ 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, accountId);
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')}
</>
);
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>
);
}
@@ -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 from={email.from} accountId={email.sourceClientAccountId} interactive={false} />
{isStarred && (
<Star className="w-4 h-4 fill-amber-400 text-amber-400 flex-shrink-0" />
)}
+8 -5
View File
@@ -3858,8 +3858,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
await saveQuickReplyDraft({ client, email: selectedEmail, identities, body, replyPrefix: t('email_composer.prefix.reply') });
};
const handleQuickReply = async (body: string) => {
if (!client || !selectedEmail) return;
// Resolves true once the reply went out (or was scheduled), false when it
// stopped before sending, so the quick-reply box keeps the text.
const handleQuickReply = async (body: string): Promise<boolean> => {
if (!client || !selectedEmail) return false;
// Quick reply follows the composer's addressing rules and sends from the
// address the message was delivered to (lib/outgoing/quick-reply-envelope.ts).
@@ -3888,7 +3890,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
fromEmail: headerFromEmail,
identityId: sendingIdentity?.id,
});
if (!prepared) return;
if (!prepared) return false;
const { html: finalHtmlBody, text: finalBody } = prepared;
const originalEmailId = selectedEmail.id;
@@ -3898,7 +3900,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (sendDelaySeconds > 0) {
if (!client.hasDelayedSend()) {
const confirmed = window.confirm(t('email_composer.send_delay_unsupported_confirm'));
if (!confirmed) return;
if (!confirmed) return false;
} else {
delayedUntil = new Date(Date.now() + sendDelaySeconds * 1000).toISOString();
}
@@ -3948,7 +3950,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (result.scheduled) {
await refreshScheduledMetadata(client);
return;
return true;
}
if (!refused) toast.success(t('notifications.email_sent'));
@@ -3990,6 +3992,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
} catch (error) {
console.error('Quick reply sent, but refreshing the view failed:', error);
}
return true;
};
// Mountain View reads a message inside its conversation, as Gmail does: load
@@ -0,0 +1,63 @@
import { render, renderHook, act, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { usePolicyStore } from '@/stores/policy-store';
import { clearSlotOffers, offerSlots } from './fake-slot-offer';
vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
const { PluginSlot } = await import('../plugin-slot');
// The retired external-mail-warning plugin's banner stays away while the
// organisation's own External label or recipient warning is on, so the
// reader doesn't show both; other plugins' banners stay.
function setExternalMail(patch: Partial<typeof DEFAULT_ORGANISATION_POLICY.externalMail>) {
usePolicyStore.setState({
policy: {
...DEFAULT_POLICY,
organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } },
},
});
}
const frames = (container: HTMLElement) =>
[...container.querySelectorAll('[data-testid="plugin-frame-email-banner"]')].map((el) => el.getAttribute('data-plugin-id'));
beforeEach(() => {
setExternalMail({});
offerSlots('external-mail-warning', ['email-banner']);
offerSlots('spam-score', ['email-banner']);
});
afterEach(() => {
cleanup();
clearSlotOffers();
});
describe('email-banner slot while core external warnings are on', () => {
it('shows the plugin banner until the sender label is on, whatever the recipient warning', () => {
const { container } = render(<PluginSlot name="email-banner" />);
expect(frames(container).sort()).toEqual(['external-mail-warning', 'spam-score']);
act(() => setExternalMail({ recipientWarning: true }));
expect(frames(container).sort()).toEqual(['external-mail-warning', 'spam-score']);
act(() => setExternalMail({ senderLabel: true }));
expect(frames(container)).toEqual(['spam-score']);
act(() => setExternalMail({}));
expect(frames(container).sort()).toEqual(['external-mail-warning', 'spam-score']);
});
});
describe('usePluginSlotOffers', () => {
// The reader and the conversation view build the plugin's view of a message
// only when some offer is left, so a hidden banner costs nothing.
it('leaves out the hidden banner offer, so nothing is built for it', async () => {
const { usePluginSlotOffers } = await import('@/hooks/use-plugin-slot-offers');
clearSlotOffers();
offerSlots('external-mail-warning', ['email-banner']);
const { result } = renderHook(() => usePluginSlotOffers('email-banner'));
expect(result.current.map((o) => o.pluginId)).toEqual(['external-mail-warning']);
act(() => setExternalMail({ senderLabel: true }));
expect(result.current).toEqual([]);
});
});
+4 -4
View File
@@ -1,10 +1,10 @@
'use client';
import React, { useSyncExternalStore } from 'react';
import React from 'react';
import type { SlotName } from '@/lib/plugin-types';
import { offersForSlot, subscribe } from '@/lib/plugin-sandbox/registry';
import { PluginIframeSlot } from './plugin-iframe-slot';
import { isInlineSlot } from '@/lib/plugin-sandbox/slot-layout';
import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
import { cn } from '@/lib/utils';
interface PluginSlotProps {
@@ -14,8 +14,8 @@ interface PluginSlotProps {
}
export function PluginSlot({ name, className, extraProps }: PluginSlotProps) {
const getSnapshot = () => offersForSlot(name);
const offers = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
// Without a retired plugin's offer while Bulwark does its job (host-features.ts).
const offers = usePluginSlotOffers(name);
if (offers.length === 0) return null;
+14 -2
View File
@@ -3,12 +3,24 @@
// Reactive accessor for sandboxed plugin slot offers. Components that gate
// layout on the *presence* of a plugin-supplied slot (e.g. a detail
// sidebar) read from here instead of the legacy `usePluginStore.slots` map.
//
// These are the offers the host shows: a retired plugin's offer is left out
// while Bulwark does its job itself (lib/plugin-sandbox/host-features.ts), so
// nothing is built for a slot nobody will see.
import { useSyncExternalStore } from 'react';
import { useMemo, useSyncExternalStore } from 'react';
import type { SlotName } from '@/lib/plugin-types';
import { offersForSlot, subscribe } from '@/lib/plugin-sandbox/registry';
import { hostFeaturesFor, isSlotHandedOver } from '@/lib/plugin-sandbox/host-features';
import { usePolicyStore } from '@/stores/policy-store';
export function usePluginSlotOffers(slotName: SlotName) {
const getSnapshot = () => offersForSlot(slotName);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const offers = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const externalMail = usePolicyStore((s) => s.policy.organisation?.externalMail);
return useMemo(() => {
const features = hostFeaturesFor(externalMail);
const shown = offers.filter((offer) => !isSlotHandedOver(offer.pluginId, slotName, features));
return shown.length === offers.length ? offers : shown;
}, [offers, slotName, externalMail]);
}
@@ -21,6 +21,11 @@ const CROSS_ACCOUNT_IDENTITY_DELIMITER = '::';
// still refetches.
const remoteIdentityCache = new Map<string, Identity[]>();
/** 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();
+360
View File
@@ -0,0 +1,360 @@
import { test, expect, type Page } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { JmapClient } from './helpers/jmap';
import { addRecipient, emailItem, openComposer, setSubject } from './helpers/app';
import {
ORG,
ORG_PASSWORD,
adminContext,
getPolicy,
putPolicy,
requireOrgEnv,
shot,
userPage,
} from './helpers/org';
/**
* External sender label and external recipient warning (organisation
* feature 2), through the real UI on a VM 106 test server. Test mail is
* delivered over JMAP between the org-* accounts (SMTP on the VM doesn't
* work). The admin's policy is put back afterwards.
*/
test.describe.configure({ mode: 'serial' });
const RUN = Date.now().toString(36);
const FROM_DAVE = `External check from dave ${RUN}`;
const FROM_BOB = `Internal check from bob ${RUN}`;
// The quick reply turns FROM_DAVE into a thread whose latest message is
// alice's own; the later checks read this one.
const FROM_DAVE_2 = `Second external check from dave ${RUN}`;
let original: Record<string, unknown>;
/** Sends a plain message from one org account to another, the way a mail app would. */
async function deliver(from: string, to: string, subject: string): Promise<void> {
const client = await JmapClient.connect(from, ORG_PASSWORD);
const [drafts, sent] = await Promise.all([client.mailboxByRole('drafts'), client.mailboxByRole('sent')]);
const identity = (await client.identities()).find((i) => i.email === from);
if (!drafts || !sent || !identity) throw new Error(`${from}: no drafts, sent or identity`);
const r = await client.request([
['Email/set', {
accountId: client.accountId,
create: {
m: {
mailboxIds: { [drafts.id]: true },
keywords: { $draft: true, $seen: true },
from: [{ email: from }],
to: [{ email: to }],
subject,
bodyValues: { b: { value: `Test message for the external mail spec (${RUN}).` } },
textBody: [{ partId: 'b', type: 'text/plain' }],
},
},
}, '0'],
['EmailSubmission/set', {
accountId: client.accountId,
create: { s: { identityId: identity.id, emailId: '#m' } },
onSuccessUpdateEmail: { '#s': { [`mailboxIds/${drafts.id}`]: null, [`mailboxIds/${sent.id}`]: true, 'keywords/$draft': null } },
}, '1'],
]);
const submission = r.methodResponses[1][1];
if (!submission.created?.s) throw new Error(`${from}: submission failed ${JSON.stringify(r.methodResponses)}`);
const receiver = await JmapClient.connect(to, ORG_PASSWORD);
await receiver.waitForEmail(subject, { timeoutMs: 30000 });
}
/** Polls until the account has a message with exactly this subject (the subject filter doesn't find "Re:" subjects). */
async function waitForSubject(account: string, subject: string, timeoutMs = 60000): Promise<void> {
const client = await JmapClient.connect(account, ORG_PASSWORD);
const deadline = Date.now() + timeoutMs;
for (;;) {
const r = await client.request([
['Email/query', { accountId: client.accountId, filter: { text: RUN }, limit: 50 }, '0'],
['Email/get', { accountId: client.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['subject'] }, '1'],
]);
const subjects = (r.methodResponses[1][1].list as Array<{ subject: string }>).map((e) => e.subject);
if (subjects.includes(subject)) return;
if (Date.now() > deadline) throw new Error(`${account} has no "${subject}" (has: ${subjects.join(' | ')})`);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
async function openMessage(page: Page, subject: string): Promise<void> {
const row = emailItem(page, subject).first();
await expect(row).toBeVisible({ timeout: 30000 });
await row.click();
await expect(page.getByRole('heading', { name: subject }).first()).toBeVisible({ timeout: 15000 });
}
const chip = (page: Page) => page.locator('[data-external-sender-chip]:visible');
const confirm = (page: Page) => page.locator('[data-external-recipient-confirm]');
async function composeTo(page: Page, to: string, subject: string): Promise<void> {
await openComposer(page);
await addRecipient(page, to);
await setSubject(page, subject);
// An empty body keeps Send disabled.
await page.locator('[data-testid="email-composer"] [contenteditable="true"]').first().click();
await page.keyboard.type('Hello');
// Send with the shortcut: the recipient autocomplete can cover the button.
await page.locator('[data-testid="composer-subject"]').first().focus();
await page.keyboard.press('Control+Enter');
}
test.beforeAll(async ({ browser }) => {
requireOrgEnv();
const admin = await adminContext(browser);
original = await getPolicy(admin);
// Start from defaults: no scopes, both switches off.
await putPolicy(admin, { ...original, scopes: [], organisation: {} });
await admin.close();
await deliver(ORG.dave, ORG.alice, FROM_DAVE);
await deliver(ORG.bob, ORG.alice, FROM_BOB);
await deliver(ORG.dave, ORG.alice, FROM_DAVE_2);
});
test.afterAll(async ({ browser }) => {
const admin = await adminContext(browser);
await putPolicy(admin, original);
await admin.close();
});
test('with both switches off nothing is labelled or asked', async ({ browser }) => {
const alice = await userPage(browser, ORG.alice);
await openMessage(alice, FROM_DAVE);
await expect(chip(alice)).toHaveCount(0);
await composeTo(alice, ORG.dave, `Off ${RUN}`);
await expect(alice.locator('[data-testid="email-composer"]')).toBeHidden({ timeout: 30000 });
await expect(confirm(alice)).toHaveCount(0);
await alice.context().close();
});
test('the admin turns both on in Organisation > Security', async ({ browser }) => {
const admin = await adminContext(browser);
const page = await admin.newPage();
await page.goto('/admin/org-security');
await expect(page.getByRole('heading', { name: 'External mail' })).toBeVisible();
await page.getByTestId('org-external-sender-label').click();
await page.getByTestId('org-external-recipient-warning').click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('Saved.')).toBeVisible();
await page.locator('[data-settings-section="external"]').scrollIntoViewIfNeeded();
await shot(page, 'external-admin-section');
const policy = await getPolicy(admin);
expect((policy.organisation as { externalMail: unknown }).externalMail).toMatchObject({ senderLabel: true, recipientWarning: true, trustContacts: true });
await admin.close();
});
test('mail from org-dave is labelled External, mail from org-bob is not', async ({ browser }) => {
const alice = await userPage(browser, ORG.alice);
await openMessage(alice, FROM_DAVE);
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);
await alice.context().close();
});
test('sending to org-dave asks first, sending to org-bob does not', async ({ browser }) => {
const alice = await userPage(browser, ORG.alice);
await composeTo(alice, ORG.dave, `Asks ${RUN}`);
await expect(confirm(alice)).toBeVisible();
await expect(confirm(alice).locator('[data-external-recipients]')).toHaveText(/org-dave@org-two\.test/);
await shot(alice, 'external-recipient-confirm');
// Back keeps the message open and unsent.
await confirm(alice).locator('[data-external-recipient-back]').click();
await expect(confirm(alice)).toHaveCount(0);
await expect(alice.locator('[data-testid="email-composer"]')).toBeVisible();
await alice.keyboard.press('Escape');
await alice.getByRole('button', { name: 'Discard', exact: true }).last().click();
await expect(alice.locator('[data-testid="email-composer"]')).toBeHidden();
await composeTo(alice, ORG.bob, `Internal ${RUN}`);
await expect(alice.locator('[data-testid="email-composer"]')).toBeHidden({ timeout: 30000 });
await expect(confirm(alice)).toHaveCount(0);
await alice.context().close();
});
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}`);
await alice.context().close();
});
test('a scope for org-two.test with the label off applies to org-dave only', async ({ browser }) => {
const admin = await adminContext(browser);
const policy = await getPolicy(admin);
await putPolicy(admin, {
...policy,
scopes: [{ id: 'org-two', label: 'Org two', match: { domains: ['org-two.test'] }, policy: { organisation: { externalMail: { senderLabel: false } } } }],
});
// The scope shows on the admin page as an override.
const page = await admin.newPage();
await page.goto('/admin/org-security');
await page.getByTestId('org-scope-bar').getByRole('button', { name: 'Org two' }).click();
await expect(page.getByTestId('org-external-sender-label')).toHaveAttribute('aria-checked', 'false');
await expect(page.getByText('Everyone: on').first()).toBeVisible();
await shot(page, 'external-admin-scope');
await admin.close();
// org-dave reads mail from outside org-two.test: no label for that domain.
await deliver(ORG.alice, ORG.dave, `From alice ${RUN}`);
const dave = await userPage(browser, ORG.dave);
await openMessage(dave, `From alice ${RUN}`);
await expect(chip(dave)).toHaveCount(0);
await dave.context().close();
// org-alice keeps it.
const alice = await userPage(browser, ORG.alice);
await openMessage(alice, FROM_DAVE_2);
await expect(chip(alice).first()).toBeVisible();
await alice.context().close();
});
test('the label and the confirm fit a phone', async ({ browser }) => {
const alice = await userPage(browser, ORG.alice, true);
await openMessage(alice, FROM_DAVE_2);
await expect(chip(alice).first()).toBeVisible();
const overflow = await alice.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
expect(overflow).toBeLessThanOrEqual(0);
const box = await chip(alice).first().boundingBox();
expect(box && box.x + box.width).toBeLessThanOrEqual(390);
await shot(alice, 'external-sender-chip-phone');
await alice.goBack().catch(() => {});
await composeTo(alice, ORG.dave, `Phone ${RUN}`);
await expect(confirm(alice)).toBeVisible();
const dialog = await confirm(alice).boundingBox();
expect(dialog && dialog.x).toBeGreaterThanOrEqual(0);
expect(dialog && dialog.x + dialog.width).toBeLessThanOrEqual(390);
await shot(alice, 'external-recipient-confirm-phone');
await confirm(alice).locator('[data-external-recipient-back]').click();
await alice.context().close();
});
test('Organisation > Security fits a phone', async ({ browser }) => {
const admin = await adminContext(browser, true);
const page = await admin.newPage();
await page.goto('/admin/org-security');
await expect(page.getByTestId('org-external-sender-label')).toBeVisible();
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
expect(overflow).toBeLessThanOrEqual(0);
await page.getByTestId('org-external-domains').scrollIntoViewIfNeeded();
await shot(page, 'external-admin-section-phone');
await admin.close();
});
// ─── The retired External Mail Warning plugin ─────────────────────────────
// Needs the 1.5.1 package (from before the plugin knew api.host): set
// IT_EMW_ZIP to its path. It is installed for this test and removed again.
test.describe('an installed External Mail Warning 1.5.1', () => {
const zip = process.env.IT_EMW_ZIP;
test.skip(!zip, 'set IT_EMW_ZIP to the external-mail-warning 1.5.1 zip');
test.afterAll(async ({ browser }) => {
const admin = await adminContext(browser);
await admin.request.delete('/api/admin/plugins', { data: { id: 'external-mail-warning' } });
await admin.close();
});
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 });
const installed = await admin.request.post('/api/admin/plugins', {
multipart: { file: { name: 'external-mail-warning.zip', mimeType: 'application/zip', buffer: readFileSync(zip!) } },
});
expect(installed.ok(), await installed.text()).toBe(true);
const config = await admin.request.put('/api/admin/plugins/external-mail-warning/config', { data: { key: 'safeDomains', value: 'partner.example, other-partner.example' } });
expect(config.ok(), await config.text()).toBe(true);
// Core off, plugin on for everyone: the plugin asks, which shows it runs.
const policy = await getPolicy(admin);
const features = { ...(policy.features as Record<string, unknown>), pluginsEnabled: true };
await putPolicy(admin, {
...policy, features, forceEnabledPlugins: ['external-mail-warning'], scopes: [],
organisation: { externalMail: { senderLabel: false, recipientWarning: false } },
});
let alice = await userPage(browser, ORG.alice);
// The plugin starts after the sign-in; its frame is the sign that it runs.
await expect(alice.locator('iframe[src*="/plugin-sandbox"]').first()).toBeAttached({ timeout: 30000 });
await composeTo(alice, ORG.dave, `Plugin asks ${RUN}`);
const pluginDialog = alice.locator('[data-plugin-dialog="external-mail-warning"]');
await expect(pluginDialog).toBeVisible({ timeout: 30000 });
await expect(confirm(alice)).toHaveCount(0);
await pluginDialog.getByRole('button', { name: 'Cancel' }).click();
await alice.context().close();
// The admin page asks to update the plugin and imports its list.
await putPolicy(admin, { ...(await getPolicy(admin)), organisation: { externalMail: { senderLabel: true, recipientWarning: true } } });
const page = await admin.newPage();
await page.goto('/admin/org-security');
await expect(page.getByTestId('org-external-plugin-note')).toContainText('Update or remove the External Mail Warning plugin');
await page.getByTestId('org-external-import-plugin').click();
await expect(page.getByTestId('org-external-domains')).toHaveValue('partner.example\nother-partner.example');
await page.getByTestId('org-external-plugin-note').scrollIntoViewIfNeeded();
await shot(page, 'external-admin-plugin-note');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('Saved.')).toBeVisible();
expect(((await getPolicy(admin)).organisation as { externalMail: { domains: string[] } }).externalMail.domains)
.toEqual(['partner.example', 'other-partner.example']);
await page.close();
// 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)).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();
await expect(alice.locator('[data-testid="email-composer"]')).toBeHidden({ timeout: 30000 });
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();
});
});
+216
View File
@@ -0,0 +1,216 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
addressDomain,
addressKey,
contactAddresses,
activeExternalMailContext,
contextInternalDomains,
externalMailContextFor,
domainMatches,
externalRecipients,
externalSenders,
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(['me@example.org', 'alias@Example.org'], ['partner.com'])).toEqual(['example.org', 'partner.com']);
});
it('keeps the identity domains when the admin list fills the cap', () => {
const full = Array.from({ length: 500 }, (_, i) => `partner${i}.example`);
const internal = internalDomains(['me@own.example'], full);
expect(internal).toHaveLength(500);
expect(internal[0]).toBe('own.example');
});
});
describe('IDN domains', () => {
it('compares Unicode and xn-- forms as the same domain, on both sides', () => {
// The admin typed it in Unicode, the server delivers xn--.
const internal = internalDomains([], ['bücher.de']);
expect(internal).toEqual(['xn--bcher-kva.de']);
expect(isExternalAddress('anna@xn--bcher-kva.de', internal)).toBe(false);
expect(isExternalAddress('anna@bücher.de', internal)).toBe(false);
expect(isExternalAddress('anna@eu.bücher.de', internal)).toBe(false);
expect(isExternalAddress('anna@buecher.de', internal)).toBe(true);
// An identity on an IDN domain counts in whichever form the address has.
expect(isExternalAddress('x@münchen.example', internalDomains(['me@xn--mnchen-3ya.example'], []))).toBe(false);
});
it('matches a contact written in Unicode against an xn-- recipient', () => {
expect(addressKey('Anna@Bücher.de')).toBe(addressKey('anna@xn--bcher-kva.de'));
});
});
describe('isExternalAddress', () => {
const internal = internalDomains(['me@example.org'], ['partner.com']);
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('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([]);
});
});
describe('the active account', () => {
beforeEach(() => {
setExternalMail({});
useAuthStore.setState({ identities: [], activeAccountId: 'acc-alice' });
useContactStore.setState({ contacts: [], addressBooks: [], trustedSendersBookId: null });
});
it('reads the switches from the effective policy', () => {
expect(activeExternalMailContext().policy.senderLabel).toBe(false);
setExternalMail({ senderLabel: 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(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', () => {
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']);
});
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']);
});
});
@@ -0,0 +1,116 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createHash } from 'node:crypto';
import type { InstalledPlugin } from '../plugin-types';
// Copies of the external-mail-warning plugin older than the release that
// steps back on its own keep their send check. While the organisation's
// own warnings are on, the host skips that plugin's hooks by id, so the
// user isn't asked twice; other plugins keep theirs.
const mocks = vi.hoisted(() => ({
hooks: [] as string[],
invokeHook: null as null | ReturnType<typeof vi.fn>,
}));
const CODE = 'export default { activate() {} }';
const HASH = createHash('sha256').update(CODE, 'utf-8').digest('hex');
vi.mock('../plugin-storage', () => ({
pluginStorage: { getCode: async () => CODE, saveCode: async () => {}, deleteCode: vi.fn() },
}));
vi.mock('../plugin-sandbox/bundle-fetch', () => ({ downloadManagedBundle: vi.fn() }));
vi.mock('../plugin-sandbox/host-bridge', () => ({
createBackgroundInstance: () => {
// The old plugin's check: "Send to external recipients?" answered with Cancel.
const invokeHook = vi.fn(async () => false);
mocks.invokeHook = invokeHook;
return {
initPromise: Promise.resolve({ hooks: mocks.hooks, slots: [], shortcuts: [] }),
invokeHook,
destroy: vi.fn(),
};
},
snapshotRegionalSettings: vi.fn((locale: string) => ({ locale, dateLocale: locale, hour12: false, timeZone: 'UTC', firstDayOfWeek: 1 })),
forEachInstance: vi.fn(),
SandboxInstance: class {},
}));
vi.mock('../plugin-sandbox/host-api', () => ({ cancelPluginDialogs: vi.fn() }));
vi.mock('../plugin-sandbox/shortcuts', () => ({ registerShortcuts: () => () => {} }));
import { loadSandboxedPlugin, unloadSandboxedPlugin, setSandboxStoreAccessor } from '../plugin-sandbox/loader';
import { emailHooks } from '../plugin-hooks';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { usePolicyStore } from '@/stores/policy-store';
function plugin(id: string): InstalledPlugin {
return {
id, name: id, version: '1.5.1', author: 'someone', description: '', type: 'hook',
permissions: ['email:send', 'email:read'], entrypoint: 'index.js', enabled: true, status: 'enabled',
settings: {}, managed: true, adminApproved: true, bundleHash: HASH,
};
}
function setExternalMail(patch: Partial<typeof DEFAULT_ORGANISATION_POLICY.externalMail>) {
usePolicyStore.setState({
policy: {
...DEFAULT_POLICY,
organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } },
},
});
}
const OUTGOING = { to: ['dave@org-two.test'], cc: [], bcc: [], subject: 'Hi' };
let loaded: string | null = null;
beforeEach(() => {
setSandboxStoreAccessor({ setPluginStatus: () => {} });
mocks.hooks = ['onBeforeEmailSend', 'onNewEmailReceived'];
setExternalMail({});
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'info').mockImplementation(() => {});
});
afterEach(() => {
if (loaded) unloadSandboxedPlugin(loaded);
loaded = null;
vi.restoreAllMocks();
});
describe('the retired external-mail-warning plugin', () => {
it('asks as before while the core warnings are off', async () => {
loaded = 'external-mail-warning';
await loadSandboxedPlugin(plugin(loaded));
expect(await emailHooks.onBeforeEmailSend.interceptOutcome(OUTGOING as never)).toBe('vetoed');
expect(mocks.invokeHook).toHaveBeenCalledWith('onBeforeEmailSend', [OUTGOING]);
});
it('loses its send check to the recipient warning, and gets it back once that is off', async () => {
loaded = 'external-mail-warning';
await loadSandboxedPlugin(plugin(loaded));
setExternalMail({ recipientWarning: true });
expect(await emailHooks.onBeforeEmailSend.interceptOutcome(OUTGOING as never)).toBe('allowed');
expect(mocks.invokeHook).not.toHaveBeenCalled();
// The toast is about incoming mail: it stays while only the warning is on.
await emailHooks.onNewEmailReceived.emit({ from: [{ email: 'dave@org-two.test' }] } as never);
expect(mocks.invokeHook).toHaveBeenCalledWith('onNewEmailReceived', expect.anything());
setExternalMail({});
expect(await emailHooks.onBeforeEmailSend.interceptOutcome(OUTGOING as never)).toBe('vetoed');
});
it('loses its new-mail toast to the sender label but keeps its send check', async () => {
loaded = 'external-mail-warning';
await loadSandboxedPlugin(plugin(loaded));
setExternalMail({ senderLabel: true });
await emailHooks.onNewEmailReceived.emit({ from: [{ email: 'dave@org-two.test' }] } as never);
expect(mocks.invokeHook).not.toHaveBeenCalled();
expect(await emailHooks.onBeforeEmailSend.interceptOutcome(OUTGOING as never)).toBe('vetoed');
});
it('leaves the send checks of other plugins alone', async () => {
loaded = 'reply-all-guardrail';
await loadSandboxedPlugin(plugin(loaded));
setExternalMail({ recipientWarning: true });
expect(await emailHooks.onBeforeEmailSend.interceptOutcome(OUTGOING as never)).toBe('vetoed');
});
});
+148
View File
@@ -0,0 +1,148 @@
/**
* api.host.features tells plugins which of Bulwark's own features replace
* them (externalSenderLabel and externalRecipientWarning: the
* organisation's External label and recipient warning). The host sends it with init and again on every
* change; the runtime keeps one object up to date.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { render, cleanup, waitFor } from '@testing-library/react';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { usePolicyStore } from '@/stores/policy-store';
import {
EXTERNAL_MAIL_WARNING_PLUGIN_ID,
isHookHandedOver,
isSlotHandedOver,
snapshotHostFeatures,
} from '@/lib/plugin-sandbox/host-features';
function setExternalMail(patch: Partial<typeof DEFAULT_ORGANISATION_POLICY.externalMail>) {
usePolicyStore.setState({
policy: {
...DEFAULT_POLICY,
organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } },
},
});
}
beforeEach(() => setExternalMail({}));
describe('snapshotHostFeatures', () => {
it('has one flag per switch', () => {
expect(snapshotHostFeatures()).toEqual({ externalSenderLabel: false, externalRecipientWarning: false });
setExternalMail({ senderLabel: true });
expect(snapshotHostFeatures()).toEqual({ externalSenderLabel: true, externalRecipientWarning: false });
setExternalMail({ recipientWarning: true });
expect(snapshotHostFeatures()).toEqual({ externalSenderLabel: false, externalRecipientWarning: true });
});
});
describe('hand-over of the external-mail-warning plugin', () => {
it('skips its send check with the recipient warning and its toast and banner with the label', () => {
const id = EXTERNAL_MAIL_WARNING_PLUGIN_ID;
expect(isHookHandedOver(id, 'onBeforeEmailSend')).toBe(false);
expect(isSlotHandedOver(id, 'email-banner')).toBe(false);
setExternalMail({ recipientWarning: true });
expect(isHookHandedOver(id, 'onBeforeEmailSend')).toBe(true);
expect(isHookHandedOver(id, 'onNewEmailReceived')).toBe(false);
expect(isSlotHandedOver(id, 'email-banner')).toBe(false);
setExternalMail({ senderLabel: true });
expect(isHookHandedOver(id, 'onBeforeEmailSend')).toBe(false);
expect(isHookHandedOver(id, 'onNewEmailReceived')).toBe(true);
expect(isSlotHandedOver(id, 'email-banner')).toBe(true);
// Its other hooks, and every other plugin, keep running.
expect(isHookHandedOver(id, 'onIdentitiesLoaded')).toBe(false);
expect(isHookHandedOver('reply-all-guardrail', 'onBeforeEmailSend')).toBe(false);
expect(isSlotHandedOver('spam-score', 'email-banner')).toBe(false);
});
});
// ─── Host bridge: init payload and pushes ────────────────────
describe('host bridge', () => {
afterEach(() => { document.body.innerHTML = ''; });
it('puts the features into the init payload and pushes changes to running frames', async () => {
const { createBackgroundInstance } = await import('@/lib/plugin-sandbox/host-bridge');
setExternalMail({ senderLabel: true });
const plugin = { id: 'p', name: 'P', version: '1.0.0', permissions: [], settings: {}, settingsSchema: {} } as never;
const instance = createBackgroundInstance({ plugin, tier: 'privileged', code: '', locale: 'en' });
const pending = (instance as unknown as { pendingInit: { hostFeatures?: unknown } | null }).pendingInit;
expect(pending?.hostFeatures).toEqual({ externalSenderLabel: true, externalRecipientWarning: false });
// Before init has gone out a change lands in the payload ...
setExternalMail({});
expect(pending?.hostFeatures).toEqual({ externalSenderLabel: false, externalRecipientWarning: false });
// ... after it, as a host-change message.
const sent: unknown[] = [];
(instance as unknown as { pendingInit: unknown }).pendingInit = null;
(instance as unknown as { send: (m: unknown) => void }).send = (m) => sent.push(m);
setExternalMail({ recipientWarning: true });
expect(sent).toEqual([{ type: 'host-change', features: { externalSenderLabel: false, externalRecipientWarning: true } }]);
instance.destroy();
}, 30_000);
});
// ─── Sandbox runtime: api.host.features ──────────────────────
const HOST_ORIGIN = window.location.origin;
function post(source: unknown, origin: string, data: unknown): void {
const ev = new MessageEvent('message', { data, origin });
Object.defineProperty(ev, 'source', { value: source });
window.dispatchEvent(ev);
}
describe('api.host in the sandbox runtime', () => {
let hostWindow: { postMessage: ReturnType<typeof vi.fn> };
const realParent = Object.getOwnPropertyDescriptor(window, 'parent');
const g = globalThis as {
__pluginApi?: { host: { features: { externalSenderLabel: boolean; externalRecipientWarning: boolean }; onFeaturesChange(fn: (f: unknown) => void): () => void } };
__seenAtActivate?: boolean;
};
beforeEach(() => {
vi.resetModules();
hostWindow = { postMessage: vi.fn() };
Object.defineProperty(window, 'parent', { configurable: true, get: () => hostWindow as unknown as Window });
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
delete g.__pluginApi;
delete g.__seenAtActivate;
if (realParent) Object.defineProperty(window, 'parent', realParent);
});
it('has the value from init in activate and follows host-change', async () => {
const bundle = `module.exports = { activate: function (api) {
globalThis.__seenAtActivate = api.host.features.externalRecipientWarning;
globalThis.__pluginApi = api;
} };`;
const { SandboxRuntime } = await import('@/lib/plugin-sandbox/runtime');
render(React.createElement(SandboxRuntime));
post(hostWindow, HOST_ORIGIN, {
type: 'init',
payload: {
mode: 'background', pluginId: 'x', tier: 'privileged', locale: 'en',
manifest: { id: 'x', version: '1.0.0', permissions: [], settings: {} },
code: bundle,
hostFeatures: { externalSenderLabel: false, externalRecipientWarning: true },
},
});
await waitFor(() => expect(g.__pluginApi).toBeDefined());
expect(g.__seenAtActivate).toBe(true);
const api = g.__pluginApi!;
const kept = api.host.features;
const changes: unknown[] = [];
api.host.onFeaturesChange((f) => changes.push({ ...(f as object) }));
post(hostWindow, HOST_ORIGIN, { type: 'host-change', features: { externalSenderLabel: true, externalRecipientWarning: false } });
expect(kept.externalRecipientWarning).toBe(false);
expect(kept.externalSenderLabel).toBe(true);
expect(changes).toEqual([{ externalSenderLabel: true, externalRecipientWarning: false }]);
}, 30_000);
});
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
DEFAULT_ORGANISATION_POLICY,
normalizeDomainList,
parseDomainList,
sanitizeOrganisationPolicy,
} from '@/lib/admin/organisation-policy';
import { applyLitePolicy } from '@/lib/lite-config';
@@ -32,6 +33,14 @@ describe('sanitizeOrganisationPolicy', () => {
expect(normalizeDomainList(['A.org', 'a.org', 'x'])).toEqual(['a.org']);
expect(normalizeDomainList(42)).toBeUndefined();
});
it('takes IDN domains in ASCII form and names what it left out', () => {
expect(parseDomainList('Bücher.de, xn--bcher-kva.de, @partner.example, *.sub.example\nnot a domain, x')).toEqual({
domains: ['xn--bcher-kva.de', 'partner.example', 'sub.example'],
rejected: ['not', 'a', 'domain', 'x'],
});
expect(parseDomainList(['bad_domain.example', 'ok.example'])).toEqual({ domains: ['ok.example'], rejected: ['bad_domain.example'] });
});
});
describe('applyLitePolicy', () => {
+30 -7
View File
@@ -11,6 +11,7 @@
*/
import type { EmailTemplate } from '@/lib/template-types';
import { toAsciiDomain } from '@/lib/idn';
/** Details a user fills in once for the organisation signature. */
export const SIGNATURE_FIELDS = ['title', 'department', 'phone', 'mobile'] as const;
@@ -25,7 +26,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;
@@ -97,18 +102,36 @@ function count(value: unknown, max: number): number | undefined {
return Math.min(max, Math.max(0, Math.round(value)));
}
/** Lowercased, trimmed, deduplicated domains; "@example.com" and "*.example.com" are taken as example.com. */
export function normalizeDomainList(value: unknown): string[] | undefined {
/**
* A domain list taken apart: the usable domains, lowercased, deduplicated
* and in ASCII form (an IDN like bücher.de becomes xn--bcher-kva.de, the
* form mail servers use), and the entries that aren't domains.
* "@example.com" and "*.example.com" are taken as example.com. Undefined for
* anything that isn't a string or an array.
*/
export function parseDomainList(value: unknown): { domains: string[]; rejected: string[] } | undefined {
const raw = typeof value === 'string' ? value.split(/[\s,;]+/) : Array.isArray(value) ? value : undefined;
if (!raw) return undefined;
const out = new Set<string>();
const rejected: string[] = [];
for (const item of raw) {
if (typeof item !== 'string') continue;
const domain = item.trim().toLowerCase().replace(/^\*\./, '').replace(/^@/, '').replace(/\.+$/, '');
if (domain && DOMAIN_RE.test(domain)) out.add(domain);
if (out.size >= MAX_DOMAINS) break;
const entry = item.trim();
if (!entry) continue;
const bare = entry.toLowerCase().replace(/^\*\./, '').replace(/^@/, '').replace(/\.+$/, '');
const domain = bare ? toAsciiDomain(bare) : null;
if (domain && DOMAIN_RE.test(domain)) {
if (out.size < MAX_DOMAINS) out.add(domain);
} else {
rejected.push(entry);
}
}
return [...out];
return { domains: [...out], rejected };
}
/** The usable domains of a list (see parseDomainList). */
export function normalizeDomainList(value: unknown): string[] | undefined {
return parseDomainList(value)?.domains;
}
function sanitizeLibrary(value: unknown): OrgTemplate[] | undefined {
+215
View File
@@ -0,0 +1,215 @@
/**
* 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.
*
* 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 context helpers
* read one signed-in account's effective policy and identities.
*/
import { DEFAULT_ORGANISATION_POLICY, normalizeDomainList, type OrganisationPolicy } from '@/lib/admin/organisation-policy';
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'];
/** The bare address of "Name <a@b>" or "a@b". */
function bareAddress(address: string | null | undefined): string {
const raw = String(address ?? '').trim();
const bracket = raw.match(/<([^<>]*)>\s*$/);
return (bracket ? bracket[1] : raw).trim();
}
/** The domain of an address in lowercased ASCII form; '' without a usable one. */
export function addressDomain(address: string | null | undefined): string {
const bare = bareAddress(address);
const at = bare.lastIndexOf('@');
if (at < 0) return '';
const domain = bare.slice(at + 1).trim().replace(/\.+$/, '');
return (domain && toAsciiDomain(domain)) || '';
}
/** The address as a lookup key: the domain in ASCII form, all lowercased. */
export function addressKey(address: string): string {
return toAsciiEmail(bareAddress(address)).toLowerCase();
}
/** True when `domain` (ASCII) 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 domains of the user's identities, then the admin's, in ASCII form and
* deduplicated. The identity domains come first so the list's size cap never
* cuts them.
*/
export function internalDomains(identityEmails: readonly string[], policyDomains: readonly string[]): string[] {
const own = identityEmails.map(addressDomain).filter(Boolean);
return normalizeDomainList([...own, ...policyDomains]) ?? [];
}
/** Whether mail to this address leaves the organisation. An address without a domain isn't sendable and isn't counted. */
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 the address as
* addressKey makes it: with "trust contacts" on, a contact doesn't count.
*/
export function externalRecipients(
recipients: { to: readonly string[]; cc: readonly string[]; bcc: readonly string[] },
internal: readonly string[],
trusted?: (key: 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 = addressKey(address);
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;
}
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;
}
// ─── Per account ─────────────────────────────────────────────
/** 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[];
}
/** 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 of a context. */
export function contextInternalDomains(context: ExternalMailContext): string[] {
return internalDomains(context.identityEmails, context.policy.domains);
}
type AccountClient = NonNullable<ReturnType<ReturnType<typeof useAuthStore.getState>['getClientForAccount']>>;
const subjects = new WeakMap<AccountClient, Promise<PolicySubject | null>>();
const identityLists = new WeakMap<AccountClient, Promise<string[]>>();
/** 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<ExternalMailContext> {
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 };
}
/**
* 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<string> {
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<string>();
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));
}
}
return out;
}
@@ -0,0 +1,224 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { externalRecipientCheck, recipientsToConfirm, registerExternalRecipientCheck } from '../external-recipients';
import { resetSendPipeline, type OutgoingContext } from '../send-pipeline';
import { prepareQuickReply } from '../quick-reply';
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 type { ContactCard, Identity } from '@/lib/jmap/types';
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
function setExternalMail(patch: Partial<typeof DEFAULT_ORGANISATION_POLICY.externalMail>) {
usePolicyStore.setState({
policy: {
...DEFAULT_POLICY,
organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } },
},
});
}
const context = (to: string[], cc: string[] = [], bcc: string[] = []): OutgoingContext => ({
surface: 'composer', to, cc, bcc, fromEmail: 'org-alice@example.org', identityId: 'i1', mode: 'compose', plainText: false,
});
beforeEach(() => {
setExternalMail({ recipientWarning: true });
useAuthStore.setState({ client: null, identities: [{ id: 'i1', name: 'Org Alice', email: 'org-alice@example.org' } as unknown as Identity] });
useContactStore.setState({ contacts: [], contactsLoaded: true });
useExternalRecipientConfirmStore.setState({ pending: null, queue: [] });
});
afterEach(() => resetSendPipeline());
describe('externalRecipientCheck', () => {
it('does not ask while the warning is off', async () => {
setExternalMail({ recipientWarning: false, senderLabel: true });
const ask = vi.fn(async () => false);
expect(await externalRecipientCheck(context(['org-dave@org-two.test']), ask)).toBe(true);
expect(ask).not.toHaveBeenCalled();
});
it('does not ask when every recipient is internal, subdomains included', async () => {
const ask = vi.fn(async () => false);
expect(await externalRecipientCheck(context(['org-bob@example.org'], ['x@eu.example.org']), ask)).toBe(true);
expect(ask).not.toHaveBeenCalled();
});
it('asks about the external recipients only and returns the answer', async () => {
const ask = vi.fn(async () => false);
const recipients = context(['org-bob@example.org', 'org-dave@org-two.test'], [], ['eve@other.net']);
expect(await externalRecipientCheck(recipients, ask)).toBe(false);
expect(ask).toHaveBeenCalledWith([
{ address: 'org-dave@org-two.test', domain: 'org-two.test', field: 'to' },
{ address: 'eve@other.net', domain: 'other.net', field: 'bcc' },
]);
ask.mockResolvedValueOnce(true);
expect(await externalRecipientCheck(recipients, ask)).toBe(true);
});
it('counts the admin domains as internal', async () => {
setExternalMail({ recipientWarning: true, domains: ['org-two.test'] });
const ask = vi.fn(async () => false);
expect(await externalRecipientCheck(context(['org-dave@org-two.test']), ask)).toBe(true);
expect(ask).not.toHaveBeenCalled();
});
it('trusts contacts only while trustContacts is on', async () => {
useContactStore.setState({
contacts: [{ id: 'c1', emails: { e: { address: 'Org-Dave@org-two.test' } } }] as unknown as ContactCard[],
});
const ask = vi.fn(async () => false);
expect(await externalRecipientCheck(context(['org-dave@org-two.test']), ask)).toBe(true);
expect(ask).not.toHaveBeenCalled();
setExternalMail({ recipientWarning: true, trustContacts: false });
expect(await externalRecipientCheck(context(['org-dave@org-two.test']), ask)).toBe(false);
expect(ask).toHaveBeenCalledTimes(1);
});
});
describe('quick reply goes through the same confirm', () => {
const input = { body: 'Thanks', signatureIdentity: null, separator: true, to: ['org-dave@org-two.test'], fromEmail: 'org-alice@example.org', identityId: 'i1' };
async function answerWhenAsked(send: boolean) {
await vi.waitFor(() => expect(useExternalRecipientConfirmStore.getState().pending).not.toBeNull());
expect(useExternalRecipientConfirmStore.getState().pending?.recipients.map((r) => r.address)).toEqual(['org-dave@org-two.test']);
useExternalRecipientConfirmStore.getState().answer(send);
}
it('sends nothing on Back', async () => {
registerExternalRecipientCheck();
const result = prepareQuickReply(input);
await answerWhenAsked(false);
expect(await result).toBeNull();
});
it('returns the bodies on Send anyway', async () => {
registerExternalRecipientCheck();
const result = prepareQuickReply(input);
await answerWhenAsked(true);
expect((await result)?.text).toContain('Thanks');
});
it('does not ask for a reply to an internal sender', async () => {
registerExternalRecipientCheck();
const result = await prepareQuickReply({ ...input, to: ['org-bob@example.org'] });
expect(result?.text).toContain('Thanks');
expect(useExternalRecipientConfirmStore.getState().pending).toBeNull();
});
});
describe('the From address', () => {
it('counts the chosen identity as internal, but not a From typed over it', async () => {
useAuthStore.setState({ identities: [] });
const toPartner = { ...context(['bob@partner.example']), fromEmail: 'me@partner.example', identityEmail: 'org-alice@example.org' };
// The typed From is on partner.example, the identity on example.org:
// partner.example stays external.
expect((await recipientsToConfirm(toPartner)).map((r) => r.address)).toEqual(['bob@partner.example']);
// The identity's own domain is internal even before identities load.
const toColleague = { ...context(['bob@example.org']), identityEmail: 'org-alice@example.org' };
expect(await recipientsToConfirm(toColleague)).toEqual([]);
});
});
describe('several questions at once', () => {
it('asks the second after the first is answered, each with its own answer', async () => {
const store = useExternalRecipientConfirmStore.getState();
const first = store.ask([{ address: 'a@x.example', domain: 'x.example', field: 'to' }]);
const second = store.ask([{ address: 'b@y.example', domain: 'y.example', field: 'to' }]);
expect(useExternalRecipientConfirmStore.getState().pending?.recipients[0].address).toBe('a@x.example');
useExternalRecipientConfirmStore.getState().answer(false);
expect(await first).toBe(false);
expect(useExternalRecipientConfirmStore.getState().pending?.recipients[0].address).toBe('b@y.example');
useExternalRecipientConfirmStore.getState().answer(true);
expect(await second).toBe(true);
expect(useExternalRecipientConfirmStore.getState().pending).toBeNull();
});
it('answers every waiting question with Back when the dialog goes away', async () => {
const store = useExternalRecipientConfirmStore.getState();
const first = store.ask([{ address: 'a@x.example', domain: 'x.example', field: 'to' }]);
const second = store.ask([{ address: 'b@y.example', domain: 'y.example', field: 'to' }]);
useExternalRecipientConfirmStore.getState().cancelAll();
expect(await first).toBe(false);
expect(await second).toBe(false);
});
});
describe('contacts that are not loaded yet', () => {
const dave = [{ id: 'c1', emails: { e: { address: 'org-dave@org-two.test' } } }] as unknown as ContactCard[];
afterEach(() => { vi.useRealTimers(); });
it('waits for them, so a contact loaded within the wait is trusted', async () => {
vi.useFakeTimers();
// Timers pending once the contacts are in: the wait's own and whatever the store started.
let pendingAtLoad = -1;
const fetchContacts = vi.fn(() => new Promise<void>((resolve) => {
setTimeout(() => { useContactStore.setState({ contacts: dave, contactsLoaded: true }); pendingAtLoad = vi.getTimerCount(); resolve(); }, 1000);
}));
useContactStore.setState({ contacts: [], contactsLoaded: false, fetchContacts } as never);
useAuthStore.setState({ client: {} as never });
const result = recipientsToConfirm(context(['org-dave@org-two.test']));
await vi.advanceTimersByTimeAsync(1000);
expect(await result).toEqual([]);
expect(fetchContacts).toHaveBeenCalledTimes(1);
// The wait's own timer is gone once the contacts are in.
expect(vi.getTimerCount()).toBe(pendingAtLoad - 1);
});
it('gives up after three seconds and asks about the recipient', async () => {
vi.useFakeTimers();
const fetchContacts = vi.fn(() => new Promise<void>(() => {}));
useContactStore.setState({ contacts: [], contactsLoaded: false, fetchContacts } as never);
useAuthStore.setState({ client: {} as never });
let done = false;
const result = recipientsToConfirm(context(['org-dave@org-two.test'])).then((r) => { done = true; return r; });
await vi.advanceTimersByTimeAsync(2900);
expect(done).toBe(false);
await vi.advanceTimersByTimeAsync(200);
expect((await result).map((r) => r.address)).toEqual(['org-dave@org-two.test']);
});
});
describe('sending through another signed-in account', () => {
function signInDave(daveScope: Record<string, unknown> | 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']);
});
});
+115
View File
@@ -0,0 +1,115 @@
/**
* The pre-send check behind "Send outside your organisation?": when the
* admin turned the recipient warning on, recipients outside the
* organisation (and, with trustContacts, not in the user's contacts) are
* listed in one confirm. Runs for every surface that sends through the
* pipeline: the composer and quick reply.
*/
import {
addressDomain,
contactAddresses,
contextInternalDomains,
externalMailContextFor,
externalRecipients,
type ExternalRecipient,
} from '@/lib/external-mail';
import { useAuthStore } from '@/stores/auth-store';
import { useContactStore } from '@/stores/contact-store';
import { useExternalRecipientConfirmStore } from '@/stores/external-recipient-confirm-store';
import { registerPreSendCheck, type OutgoingContext } from './send-pipeline';
import { debug } from '@/lib/debug';
export const EXTERNAL_RECIPIENT_CHECK_ID = 'external-recipients';
/** After the composer's own checks (attachment, subject, placeholders), before anything later. */
const ORDER = 40;
/** Longest the send waits for the contacts to load the first time. */
const CONTACTS_WAIT_MS = 3000;
/**
* Waits for the contacts the first time, at most CONTACTS_WAIT_MS: a send
* right after signing in shouldn't ask about every contact, nor hang on a
* slow address book.
*/
export async function ensureContactsLoaded(waitMs = CONTACTS_WAIT_MS): Promise<void> {
const contacts = useContactStore.getState();
if (contacts.contactsLoaded) return;
const client = useAuthStore.getState().client;
if (!client) return;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
contacts.fetchContacts(client),
new Promise((resolve) => { timer = setTimeout(resolve, waitMs); }),
]);
} catch (error) {
debug.error('Could not load contacts for the external-recipient check:', error);
} finally {
clearTimeout(timer);
}
}
/** The recipients the user is asked about; empty when the check doesn't apply. */
export async function recipientsToConfirm(context: OutgoingContext): Promise<ExternalRecipient[]> {
// 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 = contextInternalDomains(account);
if (identityDomain && !internal.includes(identityDomain)) internal.push(identityDomain);
let trusted: ((address: string) => boolean) | undefined;
if (policy.trustContacts) {
await ensureContactsLoaded();
const known = contactAddresses();
trusted = (address) => known.has(address);
}
return externalRecipients(context, internal, trusted);
}
/** True to send: nobody external, or the user chose "Send anyway". */
export async function externalRecipientCheck(
context: OutgoingContext,
ask: (recipients: ExternalRecipient[]) => Promise<boolean> = (recipients) => useExternalRecipientConfirmStore.getState().ask(recipients),
): Promise<boolean> {
const recipients = await recipientsToConfirm(context);
if (recipients.length === 0) return true;
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 {
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<boolean> {
if (hosts === 0 || invited.length === 0) return true;
return externalRecipientCheck({
surface: 'calendar-invitation',
to: invited,
cc: [],
bcc: [],
mode: 'compose',
plainText: false,
});
}
+2
View File
@@ -49,6 +49,8 @@ export async function prepareQuickReply(input: {
sender: input.sendingIdentity
? { name: input.sendingIdentity.name, email: input.sendingIdentity.email }
: undefined,
// Quick reply never rewrites From: it is the identity's own address.
identityEmail: input.fromEmail,
mode: 'reply' as const,
plainText: !html,
};
+10 -1
View File
@@ -18,7 +18,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;
@@ -26,6 +26,7 @@ export interface OutgoingContext {
to: string[];
cc: string[];
bcc: string[];
/** The From address: the identity's, or one typed over it in the composer. */
fromEmail?: string;
identityId?: string;
/**
@@ -34,6 +35,14 @@ export interface OutgoingContext {
* identityId can name an identity of another account ("<account>::<id>").
*/
sender?: { name: string; email: string; accountId?: string | null };
/** 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). */
+22 -1
View File
@@ -7,12 +7,13 @@
// `event.source === iframe.contentWindow`. The iframe's runtime pins the
// parent on the first inbound message.
import type { InstalledPlugin, SlotName, PluginTier } from '../plugin-types';
import type { HostFeaturesView, InstalledPlugin, SlotName, PluginTier } from '../plugin-types';
import { dispatchApiCall } from './host-api';
import { SANDBOX_PATH, SANDBOX_PRIVILEGED_PATH, type RegionalSnapshot } from './protocol';
export type { RegionalSnapshot };
import { withBasePath } from '../browser-navigation';
import { snapshotHostTheme, type ThemeSnapshot } from './host-theme';
import { snapshotHostFeatures, watchHostFeatures } from './host-features';
import { effectiveSettings } from '../plugin-settings';
import { clampSlotHeight, clampSlotWidth, isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout';
import { resolveDateLocale } from '../utils';
@@ -252,6 +253,13 @@ function pushThemeToFrames(): void {
}
}
/** Every frame hears about a Bulwark feature that replaces plugins turning on or off. */
function pushHostFeaturesToFrames(features: HostFeaturesView): void {
for (const instance of liveInstances) {
try { instance.setHostFeatures(features); } catch { /* one broken iframe must not stop the rest */ }
}
}
function ensureThemeWatcher(): void {
if (themeWatcher || typeof MutationObserver === 'undefined' || typeof document === 'undefined') return;
let scheduled = false;
@@ -414,6 +422,7 @@ export class SandboxInstance {
hostContainer.appendChild(this.iframe);
liveInstances.add(this);
if (this.mode === 'slot') ensureThemeWatcher();
watchHostFeatures(pushHostFeaturesToFrames);
// Send init after the iframe runtime signals it's ready.
this.readyPromise.then(() => {
@@ -637,6 +646,16 @@ export class SandboxInstance {
this.send({ type: 'theme-change', theme });
}
/** Push what api.host.features says now; before init it goes into the init payload. */
setHostFeatures(features: HostFeaturesView): void {
if (this.destroyed) return;
if (this.pendingInit) {
this.pendingInit.hostFeatures = features;
return;
}
this.send({ type: 'host-change', features });
}
/** Push the plugin's current settings (see effectiveSettings). */
setSettings(settings: Record<string, unknown>): void {
if (this.destroyed) return;
@@ -735,6 +754,7 @@ export function createBackgroundInstance(opts: BackgroundOptions): SandboxInstan
code: opts.code,
locale: opts.locale,
regional: opts.regional ?? snapshotRegionalSettings(opts.locale),
hostFeatures: snapshotHostFeatures(),
};
return new SandboxInstance(
opts.plugin,
@@ -764,6 +784,7 @@ export function createSlotInstance(opts: SlotOptions): SandboxInstance {
locale: opts.locale,
regional: opts.regional ?? snapshotRegionalSettings(opts.locale),
theme: snapshotHostTheme(),
hostFeatures: snapshotHostFeatures(),
};
return new SandboxInstance(opts.plugin, payload, opts.hostContainer, opts.onResize, opts.onFloatingDropdown);
}
+72
View File
@@ -0,0 +1,72 @@
// What the host tells plugins about its own features (`api.host.features`),
// so a plugin can step back where Bulwark now does the job itself. Sent with
// every frame's init and again whenever it changes (the admin's policy, an
// account switch to another scope).
//
// And the other half of that hand-over: copies of a plugin core replaced
// that are older than the release which knows the flags keep running their
// hooks. While core does a job, the host skips the hooks that did it, by
// plugin id, one switch at a time.
import { usePolicyStore } from '@/stores/policy-store';
import type { HostFeaturesView } from '../plugin-types';
import type { OrganisationPolicy } from '@/lib/admin/organisation-policy';
/** The external-mail-warning plugin, retired in favour of organisation.externalMail. */
export const EXTERNAL_MAIL_WARNING_PLUGIN_ID = 'external-mail-warning';
/** The flags for an externalMail policy section (missing = both off). */
export function hostFeaturesFor(externalMail: OrganisationPolicy['externalMail'] | undefined): HostFeaturesView {
return {
externalSenderLabel: externalMail?.senderLabel === true,
externalRecipientWarning: externalMail?.recipientWarning === true,
};
}
export function snapshotHostFeatures(): HostFeaturesView {
return hostFeaturesFor(usePolicyStore.getState().getOrganisation().externalMail);
}
/**
* Which flag hands over which of the plugin's parts: the send confirm goes
* with the recipient warning, the banner and the new-mail toast (both about
* incoming mail from outside) with the sender label.
*/
const HOOK_FLAGS: Record<string, keyof HostFeaturesView> = {
onBeforeEmailSend: 'externalRecipientWarning',
onNewEmailReceived: 'externalSenderLabel',
};
const SLOT_FLAGS: Record<string, keyof HostFeaturesView> = {
'email-banner': 'externalSenderLabel',
};
/** True when core does this plugin hook's job right now, so the host skips it. */
export function isHookHandedOver(pluginId: string, hookName: string, features: HostFeaturesView = snapshotHostFeatures()): boolean {
const flag = HOOK_FLAGS[hookName];
return pluginId === EXTERNAL_MAIL_WARNING_PLUGIN_ID && !!flag && features[flag];
}
/** True when core does this plugin slot's job right now, so the host doesn't show it. */
export function isSlotHandedOver(pluginId: string, slot: string, features: HostFeaturesView = snapshotHostFeatures()): boolean {
const flag = SLOT_FLAGS[slot];
return pluginId === EXTERNAL_MAIL_WARNING_PLUGIN_ID && !!flag && features[flag];
}
// ─── Change watch ────────────────────────────────────────────
let watching = false;
let lastKey = '';
/** Calls `onChange` with the new features whenever they change; set up once. */
export function watchHostFeatures(onChange: (features: HostFeaturesView) => void): void {
if (watching) return;
watching = true;
lastKey = JSON.stringify(snapshotHostFeatures());
usePolicyStore.subscribe(() => {
const features = snapshotHostFeatures();
const key = JSON.stringify(features);
if (key === lastKey) return;
lastKey = key;
onChange(features);
});
}
+7 -1
View File
@@ -31,6 +31,7 @@ import { clearPluginPages } from './pages';
import { clearSectionTitle } from './section-titles';
import { cancelPluginProxyRequests } from './proxy-client';
import { cancelConnectFor } from './connect-dialog';
import { isHookHandedOver } from './host-features';
import type { SlotName } from '../plugin-types';
// ─── Hook-bus lookup (one flat map for name → bus) ────────────
@@ -280,7 +281,12 @@ async function startSandboxedPlugin(plugin: InstalledPlugin): Promise<void> {
}
// The bus records a failure once; recording it here too made the
// three-strikes breaker trip on the second error.
const proxy = (...args: unknown[]) => bg.invokeHook(hookName, args);
// While Bulwark does a retired plugin's job itself, that plugin's
// hook is skipped: copies older than the release that steps back on
// its own would ask twice (host-features.ts). Asked per call, so
// turning the core switch off gives the plugin its hook back.
const pluginId = plugin.id;
const proxy = (...args: unknown[]) => (isHookHandedOver(pluginId, hookName) ? undefined : bg.invokeHook(hookName, args));
hookDisposables.push(bus.register(plugin.id, proxy as (...a: unknown[]) => unknown));
if (hookName === 'onEmailOpen') openHandler = proxy;
}
+9 -1
View File
@@ -8,7 +8,7 @@
// the boundary must be structured-cloneable: no functions, no DOM nodes, no
// class instances.
import type { SlotName, PluginTier } from '../plugin-types';
import type { SlotName, PluginTier, HostFeaturesView } from '../plugin-types';
import type { ThemeSnapshot } from './host-theme';
/** Resolved presentation/regional settings mirrored into plugin runtimes. */
@@ -50,6 +50,8 @@ export interface BackgroundInit {
locale: string;
/** Resolved presentation/regional preferences (time format, date locale, time zone). */
regional?: RegionalSnapshot;
/** Bulwark features that replace plugins (api.host.features); host pushes updates via 'host-change'. */
hostFeatures?: HostFeaturesView;
}
/** Initialisation payload for a slot-instance iframe (one per slot mount). */
@@ -97,6 +99,8 @@ export interface SlotInit {
* injected CSS + a `.dark` class. Host pushes updates via 'theme-change'.
*/
theme: ThemeSnapshot;
/** Mirrors `BackgroundInit.hostFeatures`. */
hostFeatures?: HostFeaturesView;
}
export type InitPayload = BackgroundInit | SlotInit;
@@ -265,6 +269,9 @@ export interface ThemeChangeMsg { type: 'theme-change'; theme: ThemeSnapshot; }
/** Host → sandbox: the user changed this plugin's settings. */
export interface SettingsChangeMsg { type: 'settings-change'; settings: Record<string, unknown>; }
/** Host → sandbox: a Bulwark feature that replaces plugins was turned on or off. */
export interface HostChangeMsg { type: 'host-change'; features: HostFeaturesView; }
export interface PropsUpdateMsg { type: 'props-update'; props: Record<string, unknown>; }
export interface SlotShouldShowMsg {
@@ -309,6 +316,7 @@ export type HostToSandbox =
| LocaleChangeMsg
| ThemeChangeMsg
| SettingsChangeMsg
| HostChangeMsg
| PropsUpdateMsg
| SlotShouldShowMsg
| FloatingDropdownSelectMsg
+34 -1
View File
@@ -33,7 +33,7 @@ import { initFloatingDropdownBridge, dispatchFloatingDropdownEvent } from './ui-
import { interpolate, lookup, textDirection, type LocaleTables } from './plugin-i18n';
import { isFillableSlot, isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout';
import type {
AppInfoView, AppSettingsView, SettingsSearchResult, CalendarEventInput, CalendarEventView, CalendarView, ComposeInput, DraftInput, EmailReadView,
AppInfoView, AppSettingsView, HostFeaturesView, SettingsSearchResult, CalendarEventInput, CalendarEventView, CalendarView, ComposeInput, DraftInput, EmailReadView,
EmailSummary, IdentityPatch, IdentityView, MailboxView, NotificationInput, SlotName, TaskInput,
TaskView, TemplateInput, TemplateView, VacationInput, VacationView,
} from '../plugin-types';
@@ -148,6 +148,19 @@ function applySettings(next: Record<string, unknown>): void {
try { listener(settingsState); } catch (err) { console.error('[plugin] settings listener failed', err); }
}
}
// What api.host.features says: one object for the iframe's lifetime,
// updated in place on 'host-change' like the settings.
const hostFeaturesState: HostFeaturesView = { externalSenderLabel: false, externalRecipientWarning: false };
const hostFeatureListeners = new Set<(features: HostFeaturesView) => void>();
function applyHostFeatures(next: HostFeaturesView | undefined): void {
if (!next) return;
hostFeaturesState.externalSenderLabel = next.externalSenderLabel === true;
hostFeaturesState.externalRecipientWarning = next.externalRecipientWarning === true;
for (const listener of hostFeatureListeners) {
try { listener(hostFeaturesState); } catch (err) { console.error('[plugin] host feature listener failed', err); }
}
}
const pendingCallbacks = new Map<string, { resolve: (v: unknown) => void; reject: (err: Error) => void }>();
const hookHandlers: Record<string, (...args: unknown[]) => unknown> = {};
@@ -375,6 +388,18 @@ function buildPluginApi(manifest: PluginManifest) {
return () => { settingsListeners.delete(listener); };
},
},
/**
* Bulwark features that replace what plugins did, so a plugin can step
* back while Bulwark does its job. Updated in place; no permission needed.
*/
host: {
features: hostFeaturesState,
/** Called with the new features whenever one turns on or off. Returns an unsubscribe function. */
onFeaturesChange: (listener: (features: HostFeaturesView) => void) => {
hostFeatureListeners.add(listener);
return () => { hostFeatureListeners.delete(listener); };
},
},
/** The app the plugin runs in: product, the administrator's name for it, version, edition. No permission needed. */
app: {
getInfo: () => callApi('app.getInfo', []) as Promise<AppInfoView>,
@@ -1143,9 +1168,11 @@ async function bootSlot(payload: SlotInit): Promise<void> {
const bump = () => setSettingsVersion((v) => v + 1);
settingsListeners.add(bump);
localeListeners.add(bump);
hostFeatureListeners.add(bump);
return () => {
settingsListeners.delete(bump);
localeListeners.delete(bump);
hostFeatureListeners.delete(bump);
};
}, []);
React.useEffect(() => {
@@ -1193,6 +1220,8 @@ async function handleInit(payload: InitPayload): Promise<void> {
// Make the active locale available to plugin code (api.i18n) right away -
// not only after the first 'locale-change' push.
applyLocale(payload.locale, payload.regional);
// Before the bundle runs, so activate() and shouldShow already see it.
applyHostFeatures(payload.hostFeatures);
try {
if (payload.mode === 'background') {
await bootBackground(payload);
@@ -1289,6 +1318,10 @@ function handleHostMessage(ev: MessageEvent): void {
applySettings(msg.settings ?? {});
break;
case 'host-change':
applyHostFeatures(msg.features);
break;
case 'props-update':
if (slotPropsUpdater) slotPropsUpdater(msg.props ?? {});
else if (mode === 'slot') propsBeforeBoot = msg.props ?? {};
+13
View File
@@ -837,6 +837,19 @@ export interface AppInfoView {
demoMode: boolean;
}
/**
* Features of Bulwark itself that replace what a plugin did, as
* `api.host.features` tells the plugin (no permission needed). A plugin
* that does the same job steps back while one is true. Missing on hosts
* older than 1.19.0, so read it as `api.host?.features?.x === true`.
*/
export interface HostFeaturesView {
/** The admin turned on the "External" label on mail from outside the organisation (organisation.externalMail.senderLabel). */
externalSenderLabel: boolean;
/** The admin turned on the confirm before sending outside the organisation (organisation.externalMail.recipientWarning). */
externalRecipientWarning: boolean;
}
/** One setting `api.app.searchSettings` found, in the user's language. */
export interface SettingsSearchResult {
/** The settings page's id: account, appearance, composing, vacation, ... */
+12
View File
@@ -673,6 +673,18 @@
"plugin_panel_hide": "Plugin-Bereich ausblenden",
"copy": "Kopieren"
},
"external_mail": {
"sender_label": "Extern",
"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",
"field_cc": "Cc",
"field_bcc": "Bcc",
"send_anyway": "Trotzdem senden",
"back": "Zurück"
},
"email_composer": {
"select_from_directory": "Aus Verzeichnis auswählen",
"minimize": "Minimieren",
+12
View File
@@ -673,6 +673,18 @@
"plugin_panel_show": "Show plugin panel",
"plugin_panel_hide": "Hide plugin panel"
},
"external_mail": {
"sender_label": "External",
"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",
"field_cc": "Cc",
"field_bcc": "Bcc",
"send_anyway": "Send anyway",
"back": "Back"
},
"email_composer": {
"select_from_directory": "Select from directory",
"minimize": "Minimize",
@@ -0,0 +1,49 @@
import { create } from 'zustand';
import type { ExternalRecipient } from '@/lib/external-mail';
interface Question {
recipients: ExternalRecipient[];
resolve: (send: boolean) => void;
}
/**
* The "Send outside your organisation?" questions. The pre-send check asks,
* the dialog (components/email/external-recipient-confirm.tsx) shows the
* first and answers it. A question asked while another is open waits its
* turn: two sends at once (a quick reply and the composer) each get their
* own answer.
*/
interface ExternalRecipientConfirmState {
/** The question on screen, the first in line. */
pending: Question | null;
/** The ones waiting behind it. */
queue: Question[];
/** Resolves true for "Send anyway", false for "Back". */
ask: (recipients: ExternalRecipient[]) => Promise<boolean>;
/** Answers the question on screen and shows the next. */
answer: (send: boolean) => void;
/** Answers every question with "Back" (the dialog went away). */
cancelAll: () => void;
}
export const useExternalRecipientConfirmStore = create<ExternalRecipientConfirmState>()((set, get) => ({
pending: null,
queue: [],
ask: (recipients) => new Promise<boolean>((resolve) => {
const question = { recipients, resolve };
if (get().pending) set((state) => ({ queue: [...state.queue, question] }));
else set({ pending: question });
}),
answer: (send) => {
const { pending, queue } = get();
if (!pending) return;
const [next = null, ...rest] = queue;
set({ pending: next, queue: rest });
pending.resolve(send);
},
cancelAll: () => {
const { pending, queue } = get();
set({ pending: null, queue: [] });
for (const question of [pending, ...queue]) question?.resolve(false);
},
}));