From 9c360694bfbdc0da1492b7c7feeec894c9a13b81 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:27:13 +0100 Subject: [PATCH] fix: send paragraphs with the spacing the composer shows #1340 --- .../rich-text-editor-empty-lines.test.tsx | 7 ++- ...ich-text-editor-paragraph-spacing.test.tsx | 63 +++++++++++++++++++ components/email/paragraph-breaks.ts | 33 ++++++++++ components/email/quoted-html.ts | 3 +- components/email/rich-text-editor.tsx | 3 +- 5 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 components/email/__tests__/rich-text-editor-paragraph-spacing.test.tsx diff --git a/components/email/__tests__/rich-text-editor-empty-lines.test.tsx b/components/email/__tests__/rich-text-editor-empty-lines.test.tsx index 5678f5788..2075c55ae 100644 --- a/components/email/__tests__/rich-text-editor-empty-lines.test.tsx +++ b/components/email/__tests__/rich-text-editor-empty-lines.test.tsx @@ -19,8 +19,11 @@ async function setup(content: string) { return editor as unknown as Editor; } -// The composer marks every block dir="auto"; that's not what these tests are about. -const serialize = (editor: Editor) => serializeEditorContent(editor).replace(/ dir="auto"/g, ''); +// The composer marks every block dir="auto" and inlines the paragraph margin +// (#1340); that's not what these tests are about. +const serialize = (editor: Editor) => serializeEditorContent(editor) + .replace(/ dir="auto"/g, '') + .replace(/ style="margin:4px 0;"/g, ''); // Re-open what was saved, the way a draft or a signature swap does. function reload(editor: Editor, html: string) { diff --git a/components/email/__tests__/rich-text-editor-paragraph-spacing.test.tsx b/components/email/__tests__/rich-text-editor-paragraph-spacing.test.tsx new file mode 100644 index 000000000..ccbe4020e --- /dev/null +++ b/components/email/__tests__/rich-text-editor-paragraph-spacing.test.tsx @@ -0,0 +1,63 @@ +import { act, render, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import React from 'react'; +import type { Editor } from '@tiptap/core'; + +import { RichTextEditor } from '../rich-text-editor'; +import { serializeEditorContent } from '../quoted-html'; + +async function setup(content: string) { + let editor: Editor | null = null; + render( + {}} + onEditorReady={(ed) => { editor = ed as unknown as Editor; }} + /> + ); + await waitFor(() => expect(editor).not.toBeNull()); + return editor as unknown as Editor; +} + +const serialize = (editor: Editor) => serializeEditorContent(editor).replace(/ dir="auto"/g, ''); + +describe('RichTextEditor paragraph spacing (#1340)', () => { + it('sends each line with the 4px margin the composer shows, not the 1em default', async () => { + const editor = await setup('

one

two

'); + + expect(serialize(editor)).toBe('

one

two

'); + }); + + it('keeps the alignment next to the margin', async () => { + const editor = await setup('

mid

'); + + expect(serialize(editor)).toBe('

mid

'); + }); + + it('spaces paragraphs in lists too', async () => { + const editor = await setup(''); + + expect(serialize(editor)).toContain('
  • a

  • '); + }); + + it('sends no margin inside a table, as the composer shows none there', async () => { + const editor = await setup('

    cell

    '); + + expect(serialize(editor)).toContain('

    cell

    '); + }); + + it('does not stack margins when a saved draft is opened again', async () => { + const editor = await setup('

    one

    two

    '); + const saved = serialize(editor); + + act(() => { editor.commands.setContent(saved, { emitUpdate: false }); }); + + expect(serialize(editor)).toBe(saved); + }); + + it('leaves the quoted original alone', async () => { + const editor = await setup('

    reply

    quoted

    '); + + expect(serialize(editor)).toContain('

    quoted

    '); + }); +}); diff --git a/components/email/paragraph-breaks.ts b/components/email/paragraph-breaks.ts index 38c6b39d5..9edf8dc08 100644 --- a/components/email/paragraph-breaks.ts +++ b/components/email/paragraph-breaks.ts @@ -39,6 +39,39 @@ export function addTrailingParagraphBreaks(root: ParentNode): void { }); } +/** + * Paragraph spacing (#1340). + * + * The composer shows paragraphs 4px apart (`.tiptap p` in globals.css, none + * inside a table), but a sent `

    ` carries no style, so the Sent folder and + * every recipient's client fall back to the browser's 1em margins and each + * Enter looks like a blank line. Inline the composer's margin on the way out. + * Paragraphs keep their `style` in the editor, so the parse side strips the + * margin again (paragraphStyleWithoutSpacing) and a re-opened draft doesn't + * stack one more margin per save. + */ +const PARAGRAPH_MARGIN = "margin:4px 0;"; +const TABLE_PARAGRAPH_MARGIN = "margin:0;"; + +export function addParagraphSpacing(root: ParentNode): void { + root.querySelectorAll("p").forEach((p) => { + if (p.hasAttribute(SIGNATURE_RANGE_MARKER)) return; + const margin = p.closest("table") ? TABLE_PARAGRAPH_MARGIN : PARAGRAPH_MARGIN; + // Ahead of any style the editor wrote, so an explicit margin still wins. + p.setAttribute("style", margin + (p.getAttribute("style") ?? "")); + }); +} + +/** A paragraph's `style` without the margin addParagraphSpacing put in front. */ +export function paragraphStyleWithoutSpacing(el: HTMLElement): string | null { + const style = el.getAttribute("style"); + if (style === null) return null; + for (const margin of [PARAGRAPH_MARGIN, TABLE_PARAGRAPH_MARGIN]) { + if (style.startsWith(margin)) return style.slice(margin.length).trim() || null; + } + return style; +} + /** Whether `el` is the
    a paragraph ends on, which a browser doesn't show. */ export function isTrailingParagraphBreak(el: HTMLElement): boolean { if (el.parentElement?.nodeName !== "P") return false; diff --git a/components/email/quoted-html.ts b/components/email/quoted-html.ts index 71ab1f72d..b983539b3 100644 --- a/components/email/quoted-html.ts +++ b/components/email/quoted-html.ts @@ -5,7 +5,7 @@ import { DOMSerializer } from "@tiptap/pm/model"; import { NodeSelection, Plugin, PluginKey, Selection, TextSelection } from "@tiptap/pm/state"; import type { Editor } from "@tiptap/react"; -import { addTrailingParagraphBreaks } from "@/components/email/paragraph-breaks"; +import { addParagraphSpacing, addTrailingParagraphBreaks } from "@/components/email/paragraph-breaks"; import { buildSignatureBlock } from "@/components/email/signature-block"; import { parkRemoteResources, restoreRemoteResources, sanitizeComposerIslandHtml } from "@/lib/email-sanitization"; import { useSettingsStore } from "@/stores/settings-store"; @@ -270,6 +270,7 @@ export function serializeEditorContent(editor: Editor): string { const tmp = document.createElement("div"); tmp.appendChild(fragment); if (index <= lastLine) addTrailingParagraphBreaks(tmp); + addParagraphSpacing(tmp); parts.push(tmp.innerHTML); }); return parts.join(""); diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx index 85a81c5cb..5d45910a1 100644 --- a/components/email/rich-text-editor.tsx +++ b/components/email/rich-text-editor.tsx @@ -23,7 +23,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 { trailingParagraphBreakRule } from "@/components/email/paragraph-breaks"; +import { paragraphStyleWithoutSpacing, trailingParagraphBreakRule } from "@/components/email/paragraph-breaks"; import { SignatureBlock } from "@/components/email/signature-block"; import { styledBlockAttributes } from "@/components/email/styled-block-attributes"; import { RecipientMention, RecipientMentionList, type RecipientMentionListHandle } from "@/components/email/recipient-mention"; @@ -69,6 +69,7 @@ const StyledParagraph = Paragraph.extend({ return { ...this.parent?.(), ...styledBlockAttributes, + style: { ...styledBlockAttributes.style, parseHTML: paragraphStyleWithoutSpacing }, }; }, // The
    serializeEditorContent adds to empty lines isn't a hard break.