diff --git a/components/email/__tests__/quoted-html.test.ts b/components/email/__tests__/quoted-html.test.ts
new file mode 100644
index 000000000..6dbdea83f
--- /dev/null
+++ b/components/email/__tests__/quoted-html.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from 'vitest';
+import { Editor } from '@tiptap/core';
+import StarterKit from '@tiptap/starter-kit';
+import { buildQuotedHtmlBlock, QuotedHtml, serializeEditorContent } from '../quoted-html';
+
+const createEditor = (content: string) => new Editor({ extensions: [StarterKit, QuotedHtml], content });
+
+describe('HTML quote borders', () => {
+ it('keeps normal quote styling by default', () => {
+ const original = '
Original reply
';
+ const editor = createEditor(buildQuotedHtmlBlock(original));
+ try {
+ expect(editor.view.dom.querySelector('.quoted-html-island')?.style.borderLeftWidth).toBe('2px');
+ expect(serializeEditorContent(editor)).toContain(original);
+ } finally { editor.destroy(); }
+ });
+
+ it('flattens nested quotes and preserves content and border preference after reopening', () => {
+ const original = 'Ok no problem
Hello this is a quick test
Max
';
+ const editor = createEditor(buildQuotedHtmlBlock(original, { showQuoteBorder: false }));
+ let saved = '';
+ try {
+ const island = editor.view.dom.querySelector('.quoted-html-island')!;
+ expect(island.style.borderLeftWidth).toBe('');
+ expect(island.style.paddingLeft).toBe('');
+ expect(island.shadowRoot?.querySelector('blockquote')).toBeNull();
+ expect(island.shadowRoot?.textContent).toContain('Ok no problem');
+ expect(island.shadowRoot?.querySelector('a')?.getAttribute('href')).toBe('mailto:max@example.com');
+ expect(island.shadowRoot?.querySelector('td')?.textContent).toBe('Layout');
+ saved = serializeEditorContent(editor);
+ expect(saved).toContain('data-quote-border="false"');
+ expect(saved).not.toContain('blockquote');
+ } finally { editor.destroy(); }
+ const reopened = createEditor(saved);
+ try {
+ expect(reopened.view.dom.querySelector('.quoted-html-island')?.style.borderLeftWidth).toBe('');
+ expect(serializeEditorContent(reopened)).toBe(saved);
+ } finally { reopened.destroy(); }
+ });
+});
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index 81e641f48..6433a6c92 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -699,7 +699,7 @@ export function EmailComposer({
// first; cid rewrite runs after so its data-cid markers survive).
const island = buildQuotedHtmlBlock(
rewriteCidImagesForEditor(sanitizeEmailHtml(replyTo.htmlBody)),
- { remoteAllowed: quoteRemoteAllowed },
+ { remoteAllowed: quoteRemoteAllowed, showQuoteBorder: replyTo.quoteWrapInBlockquote !== false },
);
return `${prefix}${signatureBlock}
${replyTo.quoteHeaderHtml}${island}`;
}
diff --git a/components/email/quoted-html.ts b/components/email/quoted-html.ts
index b983539b3..ad92c90bf 100644
--- a/components/email/quoted-html.ts
+++ b/components/email/quoted-html.ts
@@ -61,6 +61,11 @@ export const QuotedHtml = TiptapNode.create({
addAttributes() {
return {
+ showQuoteBorder: {
+ default: true,
+ parseHTML: (el) => el.getAttribute("data-quote-border") !== "false",
+ renderHTML: (attrs) => attrs.showQuoteBorder === false ? { "data-quote-border": "false" } : {},
+ },
html: {
default: "",
// Capture the verbatim inner HTML when parsing. Because the node is
@@ -138,7 +143,11 @@ export const QuotedHtml = TiptapNode.create({
const dom = document.createElement("div");
dom.setAttribute(QUOTED_HTML_MARKER, "");
dom.className = "quoted-html-island";
- dom.style.cssText = QUOTE_BAR_STYLE + QUOTE_ISLAND_CONTAINMENT_STYLE;
+ const updateQuoteStyle = (showBorder: boolean) => {
+ dom.style.cssText = (showBorder ? QUOTE_BAR_STYLE : "margin-top:8px;")
+ + QUOTE_ISLAND_CONTAINMENT_STYLE;
+ };
+ updateQuoteStyle(node.attrs.showQuoteBorder !== false);
// CRITICAL: render the quoted email inside a Shadow Root. The app's
// global CSS (Tailwind preflight, .tiptap table/td rules, box-sizing
@@ -218,6 +227,7 @@ export const QuotedHtml = TiptapNode.create({
},
update: (updatedNode) => {
if (updatedNode.type.name !== "quotedHtml") return false;
+ updateQuoteStyle(updatedNode.attrs.showQuoteBorder !== false);
// Don't clobber the caret while the user is redacting inside.
if (!focused && (updatedNode.attrs.html || "") !== renderedHtml) {
render(updatedNode.attrs.html || "");
@@ -256,7 +266,7 @@ export function serializeEditorContent(editor: Editor): string {
// 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) || ""));
+ parts.push(buildQuotedHtmlBlock((node.attrs.html as string) || "", { showQuoteBorder: node.attrs.showQuoteBorder !== false }));
return;
}
if (node.type.name === "signatureBlock") {
@@ -286,8 +296,29 @@ export function serializeEditorContent(editor: Editor): string {
*/
export function buildQuotedHtmlBlock(
sanitizedInnerHtml: string,
- opts: { remoteAllowed?: boolean } = {},
+ opts: { remoteAllowed?: boolean; showQuoteBorder?: boolean } = {},
): string {
+ const showQuoteBorder = opts.showQuoteBorder !== false;
+ let html = sanitizedInnerHtml;
+ if (!showQuoteBorder) {
+ // Outlook-style replies flatten quote levels, including earlier replies.
+ // Work on sanitized DOM so tables, links and signatures remain intact.
+ const container = document.createElement("div");
+ container.innerHTML = html;
+ container.querySelectorAll("blockquote").forEach((quote) => {
+ const block = document.createElement("div");
+ block.append(...Array.from(quote.childNodes));
+ quote.replaceWith(block);
+ });
+ container.querySelectorAll(`[${QUOTED_HTML_MARKER}]`).forEach((quote) => {
+ quote.setAttribute("data-quote-border", "false");
+ quote.style.borderLeft = "none";
+ quote.style.paddingLeft = "0";
+ });
+ html = container.innerHTML;
+ }
const remote = opts.remoteAllowed ? ` ${QUOTED_REMOTE_ALLOWED_ATTR}="allowed"` : "";
- return `${sanitizedInnerHtml}
`;
+ const borderAttribute = showQuoteBorder ? "" : ' data-quote-border="false"';
+ const style = showQuoteBorder ? QUOTE_BAR_STYLE : "margin-top:8px;";
+ return `${html}
`;
}
diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts
index 4df1b48c9..2eb8212fe 100644
--- a/lib/plugin-types.ts
+++ b/lib/plugin-types.ts
@@ -1105,7 +1105,8 @@ export interface QuoteHeader {
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 body, including HTML islands and nested blockquotes (HTML mode only).
+ * This preference persists when saving/reopening drafts. 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).
*/