From cf9292262d63e52ddc534b5297bacdf8d3fa42f0 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 18 May 2026 17:53:23 +0200 Subject: [PATCH] feat: pluggable reply/forward quote header #295 --- app/[locale]/page.tsx | 73 +++++++++++++++++++++++-- components/email/email-composer.tsx | 27 ++++++++++ lib/plugin-hooks.ts | 6 +++ lib/plugin-types.ts | 46 ++++++++++++++++ lib/quote-header.ts | 82 +++++++++++++++++++++++++++++ 5 files changed, 231 insertions(+), 3 deletions(-) create mode 100644 lib/quote-header.ts diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index e1256d3d..bdcf9b2a 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -67,6 +67,9 @@ import type { ParsedMailto } from "@/lib/protocol-handlers/mailto"; import { plainTextToComposerBody } from "@/lib/email-composer-utils"; import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks"; import { emailToReadView } from "@/lib/plugin-projection"; +import { buildQuoteHeader } from "@/lib/quote-header"; +import { useLocaleStore } from "@/stores/locale-store"; +import type { QuoteHeader } from "@/lib/plugin-types"; export default function Home() { @@ -79,6 +82,9 @@ export default function Home() { const [composerDraftText, setComposerDraftText] = useState(""); const [pendingDraft, setPendingDraft] = useState(null); const [composerSessionId, setComposerSessionId] = useState(0); + // Plugin-resolved quote header for the next reply/forward composer open. + // Cleared on close so a subsequent "compose new" doesn't reuse stale state. + const [composerQuoteHeader, setComposerQuoteHeader] = useState(null); const suppressComposerStateSaveSessionRef = useRef(null); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog(); @@ -1000,6 +1006,50 @@ export default function Home() { } }; + // Build the quote header for a reply/forward open, running it through the + // emailHooks.onBuildQuoteHeader transform so plugins can replace it. Stores + // the result in composerQuoteHeader; the render site spreads it into + // EmailComposer.replyTo. Errors fall back to the composer's built-in + // header (state set to null). + const prepareComposerQuoteHeader = useCallback(async ( + email: Email | null, + mode: 'reply' | 'replyAll' | 'forward', + ) => { + if (!email) { setComposerQuoteHeader(null); return; } + try { + const replyTargets = (email.replyTo?.length + ? email.replyTo + : email.from ?? []).filter(r => r.email).map(r => r.email!); + const newTo = mode === 'reply' + ? replyTargets + : mode === 'replyAll' + ? [...replyTargets, ...(email.to ?? []).filter(r => r.email).map(r => r.email!)] + : []; + const newCc = mode === 'replyAll' + ? (email.cc ?? []).filter(r => r.email).map(r => r.email!) + : []; + const header = await buildQuoteHeader({ + mode, + email: { + from: email.from, + to: email.to, + cc: email.cc, + subject: email.subject, + receivedAt: email.receivedAt, + }, + newTo, + newCc, + locale: useLocaleStore.getState().locale, + timeFormat: useSettingsStore.getState().timeFormat, + unknownLabel: tCommon('unknown'), + }); + setComposerQuoteHeader(header); + } catch (err) { + console.warn('[quote-header] plugin transform failed; using default', err); + setComposerQuoteHeader(null); + } + }, [tCommon]); + const handleReply = async (draftText?: string) => { if (selectedEmail) { const ok = await emailHooks.onBeforeReply.intercept({ @@ -1008,6 +1058,9 @@ export default function Home() { mode: 'reply' as const, }); if (!ok) return; + await prepareComposerQuoteHeader(selectedEmail, 'reply'); + } else { + setComposerQuoteHeader(null); } setComposerDraftText(draftText || ""); setComposerMode('reply'); @@ -1075,6 +1128,9 @@ export default function Home() { mode: 'reply-all' as const, }); if (!ok) return; + await prepareComposerQuoteHeader(selectedEmail, 'replyAll'); + } else { + setComposerQuoteHeader(null); } setComposerMode('replyAll'); setShowComposer(true); @@ -1089,6 +1145,9 @@ export default function Home() { mode: 'forward' as const, }); if (!ok) return; + await prepareComposerQuoteHeader(selectedEmail, 'forward'); + } else { + setComposerQuoteHeader(null); } setComposerMode('forward'); setShowComposer(true); @@ -1917,22 +1976,25 @@ export default function Home() { }; // Handle reply from conversation view - const handleConversationReply = (email: Email) => { + const handleConversationReply = async (email: Email) => { selectEmail(email); + await prepareComposerQuoteHeader(email, 'reply'); setComposerMode('reply'); setShowComposer(true); if (isMobile) setActiveView('viewer'); }; - const handleConversationReplyAll = (email: Email) => { + const handleConversationReplyAll = async (email: Email) => { selectEmail(email); + await prepareComposerQuoteHeader(email, 'replyAll'); setComposerMode('replyAll'); setShowComposer(true); if (isMobile) setActiveView('viewer'); }; - const handleConversationForward = (email: Email) => { + const handleConversationForward = async (email: Email) => { selectEmail(email); + await prepareComposerQuoteHeader(email, 'forward'); setComposerMode('forward'); setShowComposer(true); if (isMobile) setActiveView('viewer'); @@ -2435,6 +2497,7 @@ export default function Home() { onReset={() => { setShowComposer(false); setComposerMode('compose'); + setComposerQuoteHeader(null); }} > /g, '>').replace(/\n/g, '
') + : ''); + const bodyHtml = wrap + ? `
${originalHtml}
` + : originalHtml; + return `${prefix}${signatureBlock}
${replyTo.quoteHeaderHtml}${bodyHtml}`; + } + // Build quoted content as HTML if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) { const quoteHeader = mode === 'forward' diff --git a/lib/plugin-hooks.ts b/lib/plugin-hooks.ts index 4dc57061..7f9bfd7e 100644 --- a/lib/plugin-hooks.ts +++ b/lib/plugin-hooks.ts @@ -222,6 +222,12 @@ export const emailHooks = { onBeforeReply: new HookBus(), onBeforeReplyAll: new HookBus(), onBeforeForward: new HookBus(), + // Transform hook - lets plugins replace the quote header block ("On X, + // Y wrote:" / "---------- Forwarded message ----------") used when opening + // a reply or forward. Initial value: QuoteHeader (host default), second + // argument: QuoteHeaderContext. Handlers return a QuoteHeader (or + // undefined to pass through). Fires once per composer open. + onBuildQuoteHeader: new HookBus(), // Intercept hook fired before a file is added to the composer as an // attachment. Handler receives AttachmentInfo (size/type/name only - the // raw file is not exposed). Return false to refuse the upload. diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index ec664381..1eec2ddf 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -645,6 +645,52 @@ export interface ReplyContext { mode: 'reply' | 'reply-all' | 'forward'; } +/** + * Second argument to onBuildQuoteHeader transform handlers. Describes the + * original message and how the host plans to render the quote header so + * plugins can produce a replacement block (e.g. an Outlook-style + * From/Sent/To/Cc/Subject section). + */ +export interface QuoteHeaderContext { + mode: 'reply' | 'replyAll' | 'forward'; + /** Recipients of the new outgoing message (already resolved by the host). */ + newTo: string[]; + newCc: string[]; + /** Original message metadata. */ + from: { name?: string; email: string } | null; + to: { name?: string; email: string }[]; + cc: { name?: string; email: string }[]; + subject: string; + /** Pre-formatted date string the host already produced (locale-aware). */ + date: string; + /** Raw ISO datetime, in case the plugin wants to reformat. */ + receivedAt?: string; + /** Active UI locale (BCP-47), useful for Intl.DateTimeFormat in plugins. */ + locale: string; +} + +/** + * Initial value for the onBuildQuoteHeader transform hook. Plugins return a + * replacement; returning undefined falls through to the next handler or the + * default. The composer splices `html` into HTML drafts and `text` into + * plain-text drafts. + * + * For HTML, returning a header that includes its own surrounding wrapper + * (`
...
`) is fine; the composer does not add extra wrappers. + * For text, the host appends the quoted body after the header. + */ +export interface QuoteHeader { + html: string; + text: string; + /** + * When false, the composer skips its default blockquote wrapping around the + * quoted body (HTML mode only). Use this for the Outlook style where the + * quoted message is intended to follow the header without indentation. + * Defaults to true (preserve the existing blockquote wrapping). + */ + wrapInBlockquote?: boolean; +} + /** * Describes an attachment crossing an attachment hook (upload, download, preview). */ diff --git a/lib/quote-header.ts b/lib/quote-header.ts new file mode 100644 index 00000000..786abfc8 --- /dev/null +++ b/lib/quote-header.ts @@ -0,0 +1,82 @@ +// Builds the default reply/forward quote header and runs it through the +// emailHooks.onBuildQuoteHeader transform so plugins can replace it (e.g. +// with an Outlook-style From/Sent/To/Cc/Subject block). +// +// This module is the single source of truth for the default header strings - +// the composer keeps the same defaults inline as a fallback, but production +// flow goes through here. + +import { formatDateTime } from "@/lib/utils"; +import { emailHooks } from "@/lib/plugin-hooks"; +import type { QuoteHeader, QuoteHeaderContext } from "@/lib/plugin-types"; + +interface BuildArgs { + mode: "reply" | "replyAll" | "forward"; + email: { + from?: { email?: string; name?: string }[]; + to?: { email?: string; name?: string }[]; + cc?: { email?: string; name?: string }[]; + subject?: string; + receivedAt?: string; + }; + newTo: string[]; + newCc: string[]; + locale: string; + timeFormat: "12h" | "24h"; + unknownLabel: string; +} + +function defaultHeader(args: BuildArgs): QuoteHeader { + const { mode, email, timeFormat, unknownLabel } = args; + const date = email.receivedAt + ? formatDateTime(email.receivedAt, timeFormat, { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + }) + : ""; + const from = email.from?.[0]; + const fromStr = from ? `${from.name || from.email}` : unknownLabel; + const subject = email.subject || ""; + + if (mode === "forward") { + const text = `---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${subject}\n`; + const html = `
---------- Forwarded message ----------
From: ${fromStr}
Date: ${date}
Subject: ${subject}

`; + return { html, text, wrapInBlockquote: false }; + } + + const text = `On ${date}, ${fromStr} wrote:\n`; + const html = `
On ${date}, ${fromStr} wrote:
`; + return { html, text, wrapInBlockquote: true }; +} + +export async function buildQuoteHeader(args: BuildArgs): Promise { + const def = defaultHeader(args); + const ctx: QuoteHeaderContext = { + mode: args.mode, + newTo: args.newTo, + newCc: args.newCc, + from: args.email.from?.[0]?.email + ? { name: args.email.from[0].name, email: args.email.from[0].email } + : null, + to: (args.email.to ?? []) + .filter((r): r is { email: string; name?: string } => !!r.email) + .map((r) => ({ name: r.name, email: r.email })), + cc: (args.email.cc ?? []) + .filter((r): r is { email: string; name?: string } => !!r.email) + .map((r) => ({ name: r.name, email: r.email })), + subject: args.email.subject ?? "", + date: args.email.receivedAt + ? formatDateTime(args.email.receivedAt, args.timeFormat, { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + }) + : "", + receivedAt: args.email.receivedAt, + locale: args.locale, + }; + return emailHooks.onBuildQuoteHeader.transform(def, ctx); +}