mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
refactor: send composer and quick reply mail through one send path
This commit is contained in:
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(/>/g, '>')
|
||||
.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 <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);
|
||||
});
|
||||
});
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user