diff --git a/components/email/__tests__/signature-block.test.ts b/components/email/__tests__/signature-block.test.ts new file mode 100644 index 00000000..a034cd82 --- /dev/null +++ b/components/email/__tests__/signature-block.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { Editor } from '@tiptap/core'; +import StarterKit from '@tiptap/starter-kit'; + +import { SignatureBlock, buildSignatureBlock, SIGNATURE_BLOCK_MARKER } from '../signature-block'; +import { serializeEditorContent } from '../quoted-html'; + +describe('signature-block', () => { + it('buildSignatureBlock wraps html in the marker div', () => { + expect(buildSignatureBlock('x')).toBe(`
x
`); + }); + + it('preserves an inline-styled signature through parse + serialize (no schema flattening)', () => { + const styled = + '' + + '' + + '
MV
'; + const editor = new Editor({ + element: document.createElement('div'), + extensions: [StarterKit, SignatureBlock], + content: `

Hello

${buildSignatureBlock(styled)}`, + }); + try { + const out = serializeEditorContent(editor); + // Original inline styling survives - it is NOT re-parsed into the schema. + expect(out).toContain('background:#0a0e16'); + expect(out).toContain('border-radius:8px'); + expect(out).toContain('color:#c6f24e'); + expect(out).toContain(SIGNATURE_BLOCK_MARKER); + // Surrounding body is preserved. + expect(out).toContain('Hello'); + // The signature did not get the editor's generic table styling. + expect(out).not.toContain('rgb(204, 204, 204)'); + } finally { + editor.destroy(); + } + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 6a7a1a62..be2d23e2 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -15,6 +15,7 @@ import { sanitizeSignatureHtml, sanitizeEmailHtml } from "@/lib/email-sanitizati import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix"; import { isFilePreviewable } from "@/lib/file-preview"; import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html"; +import { buildSignatureBlock } from "@/components/email/signature-block"; import { emailHooks, contactHooks } from "@/lib/plugin-hooks"; import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types"; import { useAuthStore } from "@/stores/auth-store"; @@ -188,11 +189,13 @@ type SignatureIdentityLike = { textSignature?: string; } | null | undefined; -// Render the embedded signature for "above quote" mode. Bracketed with -// `data-signature-block` marker paragraphs so we can swap the inner content -// when the user switches identity without losing the surrounding draft or -// quoted message. The markers are preserved through TipTap by the -// StyledParagraph extension. +// Render the embedded signature. Bracketed with `data-signature-block` marker +// paragraphs so we can swap the inner content when the user switches identity +// without losing the surrounding draft or quoted message. The markers are +// preserved through TipTap by the StyledParagraph extension. The HTML +// signature itself is wrapped in a SignatureBlock atom node so its inline +// styling survives the editor (see signature-block.ts) instead of being +// flattened by the schema. function buildEmbeddedSignatureHtml( identity: SignatureIdentityLike, options: { embed: boolean; separator: boolean } @@ -203,7 +206,7 @@ function buildEmbeddedSignatureHtml( : `

`; const endMarker = `

`; if (identity?.htmlSignature) { - return `${startMarker}${sanitizeSignatureHtml(identity.htmlSignature)}${endMarker}`; + return `${startMarker}${buildSignatureBlock(sanitizeSignatureHtml(identity.htmlSignature))}${endMarker}`; } if (identity?.textSignature) { const escaped = identity.textSignature diff --git a/components/email/quoted-html.ts b/components/email/quoted-html.ts index badf6481..edd5b44b 100644 --- a/components/email/quoted-html.ts +++ b/components/email/quoted-html.ts @@ -4,6 +4,8 @@ import { Node as TiptapNode, mergeAttributes } from "@tiptap/core"; import { DOMSerializer } from "@tiptap/pm/model"; import type { Editor } from "@tiptap/react"; +import { buildSignatureBlock } from "@/components/email/signature-block"; + // 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"; @@ -166,6 +168,13 @@ export function serializeEditorContent(editor: Editor): string { parts.push(buildQuotedHtmlBlock((node.attrs.html as string) || "")); return; } + if (node.type.name === "signatureBlock") { + // Same rationale as quotedHtml: inline the verbatim signature HTML so the + // styled signature reaches the recipient (and a saved draft round-trips) + // instead of the schema-flattened version. + parts.push(buildSignatureBlock((node.attrs.html as string) || "")); + return; + } const fragment = serializer.serializeNode(node); const tmp = document.createElement("div"); tmp.appendChild(fragment); diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx index d4ed4637..936e90f5 100644 --- a/components/email/rich-text-editor.tsx +++ b/components/email/rich-text-editor.tsx @@ -17,6 +17,7 @@ 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 { SignatureBlock } from "@/components/email/signature-block"; import { cn } from "@/lib/utils"; import { Bold, @@ -235,6 +236,10 @@ export function RichTextEditor({ // Quoted/forwarded original email body - held verbatim as an atomic // node so layout-heavy HTML survives 1:1 (see quoted-html.ts). QuotedHtml, + // Identity signature - held verbatim as a non-editable atomic node so + // rich/branded signatures keep their inline styling in the editor and + // in the sent mail (see signature-block.ts). + SignatureBlock, ], content, editorProps: { diff --git a/components/email/signature-block.ts b/components/email/signature-block.ts new file mode 100644 index 00000000..231b9f6a --- /dev/null +++ b/components/email/signature-block.ts @@ -0,0 +1,105 @@ +"use client"; + +import { Node as TiptapNode, mergeAttributes } from "@tiptap/core"; + +// Marker attribute that identifies the signature wrapper in serialized HTML, +// so parseHTML can recognise it on the way back in (initial content, drafts). +export const SIGNATURE_BLOCK_MARKER = "data-signature-block-node"; + +/** + * SignatureBlock — an atomic, NON-editable block node that carries the + * *verbatim* HTML of the user's identity signature in its `html` attribute. + * + * Why: the signature is embedded into the composer so it stays in the body, + * but parsing rich, table-based "brand" signatures into the ProseMirror schema + * strips their inline CSS (background/text colors, fonts, border-radius). By + * holding the signature as an atom it is never parsed into the schema, so the + * styling survives 1:1 — both in the editor (rendered by the NodeView below) + * and in the sent mail (emitted by serializeEditorContent in quoted-html.ts). + * + * Mirrors QuotedHtml, but the inner region is read-only: a signature is meant + * to be inserted/removed as a unit, not edited inline. Select the node and + * press Backspace/Delete to drop the whole signature. + */ +export const SignatureBlock = TiptapNode.create({ + name: "signatureBlock", + 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 the rich + // signature markup never hits (and is never mangled by) the schema. + parseHTML: (el) => el.innerHTML, + // The real content round-trips via the custom serializer + // (serializeEditorContent); renderHTML below only needs the wrapper. + renderHTML: () => ({}), + }, + }; + }, + + parseHTML() { + return [{ tag: `div[${SIGNATURE_BLOCK_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, { [SIGNATURE_BLOCK_MARKER]: "" })]; + }, + + addNodeView() { + return ({ node }) => { + const dom = document.createElement("div"); + dom.setAttribute(SIGNATURE_BLOCK_MARKER, ""); + dom.className = "signature-block-island"; + + // CRITICAL: render the signature inside a Shadow Root. The app's global + // CSS (Tailwind preflight, .tiptap table/td rules, box-sizing resets) + // would otherwise cascade INTO the signature and destroy its layout - + // exactly the corruption we are fixing. Shadow DOM isolates both + // directions, so only the browser's UA defaults + the signature's own + // inline styles apply and the in-editor preview matches the sent mail. + const shadow = dom.attachShadow({ mode: "open" }); + const inner = document.createElement("div"); + // Read-only: a signature is inserted/removed as a unit, not edited inline. + inner.contentEditable = "false"; + inner.innerHTML = node.attrs.html || ""; + shadow.appendChild(inner); + + return { + dom, + // ProseMirror must not try to reconcile the foreign shadow content. + ignoreMutation: () => true, + // Let ProseMirror handle all events so clicking selects the atom and + // Backspace/Delete removes the whole signature. + stopEvent: () => false, + update: (updatedNode) => { + if (updatedNode.type.name !== "signatureBlock") return false; + if (inner.innerHTML !== (updatedNode.attrs.html || "")) { + inner.innerHTML = updatedNode.attrs.html || ""; + } + return true; + }, + }; + }; + }, +}); + +/** + * Build the editor-content wrapper that embeds the signature as a single + * SignatureBlock node. The inner HTML must be pre-sanitized + * (sanitizeSignatureHtml). The `data-signature-block-node` marker is what + * parseHTML keys on, so this exact form must be what serializeEditorContent + * emits too (round-trip consistency). + */ +export function buildSignatureBlock(sanitizedInnerHtml: string): string { + return `
${sanitizedInnerHtml}
`; +}