feat: confirm before sending to recipients outside the organisation

This commit is contained in:
Linus Rath
2026-10-28 01:59:13 +01:00
parent b504e39b63
commit 2855ca0010
8 changed files with 596 additions and 2 deletions
+2
View File
@@ -10,6 +10,7 @@ import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-la
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler";
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
import { ExternalRecipientConfirmHost } from "@/components/email/external-recipient-confirm";
import { CommandPaletteHost } from "@/components/command-palette/command-palette";
import { PluginConnectDialog } from "@/components/plugins/plugin-connect-dialog";
import { QuickRuleHost } from "@/components/filters/quick-rule-host";
@@ -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();
});
});
+74
View File
@@ -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));
}
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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);
},
}));