mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: confirm before sending to recipients outside the organisation
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";
|
||||
@@ -63,6 +64,7 @@ export default async function LocaleLayout({
|
||||
<PushBindingsHost />
|
||||
{children}
|
||||
<PluginDialogHost />
|
||||
<ExternalRecipientConfirmHost />
|
||||
<CommandPaletteHost />
|
||||
<PluginConnectDialog />
|
||||
<QuickRuleHost />
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
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';
|
||||
|
||||
// ─── 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: [],
|
||||
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 });
|
||||
setExternalMail({ recipientWarning: true });
|
||||
});
|
||||
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('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,83 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useId } 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().answer(false), []);
|
||||
|
||||
const dialogRef = useFocusTrap({ isActive: !!pending, onEscape: () => answer(false), restoreFocus: true });
|
||||
|
||||
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);
|
||||
}}
|
||||
>
|
||||
<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,112 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { externalRecipientCheck, 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 });
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 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,
|
||||
currentExternalMailPolicy,
|
||||
currentInternalDomains,
|
||||
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;
|
||||
|
||||
async function ensureContactsLoaded(): Promise<void> {
|
||||
const contacts = useContactStore.getState();
|
||||
if (contacts.contactsLoaded) return;
|
||||
const client = useAuthStore.getState().client;
|
||||
if (!client) return;
|
||||
try {
|
||||
await Promise.race([
|
||||
contacts.fetchContacts(client),
|
||||
new Promise((resolve) => setTimeout(resolve, CONTACTS_WAIT_MS)),
|
||||
]);
|
||||
} catch (error) {
|
||||
debug.error('Could not load contacts for the external-recipient check:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/** The recipients the user is asked about; empty when the check doesn't apply. */
|
||||
export async function recipientsToConfirm(context: OutgoingContext): Promise<ExternalRecipient[]> {
|
||||
const policy = currentExternalMailPolicy();
|
||||
if (!policy.recipientWarning) return [];
|
||||
// The From address is one of the user's own, even an override typed in the composer.
|
||||
const fromDomain = addressDomain(context.fromEmail);
|
||||
const internal = currentInternalDomains(policy);
|
||||
if (fromDomain && !internal.includes(fromDomain)) internal.push(fromDomain);
|
||||
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);
|
||||
}
|
||||
|
||||
/** Called by the dialog host once it is mounted; returns the unregister function. */
|
||||
export function registerExternalRecipientCheck(): () => void {
|
||||
return registerPreSendCheck(EXTERNAL_RECIPIENT_CHECK_ID, ORDER, (context) => externalRecipientCheck(context));
|
||||
}
|
||||
@@ -661,7 +661,14 @@
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "Extern",
|
||||
"sender_tooltip": "Von außerhalb Ihrer Organisation: {domain}"
|
||||
"sender_tooltip": "Von außerhalb Ihrer Organisation: {domain}",
|
||||
"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",
|
||||
|
||||
@@ -661,7 +661,14 @@
|
||||
},
|
||||
"external_mail": {
|
||||
"sender_label": "External",
|
||||
"sender_tooltip": "From outside your organisation: {domain}"
|
||||
"sender_tooltip": "From outside your organisation: {domain}",
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { create } from 'zustand';
|
||||
import type { ExternalRecipient } from '@/lib/external-mail';
|
||||
|
||||
/**
|
||||
* The one open "Send outside your organisation?" question. The pre-send
|
||||
* check asks, the dialog (components/email/external-recipient-confirm.tsx)
|
||||
* shows it and answers. Asking again while a question is open answers the
|
||||
* old one with "Back", so no send waits forever.
|
||||
*/
|
||||
interface ExternalRecipientConfirmState {
|
||||
pending: { recipients: ExternalRecipient[]; resolve: (send: boolean) => void } | null;
|
||||
/** Resolves true for "Send anyway", false for "Back". */
|
||||
ask: (recipients: ExternalRecipient[]) => Promise<boolean>;
|
||||
answer: (send: boolean) => void;
|
||||
}
|
||||
|
||||
export const useExternalRecipientConfirmStore = create<ExternalRecipientConfirmState>()((set, get) => ({
|
||||
pending: null,
|
||||
ask: (recipients) => {
|
||||
get().pending?.resolve(false);
|
||||
return new Promise<boolean>((resolve) => {
|
||||
set({ pending: { recipients, resolve } });
|
||||
});
|
||||
},
|
||||
answer: (send) => {
|
||||
const pending = get().pending;
|
||||
if (!pending) return;
|
||||
set({ pending: null });
|
||||
pending.resolve(send);
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user