Skip to content

fix(compose): stop signature and quote pastes keeping page colors - #656

Merged
maathimself merged 2 commits into
maathimself:mainfrom
Monkey7539:fix/signature-quote-paste-colors
Oct 20, 2026
Merged

maathimself merged 2 commits into
maathimself:mainfrom
Monkey7539:fix/signature-quote-paste-colors

Conversation

@Monkey7539

Copy link
Copy Markdown
Collaborator

Summary

#638 fixed the composer's main editor. This applies the same rule to the plain contenteditables it left out.

Pasting into the composer's signature or quoted message, or into the signature editor in Settings, keeps the colors of the page the text was copied from. Paragraphs copied out of an email shown as sent arrive as white boxes with dark text on the dark theme, and from one recolored dark (the dark theme's default) as near-black boxes that a recipient reading on white sees. Either way they are saved into the signature or the quoted message and sent that way.

The editors are the composer's signature, its quoted message in the phone and desktop layouts, and SignatureEditor.jsx#L203-L213. A paste there is the browser's own, and it keeps the inline colors the clipboard carries. As #638 describes, Chrome copies each run, or in a copy that spans paragraphs each block, with the styles in effect where it sat. Out of an email shown as sent, that is the frame's white page and #1a1a1a text (emailFrameDoc.js#L77), so two paragraphs arrive like this (Chrome 152, shortened):

<p style="color: rgb(26, 26, 26); …; orphans: 2; …; widows: 2; …; -webkit-text-stroke-width: 0px; …; background-color: rgb(255, 255, 255); …">Hi Marty,</p><p style="…">The ice resurfacer is booked for …</p>

Out of one recolored for the dark theme, the copy carries the recoloring's !important colors and its -webkit-text-fill-color marker instead. The main editor keeps only the marks it models, but these editors keep the colors on each element, so each paragraph becomes a box. Their HTML is what gets saved and sent: the quoted message is read back at send (ComposeModal.jsx#L912-L914), and so is the edited signature (#L931-L933), while the Settings editor saves to the account's or alias's signature.

Changes

  • New frontend/src/utils/pageColors.js holds fix(compose): stop pasted text keeping the email's page colors #638's withoutPageColors, moved out of editorPaste.js unchanged apart from the export and its comments, so that SignatureEditor can use it without loading Tiptap into the Settings bundle. editorPaste.js imports it.
  • New frontend/src/utils/contentEditablePaste.js. Its pasteWithoutPageColors is the onPaste handler of the four editors, and runs the clipboard's HTML through withoutPageColors. An element with Chrome's wrapper (orphans, widows and -webkit-text-stroke-width together) loses its colors. In a paste with the recoloring's marker anywhere, every element with the marker loses its colors too, and !important backgrounds go on any element.
    • When that changes nothing, the handler returns and the browser pastes as it does today.
    • When it does, the page inserts the result itself with document.execCommand('insertHTML'). That keeps the paste on the undo stack and fires input, so the paste is saved as typing would be. Because the page inserts the HTML itself, it sanitizes it first with DOMPurify, as the browser's own paste would be, and drops <style> blocks, which would restyle the app around the editor. It also trims the ends: on Windows, a paste's HTML comes wrapped in <html> and <body> with line breaks, which would otherwise be inserted as spaces.
    • Chrome can report false for an insertion it has made: in Chrome 152, insertHTML('<p>x</p>\n') reports false in an editor added at the end of the page, though it inserts. So the handler also checks whether the editor changed, and only a paste that inserted nothing, or that sanitizing left empty, goes to the browser's own paste.
  • ComposeModal.jsx and SignatureEditor.jsx attach the handler. There is no new dependency, as the composer already uses DOMPurify.

Behavior:

  • Text copied out of an email, as sent or recolored, takes the editor's colors in place of the page's. Bold, italics, links, lists, fonts and sizes are kept.
  • In a copy that spans paragraphs, the elements inside keep the colors Chrome wrote on them. Those are the sender's own colors in an email shown as sent, and a link's color as the frame showed it. In a recolored email, only the colors the recoloring left as written stay.
  • As with fix(compose): stop pasted text keeping the email's page colors #638, Chrome writes its wrapper on every run of a copy from within one paragraph, on any page. A highlight or colored words copied that way lose their color, from an email or from another website.
  • Everything else is pasted by the browser as it is today: copies from Word, Google Docs and the composer's main editor, Firefox's copies outside a recolored email, and pastes without HTML.

Left out: a selection dragged into these editors is a drop, not a paste, so it keeps its colors there. The composer's main editor takes drops through #638's rule.

Testing

frontend/src/utils/contentEditablePaste.test.js (16 tests) pastes, byte for byte, the clipboard contents Chromium 152 wrote for six selections in an email rendered in MessageBodyView's frame. Each was captured from a drag of the selection, which Chrome serializes with the same code as a copy. Five are captures #638's editor tests use. The sixth, two paragraphs with bold text and a link, was captured the same way for this change.

  • The handler takes over each of the six and inserts it without the page's colors. A phrase keeps its font. Two paragraphs keep their bold text and their link, the link in the frame's link color. A copy across paragraphs keeps the sender's highlight and red words. From an email recolored dark, only the red the recoloring left as written is kept. White text from a dark block loses its color, and a highlight inside one copied line goes with the page around it.
  • These pastes are left to the browser: markup as Word, Google Docs and Firefox write it, HTML from the composer's own editor, an element with only some of the wrapper's properties, text that mentions them, markup that reads differently once reparsed (a <meta charset='utf-8'> in single quotes) when nothing is dropped from it, a paste without HTML, a paste that sanitizing leaves empty, and a paste that insertHTML refuses.
  • When insertHTML reports false for an insertion it made, the browser does not paste again. When it reports true, the browser does not paste either, even if the editor reads the same as before.
  • Wrapped as the Windows clipboard wraps them, a phrase and two paragraphs insert exactly what they insert without the wrapper.
  • Scripts, event handlers, javascript: links and a <style> block after content are removed from what the page inserts.

SignatureEditor.render.test.js (new) and a describe block in ComposeModal.render.test.js paste two paragraphs into the real components. That covers the Settings editor, and the signature and quoted message of a reply sent from the desktop layout and from the phone layout, which has its own quoted-message element. They check what onChange saves and what is posted to /mail/send. jsdom has no editing, so the tests stand in for the browser: insertHTML inserts at the caret and fires input, and a paste the page does not cancel inserts the clipboard's HTML as it is.

Mutations, each rerun against the tests that should catch it, were all caught (19): the handler removed from each of the four editors; a handler that does nothing, takes over every paste, never cancels the browser's paste, or cancels it when nothing was inserted; the editor check removed, or a true report ignored; no trimming, no empty check, no sanitizing, <style> allowed, or the clipboard's HTML inserted uncleaned; and in the rule, the wrapper matched by one property, either rule dropped, or an unchanged paste handed back reparsed.

I also checked it in Chromium 152 on a test page: the handler on plain contenteditables styled like the composer's signature on the dark theme, a ClipboardEvent carrying each capture, and the real insertHTML. Without the handler, inserting the phrase, the paragraphs or the list as they are leaves rgb(26, 26, 26) text on rgb(255, 255, 255). With it, the paste is cancelled and an input event fires. Nothing keeps a background except the sender's yellow highlight. After the list's paste, document.execCommand('undo') restored the editor exactly. Wrapped as the Windows clipboard wraps them, the phrase and the list each insert once, with no stray spaces, whether pasted at the end, into an empty editor or mid-line. That holds in an editor added at the end of the page, where HTML ending in a line break got that false report, and in one that was not. A hostile paste carrying Chrome's wrapper, with event handlers, a javascript: link, a script and a <style> that hides the page, ran nothing and hid nothing. A real paste cannot be triggered there, and it is not checked in Firefox, Safari or the running app.

Frontend npm test passes (3220/3220), npm run lint is clean and npm run build succeeds, and the built AdminPanel (Settings) chunk loads no Tiptap. The backend is not touched.


Contributor License Agreement

By submitting this pull request I confirm that:

  • I have read and agree to the Contributor License Agreement.
  • My contribution is my own original work (or I have identified any
    third-party material and confirmed it is compatible with the CLA).
  • I have the right to submit this contribution under the terms of the CLA.

🤖 Generated with Claude Code

The composer's signature and quoted-reply areas and the signature
editor in Settings are plain contenteditables, so a paste there was the
browser's own and kept the inline colors the clipboard carried.
Paragraphs copied out of an email shown as sent carried the frame's
white page and #1a1a1a text on every <p>: white boxes with dark text on
the dark theme. From an email recolored dark they carried the
recoloring's colors: near-black boxes for a recipient reading on white.
Either way they were saved into the signature or the quoted message and
sent that way.

A paste into them now goes through pasteWithoutPageColors, which uses
the composer's rule from maathimself#638. The rule moves to utils/pageColors.js so
that the Settings editor does not load Tiptap with it. An element with
Chrome's wrapper (orphans, widows and -webkit-text-stroke-width
together) loses its colors, and in a paste with the recoloring's
marker, so do marked elements and !important backgrounds. Colors stay
wherever the copy keeps them apart from the page's.

When the rule changes nothing, the browser pastes as before. When it
does, the page sanitizes the cleaned HTML with DOMPurify, dropping
<style> blocks that would restyle the app, trims the line breaks the
Windows clipboard wraps it in, and inserts it with insertHTML, so the
paste can be undone and is saved as typing would be. Chrome can report
false for an insertion it has made, so the editor is checked too: only
a paste that inserted nothing, or left nothing to insert, goes to the
browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@maathimself
maathimself merged commit 5b57963 into maathimself:main Oct 20, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants