From 1512b9afc0d78adcd99112098eff35ecc6f088a0 Mon Sep 17 00:00:00 2001 From: dealerweb Date: Fri, 29 May 2026 13:09:16 +0200 Subject: [PATCH] Feature: editable layout-preserving quote island Replying to / forwarding a layout-heavy HTML email (nested tables, MJML, Outlook divs) destroyed its layout: ProseMirror re-parsed the quoted body through its strict schema and discarded anything that didn't fit. The quoted original is now held verbatim in a new atomic QuotedHtml node and never parsed into the schema; its NodeView renders inside a shadow root so app CSS can't cascade in and the in-editor view matches the sent mail 1:1. - quoted-html.ts (new): QuotedHtml atom node + shadow-DOM NodeView (inner contentEditable for redaction), serializeEditorContent(), buildQuotedHtmlBlock(). - rich-text-editor: register the node; emit via serializeEditorContent (not getHTML) so the verbatim island survives. - composer: both HTML reply/forward paths embed the original as an island (sanitize -> cid-rewrite -> buildQuotedHtmlBlock); the signature-swap effect serializes via serializeEditorContent and treats the island as a quote boundary so the splice never cuts into the quoted body. atom:true means Backspace at the boundary / Ctrl+A+Delete removes the whole quote in one go. --- components/email/email-composer.tsx | 52 ++++--- components/email/quoted-html.ts | 187 ++++++++++++++++++++++++++ components/email/rich-text-editor.tsx | 14 +- 3 files changed, 235 insertions(+), 18 deletions(-) create mode 100644 components/email/quoted-html.ts diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index fe733607..9df87f82 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -9,8 +9,9 @@ import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, Bookma import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils"; import { debug } from "@/lib/debug"; import { toast } from "@/stores/toast-store"; -import { sanitizeSignatureHtml } from "@/lib/email-sanitization"; +import { sanitizeSignatureHtml, sanitizeEmailHtml } from "@/lib/email-sanitization"; import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix"; +import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html"; import { emailHooks, contactHooks } from "@/lib/plugin-hooks"; import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types"; import { useAuthStore } from "@/stores/auth-store"; @@ -358,15 +359,22 @@ export function EmailComposer({ // Plugin override (resolved at composer open via onBuildQuoteHeader). if (replyTo.quoteHeaderHtml !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) { + if (replyTo.htmlBody) { + // Layout-heavy original: embed verbatim as a QuotedHtml island so + // nested tables / MJML survive 1:1 (sanitize strips scripts/styles + // first; cid rewrite runs after so its data-cid markers survive). + const island = buildQuotedHtmlBlock( + rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody)) + ); + return `${prefix}${signatureBlock}
${replyTo.quoteHeaderHtml}${island}`; + } + const escaped = replyTo.body + ? replyTo.body.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
') + : ''; const wrap = replyTo.quoteWrapInBlockquote !== false; - const originalHtml = replyTo.htmlBody - ? rewriteCidImagesForEditor(replyTo.htmlBody) - : (replyTo.body - ? replyTo.body.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
') - : ''); const bodyHtml = wrap - ? `
${originalHtml}
` - : originalHtml; + ? `
${escaped}
` + : escaped; return `${prefix}${signatureBlock}
${replyTo.quoteHeaderHtml}${bodyHtml}`; } @@ -375,10 +383,14 @@ export function EmailComposer({ const quoteHeader = mode === 'forward' ? `${tQuote('forwarded_separator')}
${tQuote('from_label')}: ${fromStrFull}
${tQuote('date_label')}: ${date}
${tQuote('subject_label')}: ${replyTo.subject || ''}

` : `${tQuote('reply_line', { date, from: fromStr })}
`; - // cid: image refs are rewritten so they render in the editor (browsers - // can't fetch cid: URLs); see useEffect below for the data-URL backfill. - const quotedHtml = rewriteCidImagesForEditor(replyTo.htmlBody); - return `${prefix}${signatureBlock}
${quoteHeader}
${quotedHtml}
`; + // Embed the original as a QuotedHtml island (verbatim, schema-free) so + // its layout survives the editor round-trip. Sanitize first to strip + // scripts/styles/head; cid rewrite afterwards so data-cid markers + // aren't dropped by the sanitizer's ALLOW_DATA_ATTR:false. + const island = buildQuotedHtmlBlock( + rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody)) + ); + return `${prefix}${signatureBlock}
${quoteHeader}
${island}`; } if (replyTo.body) { @@ -518,7 +530,9 @@ export function EmailComposer({ if (!isReplyLike && mode !== 'compose') return; if (isReplyLike && signaturePosition !== 'above_quote') return; - const currentHtml = editor.getHTML(); + // serializeEditorContent (not getHTML) so a QuotedHtml island's verbatim + // body isn't lost during the signature splice + setContent round-trip. + const currentHtml = serializeEditorContent(editor); const doc = new DOMParser().parseFromString(currentHtml, 'text/html'); const startEl = doc.querySelector('[data-signature-block="separator"], [data-signature-block="start"]'); if (!startEl) return; @@ -540,15 +554,23 @@ export function EmailComposer({ if (!parent) return; // Remove the existing signature range [startEl … endEl] inclusive, or - // from startEl to the next blockquote if no end marker is present. + // from startEl to the next quote boundary if no end marker is present. + // The quote boundary is either a legacy
or the QuotedHtml + // island wrapper (
) - stop before either so the + // signature splice never eats into the quoted body. const removeUntil = endEl && endEl.parentNode === parent ? endEl : null; + const isQuoteBoundary = (n: Node | null): boolean => { + if (!n || n.nodeType !== 1) return false; + const el = n as Element; + return el.tagName === 'BLOCKQUOTE' || el.hasAttribute('data-quoted-html'); + }; const toRemove: Node[] = []; let cursor: Node | null = startEl; while (cursor) { toRemove.push(cursor); if (cursor === removeUntil) break; const next: Node | null = cursor.nextSibling; - if (!removeUntil && next && (next as Element).tagName === 'BLOCKQUOTE') break; + if (!removeUntil && isQuoteBoundary(next)) break; cursor = next; } const insertBefore = toRemove[toRemove.length - 1]?.nextSibling ?? null; diff --git a/components/email/quoted-html.ts b/components/email/quoted-html.ts new file mode 100644 index 00000000..badf6481 --- /dev/null +++ b/components/email/quoted-html.ts @@ -0,0 +1,187 @@ +"use client"; + +import { Node as TiptapNode, mergeAttributes } from "@tiptap/core"; +import { DOMSerializer } from "@tiptap/pm/model"; +import type { Editor } from "@tiptap/react"; + +// Marker attribute that identifies the quoted-original wrapper in serialized +// HTML, so parseHTML can recognise it on the way back in. +export const QUOTED_HTML_MARKER = "data-quoted-html"; + +/** + * QuotedHtml — an atomic block node that carries the *verbatim* HTML of a + * quoted/forwarded original email. The HTML is stored in the `html` attribute + * and is NEVER parsed into the ProseMirror schema, so layout-heavy emails + * (nested tables, MJML, Outlook divs) survive a reply/forward 1:1. + * + * Behaviour: + * - To ProseMirror it's a single atomic block: Backspace at the boundary or + * Ctrl+A + Delete removes the whole quote in one go ("wie es sich gehört"). + * - Its NodeView renders an inner `contentEditable` region so the user can + * still redact text inside the quote. Inner edits are synced back into the + * `html` attribute (without polluting the undo history). + * + * Serialization: ProseMirror's DOM serializer can't emit an atom's inner raw + * HTML, so use `serializeEditorContent(editor)` (below) instead of + * `editor.getHTML()` to read the composer body for sending/draft-saving. + */ +export const QuotedHtml = TiptapNode.create({ + name: "quotedHtml", + group: "block", + atom: true, + selectable: true, + draggable: false, + // Isolating keeps selection/gapcursor behaviour sane at the boundary. + isolating: true, + + addAttributes() { + return { + html: { + default: "", + // Capture the verbatim inner HTML when parsing. Because the node is + // an atom, ProseMirror does NOT descend into the children, so they + // never hit the schema. + parseHTML: (el) => el.innerHTML, + // Not rendered as an attribute - the real content round-trips via the + // custom serializer. renderHTML below only needs the wrapper. + renderHTML: () => ({}), + }, + }; + }, + + parseHTML() { + return [{ tag: `div[${QUOTED_HTML_MARKER}]` }]; + }, + + renderHTML({ HTMLAttributes }) { + // Only used for ProseMirror's internal/clipboard round-trip. The send / + // draft path uses serializeEditorContent() which inlines attrs.html. + return ["div", mergeAttributes(HTMLAttributes, { [QUOTED_HTML_MARKER]: "" })]; + }, + + addNodeView() { + return ({ node, editor, getPos }) => { + // Host element ProseMirror manages. A subtle left border echoes the + // classic email quote bar without touching the quoted content's styling. + const dom = document.createElement("div"); + dom.setAttribute(QUOTED_HTML_MARKER, ""); + dom.className = "quoted-html-island"; + dom.style.cssText = + "border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;"; + + // CRITICAL: render the quoted email inside a Shadow Root. The app's + // global CSS (Tailwind preflight, .tiptap table/td rules, box-sizing + // resets) would otherwise cascade INTO the quote and destroy its layout + // - even though the verbatim HTML serializes/sends perfectly. Shadow DOM + // isolates both directions: only the browser's UA defaults + the email's + // own inline styles apply, so the in-editor rendering matches the sent + // mail 1:1. + const shadow = dom.attachShadow({ mode: "open" }); + const inner = document.createElement("div"); + inner.contentEditable = "true"; + inner.style.cssText = "outline:none;"; + inner.innerHTML = node.attrs.html || ""; + shadow.appendChild(inner); + + // Track focus via focusin/focusout: inside a shadow root, + // document.activeElement is retargeted to the host, so we can't rely on + // it to detect "is the user editing in here". + let focused = false; + inner.addEventListener("focusin", () => { + focused = true; + }); + inner.addEventListener("focusout", () => { + focused = false; + }); + + // Sync inner edits back into the node attribute. Coalesced via rAF so a + // burst of keystrokes is one transaction; addToHistory:false keeps + // redaction edits out of the editor's undo stack. The `input` event is + // composed and crosses the shadow boundary, so this listener fires. + let frame = 0; + const syncBack = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + if (typeof getPos !== "function") return; + const pos = getPos(); + if (pos == null) return; + const current = inner.innerHTML; + if (current === node.attrs.html) return; + editor.view.dispatch( + editor.view.state.tr + .setNodeAttribute(pos, "html", current) + .setMeta("addToHistory", false) + ); + }); + }; + inner.addEventListener("input", syncBack); + + return { + dom, + // ProseMirror must not try to reconcile the foreign shadow content. + ignoreMutation: () => true, + // Events originating inside the island are retargeted to the host + // (`dom`) once they cross the shadow boundary, so dom.contains(target) + // is true for them → let the native shadow contentEditable handle + // them. Events from the surrounding doc (boundary Backspace, + // Ctrl+A+Delete) target other elements → fall through to ProseMirror + // so whole-block deletion still works. + stopEvent: (event) => { + const target = event.target as Node | null; + return !!target && dom.contains(target); + }, + update: (updatedNode) => { + if (updatedNode.type.name !== "quotedHtml") return false; + // Don't clobber the caret while the user is redacting inside. + if (!focused && inner.innerHTML !== updatedNode.attrs.html) { + inner.innerHTML = updatedNode.attrs.html || ""; + } + return true; + }, + destroy: () => { + cancelAnimationFrame(frame); + inner.removeEventListener("input", syncBack); + }, + }; + }; + }, +}); + +/** + * Serialize the composer document to HTML for sending / draft-saving. + * + * Use this INSTEAD of `editor.getHTML()`: ProseMirror's DOM serializer cannot + * emit the raw inner HTML of an atom node, so it would drop the quoted body. + * Here we walk the top-level nodes, inline the quote node's verbatim `html`, + * and serialize everything else normally. + */ +export function serializeEditorContent(editor: Editor): string { + const serializer = DOMSerializer.fromSchema(editor.schema); + const parts: string[] = []; + editor.state.doc.forEach((node) => { + if (node.type.name === "quotedHtml") { + // Emit the SAME wrapper buildQuotedHtmlBlock produces, so a saved draft + // round-trips: re-opening parses this back into a QuotedHtml node + // instead of letting the schema mangle the raw table layout again. + parts.push(buildQuotedHtmlBlock((node.attrs.html as string) || "")); + return; + } + const fragment = serializer.serializeNode(node); + const tmp = document.createElement("div"); + tmp.appendChild(fragment); + parts.push(tmp.innerHTML); + }); + return parts.join(""); +} + +/** + * Build the editor-content wrapper that the composer prepends/appends so the + * quoted original becomes a single QuotedHtml node. The inner HTML must be + * pre-sanitized (scripts/styles/head stripped, cid: images rewritten). + * + * The `data-quoted-html` marker is what parseHTML keys on, so this exact form + * must be what serializeEditorContent emits too (round-trip consistency). + */ +export function buildQuotedHtmlBlock(sanitizedInnerHtml: string): string { + return `
${sanitizedInnerHtml}
`; +} diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx index 0588d107..d4ed4637 100644 --- a/components/email/rich-text-editor.tsx +++ b/components/email/rich-text-editor.tsx @@ -16,6 +16,7 @@ import { Table } from "@tiptap/extension-table"; import { TableRow } from "@tiptap/extension-table-row"; import { TableHeader } from "@tiptap/extension-table-header"; import { TableCell } from "@tiptap/extension-table-cell"; +import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-html"; import { cn } from "@/lib/utils"; import { Bold, @@ -231,6 +232,9 @@ export function RichTextEditor({ style: "padding:6px 8px;border:1px solid #ccc;vertical-align:top;", }, }), + // Quoted/forwarded original email body - held verbatim as an atomic + // node so layout-heavy HTML survives 1:1 (see quoted-html.ts). + QuotedHtml, ], content, editorProps: { @@ -281,14 +285,18 @@ export function RichTextEditor({ }, }, onUpdate: ({ editor }) => { - onChange(editor.getHTML()); + // serializeEditorContent (not getHTML) so the verbatim quoted-original + // HTML held in the QuotedHtml atom node is emitted intact. + onChange(serializeEditorContent(editor)); }, immediatelyRender: false, }); - // Sync external content changes (e.g. template application) + // Sync external content changes (e.g. template application). Compare against + // the custom serialization so a doc that only differs inside a QuotedHtml + // island isn't needlessly re-parsed (which would reset the island DOM). useEffect(() => { - if (editor && content !== editor.getHTML()) { + if (editor && content !== serializeEditorContent(editor)) { editor.commands.setContent(content, { emitUpdate: false }); } }, [content, editor]);