mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-06 00:15:32 +00:00
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:
@@ -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' 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.'}{' '}
|
||||
“Import from the plugin” 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
|
||||
@@ -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"><{sender.email}></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"><{sender.email}></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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? {};
|
||||
|
||||
@@ -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, ... */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user