diff --git a/components/email/__tests__/composer-send-pipeline.test.tsx b/components/email/__tests__/composer-send-pipeline.test.tsx new file mode 100644 index 000000000..075421c0e --- /dev/null +++ b/components/email/__tests__/composer-send-pipeline.test.tsx @@ -0,0 +1,248 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { EmailComposer } from '../email-composer'; +import { registerOutgoingFinalizer, registerPreSendCheck, resetSendPipeline } from '@/lib/outgoing/send-pipeline'; + +// ─── 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 ──────────────────────────────────────────────────────────────────── + +// Features add to every send through lib/outgoing/send-pipeline.ts: a check +// can stop it, a finaliser changes what goes out. Both run in the composer's +// send, after its own checks. + +const DRAFT = { + to: 'bob@example.com', + cc: 'carol@example.com', + bcc: '', + subject: 'Hello', + body: '

Hi

', + showCc: true, + showBcc: false, + selectedIdentityId: 'id-me', + subAddressTag: '', + mode: 'compose' as const, + draftId: null, +}; + +function clickSend() { + fireEvent.click(screen.getAllByTestId('composer-send')[0]); +} + +describe('composer send pipeline', () => { + beforeEach(() => resetSendPipeline()); + afterEach(() => { + resetSendPipeline(); + vi.clearAllMocks(); + }); + + it('sends what the finalisers return, with the recipients in the context', async () => { + const seen: unknown[] = []; + registerOutgoingFinalizer('footer', 10, (bodies, context) => { + seen.push(context); + return { html: `${bodies.html}

Footer

`, text: `${bodies.text}\n\nFooter` }; + }); + const onSend = vi.fn(); + render(); + clickSend(); + await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + const sent = onSend.mock.calls[0][0] as { htmlBody?: string; body: string }; + expect(sent.htmlBody).toMatch(/

Footer<\/p>$/); + expect(sent.body).toMatch(/\n\nFooter$/); + expect(seen).toEqual([expect.objectContaining({ + surface: 'composer', to: ['bob@example.com'], cc: ['carol@example.com'], fromEmail: 'me@example.com', mode: 'compose', plainText: false, + })]); + }); + + it('sends nothing when a check says no, and sends after it says yes', async () => { + let allow = false; + const check = vi.fn(() => allow); + registerPreSendCheck('ask', 10, check); + const onSend = vi.fn(); + render(); + clickSend(); + await waitFor(() => expect(check).toHaveBeenCalledTimes(1)); + expect(onSend).not.toHaveBeenCalled(); + + allow = true; + clickSend(); + await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index d594c091b..01ac2feb7 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -96,6 +96,7 @@ import { TemplateForm } from "@/components/templates/template-form"; import type { EmailTemplate } from "@/lib/template-types"; import { appendPlainTextSignature, getPlainTextSignature, plainTextBodyHasSignature, plainTextBodyWithoutSignature } from "@/lib/signature-utils"; import { wantsSignatureSeparator } from "@/lib/signature-delimiter"; +import { finalizeOutgoing, runPreSendChecks, type OutgoingContext } from "@/lib/outgoing/send-pipeline"; import { findComposeIdentityId, findDraftIdentityId, findReplyIdentityId, resolveReplyFrom } from "@/lib/reply-identity"; import { buildReplyRecipients, isSelfSent, ownReplyAddresses } from "@/lib/reply-recipients"; import { computeReplyThreadingHeaders, type ReplyThreadingHeaders } from "@/lib/email-threading"; @@ -3091,6 +3092,8 @@ export function EmailComposer({ // second click in the same tick - and isSending drives button disabling. const [isSending, setIsSending] = useState(false); const isSendingRef = useRef(false); + // A pre-send check is waiting for the user (send-pipeline). + const preSendCheckingRef = useRef(false); // Attachments still uploading when Send is clicked used to be silently // dropped from the outgoing message (the filters below exclude anything @@ -3275,6 +3278,29 @@ export function EmailComposer({ } } + // Checks other features register (lib/outgoing/send-pipeline.ts), such + // as the external-recipient confirm. They may ask the user, so a second + // click meanwhile does nothing. + if (preSendCheckingRef.current) return; + const outgoingContext: OutgoingContext = { + surface: 'composer', + to: toAddresses.map(r => r.email), + cc: ccAddresses.map(r => r.email), + bcc: bccAddresses.map(r => r.email), + fromEmail: (fromOverrideEnabled && fromOverrideEmail.trim()) || currentIdentity?.email || undefined, + identityId: currentIdentity?.id, + mode, + plainText: plainTextMode, + }; + preSendCheckingRef.current = true; + let checksPassed = false; + try { + checksPassed = await runPreSendChecks(outgoingContext); + } finally { + preSendCheckingRef.current = false; + } + if (!checksPassed) return; + // Past every "don't send" early return - mark the send in flight so a // second click is a no-op until this resolves (reset in the finally below). isSendingRef.current = true; @@ -3347,7 +3373,7 @@ export function EmailComposer({ // In plain text mode, send text/plain only (no HTML body) const signatureOpts = { separator: signatureSeparatorEnabled }; - const finalBody = plainTextMode + const builtTextBody = plainTextMode ? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, signatureIdentity, signatureOpts)) : (signatureAlreadyInBody ? htmlToPlainText(body) : appendPlainTextSignature(htmlToPlainText(body), signatureIdentity, signatureOpts)); @@ -3367,9 +3393,12 @@ export function EmailComposer({ } const rewritten = plainTextMode ? null : rewriteInlineImages(body); - const finalHtmlBody = plainTextMode - ? undefined - : `

${rewritten!.html}
${buildSignatureHtml()}`; + // What the send-pipeline finalisers add (disclaimer, organisation + // signature) goes in here, on the send only: drafts never get it. + const { html: finalHtmlBody, text: finalBody } = finalizeOutgoing({ + html: plainTextMode ? undefined : `
${rewritten!.html}
${buildSignatureHtml()}`, + text: builtTextBody, + }, outgoingContext); const inlineAttachments = rewritten?.attachments ?? []; try { diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index bbd5971b7..e5bfaf3c9 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -112,7 +112,7 @@ import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { WopiEditor } from "@/components/files/wopi-editor"; import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status"; import { isFilePreviewable } from "@/lib/file-preview"; -import { appendHtmlSignature, appendPlainTextSignature } from "@/lib/signature-utils"; +import { prepareQuickReply } from "@/lib/outgoing/quick-reply"; import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; import { findDraftIdentityId, findReplyIdentityId, resolveComposeAccountEmail } from "@/lib/reply-identity"; @@ -3900,21 +3900,18 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const signatureIdentity = (sendingIdentity?.htmlSignature || sendingIdentity?.textSignature) ? sendingIdentity : primaryIdentity; - const separator = useSettingsStore.getState().signatureSeparatorEnabled; - const finalBody = appendPlainTextSignature(body, signatureIdentity, { separator }); - - // When the identity has an HTML signature, send a matching HTML body so the - // signature keeps its formatting; appendPlainTextSignature would otherwise - // flatten it to plain text. Text-only identities keep the plain-text-only - // behavior (htmlBody stays undefined). - const escapedBody = body - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/\n/g, '
'); - const finalHtmlBody = signatureIdentity?.htmlSignature?.trim() - ? appendHtmlSignature(`
${escapedBody}
`, signatureIdentity, { separator }) - : undefined; + // The same checks and finalisers as the composer's send + // (lib/outgoing/send-pipeline.ts): a quick reply is no way around them. + const prepared = await prepareQuickReply({ + body, + signatureIdentity, + separator: useSettingsStore.getState().signatureSeparatorEnabled, + to: recipients, + fromEmail: headerFromEmail, + identityId: sendingIdentity?.id, + }); + if (!prepared) return; + const { html: finalHtmlBody, text: finalBody } = prepared; const originalEmailId = selectedEmail.id; const originalSource = emailWriteSource(selectedEmail, client); diff --git a/lib/outgoing/__tests__/quick-reply.test.ts b/lib/outgoing/__tests__/quick-reply.test.ts new file mode 100644 index 000000000..834082a6b --- /dev/null +++ b/lib/outgoing/__tests__/quick-reply.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { prepareQuickReply } from '@/lib/outgoing/quick-reply'; +import { registerOutgoingFinalizer, registerPreSendCheck, resetSendPipeline } from '@/lib/outgoing/send-pipeline'; +import type { Identity } from '@/lib/jmap/types'; + +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); + +// Quick reply goes through the same checks and finalisers as the composer, +// so the disclaimer and the external-recipient confirm reach it too. + +const html = { id: 'i', name: 'Me', email: 'me@example.com', htmlSignature: 'Me', textSignature: '' } as Identity; +const text = { id: 'i', name: 'Me', email: 'me@example.com', htmlSignature: '', textSignature: 'Me' } as Identity; +const input = (signatureIdentity: Identity) => ({ + body: 'Thanks <3', signatureIdentity, separator: true, to: ['bob@example.com'], fromEmail: 'me@example.com', identityId: 'i', +}); + +afterEach(() => resetSendPipeline()); + +describe('prepareQuickReply', () => { + it('builds the text and, for an HTML signature, an escaped HTML body', async () => { + const withHtml = await prepareQuickReply(input(html)); + expect(withHtml?.html).toContain('
Thanks <3
'); + expect(withHtml?.html).toContain('Me'); + expect(withHtml?.text).toContain('Thanks <3'); + const plain = await prepareQuickReply(input(text)); + expect(plain?.html).toBeUndefined(); + expect(plain?.text).toMatch(/Thanks <3[\s\S]*-- \nMe$/); + }); + + it('runs the finalisers with a reply context', async () => { + const contexts: unknown[] = []; + registerOutgoingFinalizer('f', 10, (bodies, context) => { + contexts.push(context); + return { html: bodies.html, text: `${bodies.text}\nFooter` }; + }); + const out = await prepareQuickReply(input(text)); + expect(out?.text).toMatch(/\nFooter$/); + expect(contexts).toEqual([expect.objectContaining({ surface: 'quick-reply', mode: 'reply', to: ['bob@example.com'], plainText: true })]); + }); + + it('sends nothing when a check says no', async () => { + registerPreSendCheck('no', 10, () => false); + expect(await prepareQuickReply(input(text))).toBeNull(); + }); +}); diff --git a/lib/outgoing/__tests__/send-pipeline.test.ts b/lib/outgoing/__tests__/send-pipeline.test.ts new file mode 100644 index 000000000..9492a1f39 --- /dev/null +++ b/lib/outgoing/__tests__/send-pipeline.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + finalizeOutgoing, + registerOutgoingFinalizer, + registerPreSendCheck, + resetSendPipeline, + runPreSendChecks, + type OutgoingContext, +} from '@/lib/outgoing/send-pipeline'; + +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); + +const CONTEXT: OutgoingContext = { + surface: 'composer', to: ['a@example.com'], cc: [], bcc: [], mode: 'compose', plainText: false, +}; + +afterEach(() => resetSendPipeline()); + +describe('finalizeOutgoing', () => { + it('runs finalisers in order and leaves the bodies alone without any', () => { + expect(finalizeOutgoing({ html: '

x

', text: 'x' }, CONTEXT)).toEqual({ html: '

x

', text: 'x' }); + registerOutgoingFinalizer('second', 20, (b) => ({ ...b, text: `${b.text}2` })); + registerOutgoingFinalizer('first', 10, (b) => ({ ...b, text: `${b.text}1` })); + expect(finalizeOutgoing({ text: 'x' }, CONTEXT).text).toBe('x12'); + }); + + it('replaces a finaliser registered again under the same id, and unregisters', () => { + registerOutgoingFinalizer('f', 10, (b) => ({ ...b, text: 'old' })); + const off = registerOutgoingFinalizer('f', 10, (b) => ({ ...b, text: 'new' })); + expect(finalizeOutgoing({ text: 'x' }, CONTEXT).text).toBe('new'); + off(); + expect(finalizeOutgoing({ text: 'x' }, CONTEXT).text).toBe('x'); + }); + + it('skips a finaliser that throws', () => { + registerOutgoingFinalizer('broken', 10, () => { throw new Error('boom'); }); + registerOutgoingFinalizer('ok', 20, (b) => ({ ...b, text: `${b.text}!` })); + expect(finalizeOutgoing({ text: 'x' }, CONTEXT).text).toBe('x!'); + }); +}); + +describe('runPreSendChecks', () => { + it('stops at the first check that says no', async () => { + const later = vi.fn(() => true); + registerPreSendCheck('no', 10, async () => false); + registerPreSendCheck('later', 20, later); + expect(await runPreSendChecks(CONTEXT)).toBe(false); + expect(later).not.toHaveBeenCalled(); + }); + + it('sends when every check agrees or there are none, and skips one that throws', async () => { + expect(await runPreSendChecks(CONTEXT)).toBe(true); + registerPreSendCheck('broken', 10, () => { throw new Error('boom'); }); + registerPreSendCheck('yes', 20, () => true); + expect(await runPreSendChecks(CONTEXT)).toBe(true); + }); +}); diff --git a/lib/outgoing/quick-reply.ts b/lib/outgoing/quick-reply.ts new file mode 100644 index 000000000..ce696325a --- /dev/null +++ b/lib/outgoing/quick-reply.ts @@ -0,0 +1,46 @@ +import type { Identity } from '@/lib/jmap/types'; +import { appendHtmlSignature, appendPlainTextSignature } from '@/lib/signature-utils'; +import { finalizeOutgoing, runPreSendChecks, type OutgoingBodies } from './send-pipeline'; + +/** + * The bodies a quick reply sends: the typed text with the signature, as + * HTML too when the signature is HTML, after the same pre-send checks and + * finalisers as the composer's send. Null when a check stopped the send. + */ +export async function prepareQuickReply(input: { + body: string; + signatureIdentity: Identity | null | undefined; + separator: boolean; + to: string[]; + fromEmail?: string; + identityId?: string; +}): Promise { + const { body, signatureIdentity, separator } = input; + const text = appendPlainTextSignature(body, signatureIdentity, { separator }); + + // When the identity has an HTML signature, send a matching HTML body so the + // signature keeps its formatting; appendPlainTextSignature would otherwise + // flatten it to plain text. Text-only identities keep the plain-text-only + // behavior (no HTML part). + const escapedBody = body + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\n/g, '
'); + const html = signatureIdentity?.htmlSignature?.trim() + ? appendHtmlSignature(`
${escapedBody}
`, signatureIdentity, { separator }) + : undefined; + + const context = { + surface: 'quick-reply' as const, + to: input.to, + cc: [], + bcc: [], + fromEmail: input.fromEmail, + identityId: input.identityId, + mode: 'reply' as const, + plainText: !html, + }; + if (!(await runPreSendChecks(context))) return null; + return finalizeOutgoing({ html, text }, context); +} diff --git a/lib/outgoing/send-pipeline.ts b/lib/outgoing/send-pipeline.ts new file mode 100644 index 000000000..bfe97765c --- /dev/null +++ b/lib/outgoing/send-pipeline.ts @@ -0,0 +1,115 @@ +/** + * One path for everything Bulwark does to a message on its way out, for + * every surface that sends: the composer and quick reply. Features register + * here instead of patching each send function: + * + * - pre-send checks run before anything is sent and may ask the user + * (the external-recipient confirm); + * - finalisers change the bodies that go out (the organisation signature + * check, the legal disclaimer). They run on sends only, never on drafts, + * so a re-opened draft never carries what they add. + * + * With nothing registered both are no-ops. + */ + +import { debug } from '@/lib/debug'; + +export type SendSurface = 'composer' | 'quick-reply'; + +export interface OutgoingContext { + surface: SendSurface; + /** Recipient addresses, without names. */ + to: string[]; + cc: string[]; + bcc: string[]; + fromEmail?: string; + identityId?: string; + /** compose, reply, replyAll or forward; quick reply is a reply. */ + mode: 'compose' | 'reply' | 'replyAll' | 'forward'; + /** Plain-text-only message (no HTML part). */ + plainText: boolean; +} + +export interface OutgoingBodies { + /** The HTML part; undefined for a plain-text-only message. */ + html?: string; + text: string; +} + +/** Changes the bodies; must return them unchanged when it has nothing to do. */ +export type OutgoingFinalizer = (bodies: OutgoingBodies, context: OutgoingContext) => OutgoingBodies; + +/** Resolves false to stop the send (after telling the user why, or asking them). */ +export type PreSendCheck = (context: OutgoingContext) => boolean | Promise; + +interface Entry { + id: string; + order: number; + run: T; +} + +const finalizers: Entry[] = []; +const checks: Entry[] = []; + +function register(list: Entry[], id: string, order: number, run: T): () => void { + const existing = list.findIndex((entry) => entry.id === id); + if (existing >= 0) list.splice(existing, 1); + list.push({ id, order, run }); + list.sort((a, b) => a.order - b.order); + return () => { + const index = list.findIndex((entry) => entry.id === id && entry.run === run); + if (index >= 0) list.splice(index, 1); + }; +} + +/** + * Adds a finaliser; lower `order` runs first. Registering the same id again + * replaces it. Returns the unregister function. + */ +export function registerOutgoingFinalizer(id: string, order: number, run: OutgoingFinalizer): () => void { + return register(finalizers, id, order, run); +} + +/** Adds a pre-send check; lower `order` asks first. Returns the unregister function. */ +export function registerPreSendCheck(id: string, order: number, run: PreSendCheck): () => void { + return register(checks, id, order, run); +} + +/** The bodies as they go out, after every finaliser. */ +export function finalizeOutgoing(bodies: OutgoingBodies, context: OutgoingContext): OutgoingBodies { + let current = bodies; + for (const entry of finalizers) { + try { + current = entry.run(current, context); + } catch (error) { + // A broken finaliser must not stop the mail; it goes out without its change. + debug.error(`Outgoing finaliser ${entry.id} failed:`, error); + } + } + return current; +} + +/** + * Runs the checks in order and stops at the first that says no. A check + * that throws is skipped: the checks warn, they don't guard, and a bug in + * one must not make sending impossible. + */ +export async function runPreSendChecks(context: OutgoingContext): Promise { + for (const entry of [...checks]) { + try { + if (!(await entry.run(context))) { + debug.log('email', `Send stopped by the pre-send check ${entry.id}`); + return false; + } + } catch (error) { + debug.error(`Pre-send check ${entry.id} failed:`, error); + } + } + return true; +} + +/** For tests: forget every registration. */ +export function resetSendPipeline(): void { + finalizers.length = 0; + checks.length = 0; +}