From f9c53e55828a01ef200964c5541fc34bbf471bb0 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:45:12 +0200 Subject: [PATCH] feat: tell plugins when templates change or are inserted, and let them rewrite a template before it goes in --- .../composer-identity-template-hooks.test.tsx | 224 ++++++++++++++++++ components/email/email-composer.tsx | 19 +- .../__tests__/template-plugin-hooks.test.ts | 56 +++++ stores/template-store.ts | 44 ++++ 4 files changed, 339 insertions(+), 4 deletions(-) create mode 100644 components/email/__tests__/composer-identity-template-hooks.test.tsx create mode 100644 stores/__tests__/template-plugin-hooks.test.ts diff --git a/components/email/__tests__/composer-identity-template-hooks.test.tsx b/components/email/__tests__/composer-identity-template-hooks.test.tsx new file mode 100644 index 000000000..7ca0ae9d8 --- /dev/null +++ b/components/email/__tests__/composer-identity-template-hooks.test.tsx @@ -0,0 +1,224 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import { EmailComposer } from '../email-composer'; +import { useIdentityStore } from '@/stores/identity-store'; +import { clearAllHooks, identityHooks, templateHooks } from '@/lib/plugin-hooks'; + +const TEMPLATE = vi.hoisted(() => ({ + id: 'tpl-1', + name: 'Greeting', + category: '', + subject: 'Hello', + body: 'Dear reader', + isHTML: false, + isFavorite: false, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', +})); + +// ─── Heavy component mocks (mirrors reply-addressing.test.tsx) ──────────────── + +const editorKeyDown = vi.hoisted(() => vi.fn()); +const toastError = vi.hoisted(() => vi.fn()); + +vi.mock('@/components/email/rich-text-editor', () => ({ + RichTextEditor: () => React.createElement('div', { + 'data-testid': 'rich-text-editor', + onKeyDown: (event: React.KeyboardEvent) => { + if (!event.defaultPrevented) editorKeyDown(); + }, + }), +})); + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null })); +vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null })); +// The picker hands the composer a template, as choosing one does. +vi.mock('@/components/templates/template-picker', () => ({ + TemplatePicker: ({ onSelect }: { onSelect: (t: unknown, v: Record) => void }) => + React.createElement('button', { 'data-testid': 'pick-template', onClick: () => onSelect(TEMPLATE, {}) }, 'pick'), +})); +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/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 }; +}); + +const updateSetting = vi.fn(); + +vi.mock('@/stores/settings-store', () => { + const state = { + timeFormat: '24h', + plainTextMode: false, + subAddressDelimiter: '+', + autoSelectReplyIdentity: true, + attachmentReminderEnabled: false, + attachmentReminderKeywords: [], + emptySubjectWarningEnabled: true, + sendDelaySeconds: 0, + signaturePosition: 'above_quote', + signatureSeparatorEnabled: false, + requestReadReceiptDefault: false, + addTrustedSender: () => {}, + trustedSendersAddressBook: null, + updateSetting: (...args: unknown[]) => updateSetting(...args), + }; + 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, registerTemplateSyncBridge: () => {} }; +}); + +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 }; +}); + +// ─── Misc dependency mocks ──────────────────────────────────────────────────── + +vi.mock('@/stores/toast-store', () => ({ + toast: { info: () => {}, error: toastError, success: () => {} }, +})); + +vi.mock('@/lib/email-sanitization', () => ({ + sanitizeSignatureHtml: (v: string) => v, + sanitizeEmailHtml: (v: string) => v, + sanitizeSignatureHtmlForDisplay: (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/signature-utils', () => ({ + appendPlainTextSignature: (body: string) => body, + getPlainTextSignature: () => '', +})); +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', async (importOriginal) => ({ + ...(await importOriginal()), + substitutePlaceholders: (s: string) => s, +})); + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +const DATA = { + to: '', + cc: '', + bcc: '', + subject: '', + body: '', + showCc: false, + showBcc: false, + selectedIdentityId: 'id-me', + subAddressTag: '', + draftId: null, +}; + +const ME = { id: 'id-me', email: 'me@example.com', name: 'Me', mayDelete: false }; +const WORK = { id: 'id-work', email: 'work@example.com', name: 'Work', mayDelete: true, htmlSignature: 'W' }; + +describe('composer plugin hooks for identities and templates', () => { + beforeEach(() => { + useIdentityStore.setState({ identities: [ME, WORK] }); + }); + afterEach(() => clearAllHooks()); + + it('tells plugins which From identity the user picked', () => { + const selected = vi.fn(); + identityHooks.onIdentitySelect.register('test', selected); + render(); + + fireEvent.change(screen.getByTestId('composer-from'), { target: { value: 'id-work' } }); + + expect(selected).toHaveBeenCalledTimes(1); + expect(selected).toHaveBeenCalledWith(expect.objectContaining({ id: 'id-work', email: 'work@example.com', htmlSignature: 'W' })); + }); + + it('inserts the template as a plugin rendered it and reports it applied', async () => { + const render_ = vi.fn((value: { subject: string; html: string; text: string }, ctx: { templateId: string }) => { + expect(ctx).toEqual({ templateId: 'tpl-1' }); + return { ...value, subject: `[Plugin] ${value.subject}` }; + }); + const applied = vi.fn(); + templateHooks.onTemplateRender.register('test', render_); + templateHooks.onTemplateApply.register('test', applied); + render(); + + act(() => { fireEvent.keyDown(window, { key: 't' }); }); + fireEvent.click(await screen.findByTestId('pick-template')); + + await waitFor(() => expect(applied).toHaveBeenCalledTimes(1)); + expect(render_).toHaveBeenCalledWith( + { subject: 'Hello', html: '

Dear reader

', text: 'Dear reader' }, + { templateId: 'tpl-1' }, + ); + expect(applied).toHaveBeenCalledWith(expect.objectContaining({ id: 'tpl-1', name: 'Greeting' })); + expect(screen.getByDisplayValue('[Plugin] Hello')).toBeInTheDocument(); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 9c7e2cc38..bf44cd072 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -69,7 +69,7 @@ import { FilePickerDialog } from "@/components/files/file-picker-dialog"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; import type { FileNode } from "@/lib/jmap/types"; import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; -import { useTemplateStore } from "@/stores/template-store"; +import { useTemplateStore, renderTemplateWithPlugins, notifyTemplateApplied } from "@/stores/template-store"; import { notifyIdentitySelected } from "@/stores/identity-store"; import { usePluginSignature } from "@/hooks/use-plugin-signature"; import { SubAddressHelper } from "@/components/identity/sub-address-helper"; @@ -1587,8 +1587,8 @@ export function EmailComposer({ } }; - const handleTemplateSelect = useCallback((template: EmailTemplate, filledValues: Record) => { - const filledSubject = Object.keys(filledValues).length > 0 + const handleTemplateSelect = useCallback(async (template: EmailTemplate, filledValues: Record) => { + let filledSubject = Object.keys(filledValues).length > 0 ? substitutePlaceholders(template.subject, filledValues) : template.subject; const filledBody = Object.keys(filledValues).length > 0 @@ -1596,9 +1596,19 @@ export function EmailComposer({ : template.body; // In plain text mode, use template body as-is; otherwise convert to HTML - const bodyContent = plainTextMode || template.isHTML + let bodyContent = plainTextMode || template.isHTML ? filledBody : `

${filledBody.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}

`; + // Plugins may rewrite the template before it goes in (onTemplateRender). + const rendered = await renderTemplateWithPlugins(template.id, { + subject: filledSubject, + html: plainTextMode && !template.isHTML ? `

${escapeHtml(filledBody).replace(/\n/g, '
')}

` : bodyContent, + text: template.isHTML ? htmlToPlainText(filledBody) : filledBody, + }); + if (rendered) { + filledSubject = rendered.subject; + bodyContent = plainTextMode ? rendered.text : sanitizePluginBodyHtml(rendered.html); + } if (mode === 'compose') { // An empty template subject must not wipe one the user typed (#540). @@ -1683,6 +1693,7 @@ export function EmailComposer({ } setShowTemplatePicker(false); + notifyTemplateApplied(template); }, [mode, plainTextMode, signatureIdentity, signatureSeparatorEnabled]); useEffect(() => { diff --git a/stores/__tests__/template-plugin-hooks.test.ts b/stores/__tests__/template-plugin-hooks.test.ts new file mode 100644 index 000000000..a412182ac --- /dev/null +++ b/stores/__tests__/template-plugin-hooks.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useTemplateStore, renderTemplateWithPlugins } from '../template-store'; +import { clearAllHooks, templateHooks } from '@/lib/plugin-hooks'; + +const DATA = { name: 'Greeting', category: 'Work', subject: 'Hi', body: 'Hello', isHTML: false, isFavorite: false, placeholders: [] }; + +describe('template plugin hooks', () => { + beforeEach(() => { + useTemplateStore.setState({ templates: [], recentTemplateIds: [], deletedTemplateIds: {} }); + }); + afterEach(() => clearAllHooks()); + + it('reports created, duplicated, changed and deleted templates', () => { + const created = vi.fn(); + const updated = vi.fn(); + const deleted = vi.fn(); + templateHooks.onTemplateCreate.register('test', created); + templateHooks.onTemplateUpdate.register('test', updated); + templateHooks.onTemplateDelete.register('test', deleted); + const store = useTemplateStore.getState(); + + const t = store.addTemplate(DATA as never); + expect(created).toHaveBeenCalledWith({ id: t.id, name: 'Greeting', category: 'Work', subject: 'Hi', body: 'Hello', isHTML: false }); + + const copy = store.duplicateTemplate(t.id)!; + expect(created).toHaveBeenLastCalledWith(expect.objectContaining({ id: copy.id })); + + store.updateTemplate(t.id, { subject: 'Hey' }); + expect(updated).toHaveBeenCalledWith(expect.objectContaining({ id: t.id, subject: 'Hey' })); + + store.deleteTemplate(t.id); + store.deleteTemplate(t.id); + expect(deleted).toHaveBeenCalledTimes(1); + expect(deleted).toHaveBeenCalledWith({ templateId: t.id }); + }); + + it('reports how many templates an import added, and nothing for a failed one', () => { + const imported = vi.fn(); + templateHooks.onTemplatesImport.register('test', imported); + useTemplateStore.getState().importTemplates('not json'); + expect(imported).not.toHaveBeenCalled(); + + const json = JSON.stringify({ type: 'webmail-templates', version: 1, templates: [{ ...DATA, id: 'x' }] }); + const { count } = useTemplateStore.getState().importTemplates(json); + expect(count).toBe(1); + expect(imported).toHaveBeenCalledWith({ count: 1 }); + }); + + it('renders through plugins only when one listens, and keeps fields a plugin got wrong', async () => { + const input = { subject: 'Hi', html: '

Hello

', text: 'Hello' }; + expect(await renderTemplateWithPlugins('t1', input)).toBeNull(); + + templateHooks.onTemplateRender.register('test', (value: typeof input) => ({ ...value, html: '

Changed

', text: 42 })); + expect(await renderTemplateWithPlugins('t1', input)).toEqual({ subject: 'Hi', html: '

Changed

', text: 'Hello' }); + }); +}); diff --git a/stores/template-store.ts b/stores/template-store.ts index 894d0add6..ae3185496 100644 --- a/stores/template-store.ts +++ b/stores/template-store.ts @@ -11,6 +11,8 @@ import { } from '@/lib/template-utils'; import { generateUUID } from '@/lib/utils'; import { registerTemplateSyncBridge } from './settings-store'; +import { templateHooks } from '@/lib/plugin-hooks'; +import { templateToView } from '@/lib/plugin-projection'; const MAX_RECENT = 5; @@ -71,6 +73,7 @@ export const useTemplateStore = create()( set((state) => ({ templates: [...state.templates, template], })); + void templateHooks.onTemplateCreate.emit(templateToView(template)); return template; }, @@ -82,9 +85,11 @@ export const useTemplateStore = create()( : t ), })); + emitTemplateUpdate(id); }, deleteTemplate: (id) => { + if (!get().templates.some((t) => t.id === id)) return; set((state) => ({ templates: state.templates.filter((t) => t.id !== id), recentTemplateIds: state.recentTemplateIds.filter((rid) => rid !== id), @@ -93,6 +98,7 @@ export const useTemplateStore = create()( [id]: new Date().toISOString(), }, })); + void templateHooks.onTemplateDelete.emit({ templateId: id }); }, duplicateTemplate: (id, nameSuffix) => { @@ -113,6 +119,7 @@ export const useTemplateStore = create()( set((state) => ({ templates: [...state.templates, duplicate], })); + void templateHooks.onTemplateCreate.emit(templateToView(duplicate)); return duplicate; }, @@ -122,6 +129,7 @@ export const useTemplateStore = create()( t.id === id ? { ...t, isFavorite: !t.isFavorite, updatedAt: new Date().toISOString() } : t ), })); + emitTemplateUpdate(id); }, recordUsage: (id) => { @@ -169,6 +177,7 @@ export const useTemplateStore = create()( set((state) => ({ templates: [...state.templates, ...result.templates], })); + void templateHooks.onTemplatesImport.emit({ count: result.templates.length }); } return { count: result.templates.length, errors: result.errors }; }, @@ -200,6 +209,41 @@ export const useTemplateStore = create()( ) ); +function emitTemplateUpdate(id: string): void { + const template = useTemplateStore.getState().templates.find((t) => t.id === id); + if (template) void templateHooks.onTemplateUpdate.emit(templateToView(template)); +} + +export interface RenderedTemplate { + subject: string; + html: string; + text: string; +} + +/** + * Hands a template about to be inserted into the composer to the + * onTemplateRender plugins. Returns null when no plugin listens, so the + * composer keeps its own rendering; otherwise the plugins' version, with any + * field a plugin returned as a non-string kept from the input. + */ +export async function renderTemplateWithPlugins( + templateId: string, + rendered: RenderedTemplate, +): Promise { + if (templateHooks.onTemplateRender.size === 0) return null; + const result = await templateHooks.onTemplateRender.transform(rendered, { templateId }) as Partial | null; + return { + subject: typeof result?.subject === 'string' ? result.subject : rendered.subject, + html: typeof result?.html === 'string' ? result.html : rendered.html, + text: typeof result?.text === 'string' ? result.text : rendered.text, + }; +} + +/** Tells plugins a template was inserted into the composer. */ +export function notifyTemplateApplied(template: EmailTemplate): void { + void templateHooks.onTemplateApply.emit(templateToView(template)); +} + // Every tab keeps its own in-memory copy and writes the whole list back to // localStorage on any change, so a second tab that never saw a template // created in the first one would overwrite it (and push the shorter list).