This commit is contained in:
Linus Rath
2026-06-24 19:56:15 +02:00
5 changed files with 166 additions and 6 deletions
@@ -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('<b>x</b>')).toBe(`<div ${SIGNATURE_BLOCK_MARKER}><b>x</b></div>`);
});
it('preserves an inline-styled signature through parse + serialize (no schema flattening)', () => {
const styled =
'<table style="background:#0a0e16;border-radius:8px"><tbody><tr>' +
'<td style="color:#c6f24e;font-family:\'Courier New\'">MV</td>' +
'</tr></tbody></table>';
const editor = new Editor({
element: document.createElement('div'),
extensions: [StarterKit, SignatureBlock],
content: `<p>Hello</p>${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();
}
});
});
+9 -6
View File
@@ -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(
: `<p data-signature-block="start"></p>`;
const endMarker = `<p data-signature-block="end"></p>`;
if (identity?.htmlSignature) {
return `${startMarker}${sanitizeSignatureHtml(identity.htmlSignature)}${endMarker}`;
return `${startMarker}${buildSignatureBlock(sanitizeSignatureHtml(identity.htmlSignature))}${endMarker}`;
}
if (identity?.textSignature) {
const escaped = identity.textSignature
+9
View File
@@ -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);
+5
View File
@@ -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: {
+105
View File
@@ -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 `<div ${SIGNATURE_BLOCK_MARKER}>${sanitizedInnerHtml}</div>`;
}