refactor: send composer and quick reply mail through one send path

This commit is contained in:
Linus Rath
2026-10-28 01:08:56 +01:00
parent 084cf3cf3a
commit 42f52ab066
7 changed files with 557 additions and 20 deletions
@@ -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<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 ────────────────────────────────────────────────────────────────────
// 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: '<p>Hi</p>',
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}<p>Footer</p>`, text: `${bodies.text}\n\nFooter` };
});
const onSend = vi.fn();
render(<EmailComposer initialData={DRAFT} onSend={onSend} />);
clickSend();
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
const sent = onSend.mock.calls[0][0] as { htmlBody?: string; body: string };
expect(sent.htmlBody).toMatch(/<p>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(<EmailComposer initialData={DRAFT} onSend={onSend} />);
clickSend();
await waitFor(() => expect(check).toHaveBeenCalledTimes(1));
expect(onSend).not.toHaveBeenCalled();
allow = true;
clickSend();
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
});
});
+33 -4
View File
@@ -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
: `<div>${rewritten!.html}</div>${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 : `<div>${rewritten!.html}</div>${buildSignatureHtml()}`,
text: builtTextBody,
}, outgoingContext);
const inlineAttachments = rewritten?.attachments ?? [];
try {
+13 -16
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
const finalHtmlBody = signatureIdentity?.htmlSignature?.trim()
? appendHtmlSignature(`<div>${escapedBody}</div>`, 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);
@@ -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: '<b>Me</b>', 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('<div>Thanks &lt;3</div>');
expect(withHtml?.html).toContain('<b>Me</b>');
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();
});
});
@@ -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: '<p>x</p>', text: 'x' }, CONTEXT)).toEqual({ html: '<p>x</p>', 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);
});
});
+46
View File
@@ -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<OutgoingBodies | null> {
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
const html = signatureIdentity?.htmlSignature?.trim()
? appendHtmlSignature(`<div>${escapedBody}</div>`, 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);
}
+115
View File
@@ -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<boolean>;
interface Entry<T> {
id: string;
order: number;
run: T;
}
const finalizers: Entry<OutgoingFinalizer>[] = [];
const checks: Entry<PreSendCheck>[] = [];
function register<T>(list: Entry<T>[], 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<boolean> {
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;
}