From 2855ca00104847d96efec089083d2d7dda201d35 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:59:13 +0100 Subject: [PATCH] feat: confirm before sending to recipients outside the organisation --- app/(main)/[locale]/layout.tsx | 2 + .../composer-external-recipients.test.tsx | 278 ++++++++++++++++++ .../email/external-recipient-confirm.tsx | 83 ++++++ .../__tests__/external-recipients.test.ts | 112 +++++++ lib/outgoing/external-recipients.ts | 74 +++++ locales/de/common.json | 9 +- locales/en/common.json | 9 +- stores/external-recipient-confirm-store.ts | 31 ++ 8 files changed, 596 insertions(+), 2 deletions(-) create mode 100644 components/email/__tests__/composer-external-recipients.test.tsx create mode 100644 components/email/external-recipient-confirm.tsx create mode 100644 lib/outgoing/__tests__/external-recipients.test.ts create mode 100644 lib/outgoing/external-recipients.ts create mode 100644 stores/external-recipient-confirm-store.ts diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 76f9396ad..eab9e2482 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -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({ {children} + diff --git a/components/email/__tests__/composer-external-recipients.test.tsx b/components/email/__tests__/composer-external-recipients.test.tsx new file mode 100644 index 000000000..c3a5ea328 --- /dev/null +++ b/components/email/__tests__/composer-external-recipients.test.tsx @@ -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) => 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>, + defaultIdentityId: 'id-me', + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => 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) => 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) => 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) => 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) => 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) => 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: 'Hi', + showCc: true, + showBcc: false, + selectedIdentityId: 'id-me', + subAddressTag: '', + mode: 'compose' as const, + draftId: null, +}; + +function setExternalMail(patch: Partial) { + 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( + <> + + + >, + ); +} + +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(); + }); +}); diff --git a/components/email/external-recipient-confirm.tsx b/components/email/external-recipient-confirm.tsx new file mode 100644 index 000000000..ee6e0b85a --- /dev/null +++ b/components/email/external-recipient-confirm.tsx @@ -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 ( + { + if (e.target === e.currentTarget) answer(false); + }} + > + + + + + + + + {t('confirm_title')} + + {t('confirm_message', { count: recipients.length })} + + + + + {recipients.map((recipient) => ( + + {field[recipient.field]} + {recipient.address} + + ))} + + + + {/* Back comes first, so the focus trap puts the focus on it: sending outside takes a deliberate click. */} + answer(false)} data-external-recipient-back=""> + {t('back')} + + answer(true)} data-external-recipient-send=""> + {t('send_anyway')} + + + + + ); +} diff --git a/lib/outgoing/__tests__/external-recipients.test.ts b/lib/outgoing/__tests__/external-recipients.test.ts new file mode 100644 index 000000000..c9982315f --- /dev/null +++ b/lib/outgoing/__tests__/external-recipients.test.ts @@ -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) { + 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(); + }); +}); diff --git a/lib/outgoing/external-recipients.ts b/lib/outgoing/external-recipients.ts new file mode 100644 index 000000000..e7f2b4cb4 --- /dev/null +++ b/lib/outgoing/external-recipients.ts @@ -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 { + 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 { + 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 = (recipients) => useExternalRecipientConfirmStore.getState().ask(recipients), +): Promise { + 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)); +} diff --git a/locales/de/common.json b/locales/de/common.json index 73f54a823..729bb80ca 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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", diff --git a/locales/en/common.json b/locales/en/common.json index 60734aa9d..b8c4ecb2c 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/stores/external-recipient-confirm-store.ts b/stores/external-recipient-confirm-store.ts new file mode 100644 index 000000000..45e3304e5 --- /dev/null +++ b/stores/external-recipient-confirm-store.ts @@ -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; + answer: (send: boolean) => void; +} + +export const useExternalRecipientConfirmStore = create()((set, get) => ({ + pending: null, + ask: (recipients) => { + get().pending?.resolve(false); + return new Promise((resolve) => { + set({ pending: { recipients, resolve } }); + }); + }, + answer: (send) => { + const pending = get().pending; + if (!pending) return; + set({ pending: null }); + pending.resolve(send); + }, +}));
Hi
+ {t('confirm_message', { count: recipients.length })} +