From 03e7cd86f102bc66a589b7b02d49523b00ae633a Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 3 Nov 2026 03:30:33 +0100
Subject: [PATCH] fix: print only the open message on Ctrl+P, without plugin
panels or the app around it
---
app/print.css | 25 ++++
.../__tests__/viewer-print-copy.test.tsx | 130 ++++++++++++++++++
components/email/email-viewer.tsx | 79 ++++++++---
components/email/message-print.tsx | 73 ++++++++++
4 files changed, 288 insertions(+), 19 deletions(-)
create mode 100644 components/email/__tests__/viewer-print-copy.test.tsx
create mode 100644 components/email/message-print.tsx
diff --git a/app/print.css b/app/print.css
index bc8abf93b..9cf992ad5 100644
--- a/app/print.css
+++ b/app/print.css
@@ -713,3 +713,28 @@
color: var(--pr-muted);
white-space: nowrap;
}
+
+/* ---- A mail message (components/email/message-print.tsx) ---------------- */
+
+.pr-message-meta {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ column-gap: 2mm;
+ margin: 1.5mm 0 0;
+ color: var(--pr-muted);
+ font-size: 8.5pt;
+}
+
+.pr-message-meta > div {
+ display: contents;
+}
+
+.pr-message-meta dt {
+ color: var(--pr-ink);
+ font-weight: 600;
+}
+
+.pr-message-meta dd {
+ margin: 0;
+ overflow-wrap: anywhere;
+}
diff --git a/components/email/__tests__/viewer-print-copy.test.tsx b/components/email/__tests__/viewer-print-copy.test.tsx
new file mode 100644
index 000000000..8df96af39
--- /dev/null
+++ b/components/email/__tests__/viewer-print-copy.test.tsx
@@ -0,0 +1,130 @@
+// Ctrl+P on an open message printed the app itself: the toolbar, the plugin
+// panel beside the message, and only as much of the body as fit the
+// reader's fixed height. The reader now puts a print copy of the message
+// next to the app, which app/print.css prints instead.
+import { act, render } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import React from 'react';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+
+vi.mock('@/hooks/use-media-query', () => ({
+ useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
+ useMediaQuery: () => false,
+ useIsMobile: () => false,
+}));
+vi.mock('@/components/tour/tour-provider', () => ({
+ useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
+}));
+
+class NoopResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+vi.stubGlobal('ResizeObserver', NoopResizeObserver);
+
+const { EmailViewer } = await import('../email-viewer');
+const { usePrintStore } = await import('@/stores/print-store');
+
+const mailboxes = [
+ { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
+] as unknown as Mailbox[];
+
+const HTML = '
Season one starts today
';
+
+function htmlMail(): Email {
+ return {
+ id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
+ from: [{ name: 'Call of Duty', email: 'news@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }],
+ subject: 'New trailer', receivedAt: '2026-11-03T00:09:00Z', preview: 'Season', hasAttachment: false, size: 100,
+ textBody: [{ partId: '1', type: 'text/html' }], htmlBody: [{ partId: '1', type: 'text/html' }], attachments: [],
+ bodyValues: { '1': { value: HTML, isTruncated: false } },
+ } as unknown as Email;
+}
+
+let visible = true;
+
+beforeEach(() => {
+ visible = true;
+ // jsdom lays nothing out; the reader asks whether it is on screen.
+ (HTMLElement.prototype as unknown as { checkVisibility: () => boolean }).checkVisibility = () => visible;
+});
+
+afterEach(() => {
+ act(() => { window.dispatchEvent(new Event('afterprint')); });
+ delete (HTMLElement.prototype as unknown as { checkVisibility?: unknown }).checkVisibility;
+ vi.restoreAllMocks();
+});
+
+function printCopy(): HTMLElement | null {
+ return document.body.querySelector(':scope > [data-print-root]');
+}
+
+describe('Ctrl+P on an open message', () => {
+ it('prints the message alone, next to the app', () => {
+ render();
+ expect(printCopy()).toBeNull();
+
+ act(() => { window.dispatchEvent(new Event('beforeprint')); });
+
+ const copy = printCopy()!;
+ expect(copy).not.toBeNull();
+ expect(copy.querySelector('h1')!.textContent).toBe('New trailer');
+ expect(copy.textContent).toContain('Call of Duty ');
+ expect(copy.textContent).toContain('Bob ');
+
+ // The body keeps out of the app's CSS: it sits in a shadow root.
+ const body = copy.querySelector('[data-message-print-body]')!;
+ expect(body.shadowRoot).not.toBeNull();
+ expect(body.shadowRoot!.textContent).toContain('Season one starts today');
+ expect(body.shadowRoot!.querySelector('table')).not.toBeNull();
+
+ act(() => { window.dispatchEvent(new Event('afterprint')); });
+ expect(printCopy()).toBeNull();
+ }, 30_000);
+
+ it('keeps remote content out while it is blocked, as the copy has no frame CSP', () => {
+ const mail = htmlMail();
+ mail.bodyValues = { '1': { value: 'Hello

x
', isTruncated: false } } as Email['bodyValues'];
+ render();
+
+ act(() => { window.dispatchEvent(new Event('beforeprint')); });
+
+ const shadow = printCopy()!.querySelector('[data-message-print-body]')!.shadowRoot!;
+ expect(shadow.textContent).toContain('Hello');
+ expect(shadow.innerHTML).not.toContain('tracker.example');
+ }, 30_000);
+
+ it('prints a plain-text mail with its line breaks and its quote opened', () => {
+ const mail = htmlMail();
+ mail.textBody = [{ partId: '1', type: 'text/plain' }] as Email['textBody'];
+ mail.htmlBody = [{ partId: '1', type: 'text/plain' }] as Email['htmlBody'];
+ mail.bodyValues = { '1': { value: 'line one\nline two\n\nOn Monday you wrote:\n> the old question\n', isTruncated: false } } as Email['bodyValues'];
+ render();
+
+ act(() => { window.dispatchEvent(new Event('beforeprint')); });
+
+ const shadow = printCopy()!.querySelector('[data-message-print-body]')!.shadowRoot!;
+ expect(shadow.querySelector('.plain')!.textContent).toContain('line one\nline two');
+ expect(shadow.querySelector('style')!.textContent).toMatch(/\.plain \{ white-space: pre-wrap/);
+ expect(shadow.querySelector('details')!.hasAttribute('open')).toBe(true);
+ }, 30_000);
+
+ it('leaves the copy out for a reader that is not on screen', () => {
+ visible = false;
+ render();
+
+ act(() => { window.dispatchEvent(new Event('beforeprint')); });
+
+ expect(usePrintStore.getState().printing).toBe(true);
+ expect(printCopy()).toBeNull();
+ }, 30_000);
+
+ it('prints nothing of its own with no message open', () => {
+ render();
+
+ act(() => { window.dispatchEvent(new Event('beforeprint')); });
+
+ expect(printCopy()).toBeNull();
+ }, 30_000);
+});
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index 212d8f3ed..9162f0d14 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -15,6 +15,8 @@ import { useReadAloud } from "@/hooks/use-read-aloud";
import { ReadAloudBar } from "./read-aloud-bar";
import { ReaderAttachments, type AttachmentDragProps } from "./reader-attachments";
import { ReaderViewDialog } from "./reader-view-dialog";
+import { MessagePrint, type MessagePrintFields } from "./message-print";
+import { usePrintListener, usePrinting } from "@/components/print/print-root";
import { ReadingMask } from "./reading-mask";
import { collectReferencedCids, isEmbeddedInBody, isReportPart, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility";
import { getAttachmentDisplayName } from "@/lib/attachment-name";
@@ -370,6 +372,12 @@ function getAttachmentContentBytes(attachment: {
return null;
}
+/** Whether the element takes up room on screen (not in a hidden tab or a display: none pane). */
+function isShownOnScreen(el: HTMLElement): boolean {
+ if (typeof el.checkVisibility === 'function') return el.checkVisibility();
+ return el.getClientRects().length > 0;
+}
+
/**
* Check if an HTML body string is effectively empty (just boilerplate/whitespace).
* Outlook often generates HTML bodies with Word CSS + but no real text.
@@ -3644,6 +3652,34 @@ export function EmailViewer({
openMailImport({ mailboxId: useEmailStore.getState().selectedMailbox || undefined });
};
+ // What a printout of the message shows, for the Print action's window and
+ // for the page's print copy (Ctrl+P). Text fields are unescaped.
+ const messagePrintFields = (): MessagePrintFields | null => {
+ if (!email) return null;
+ const printSender = email.from?.[0];
+ const formatRecipient = (r: { name?: string | null; email: string }) =>
+ r.name ? `${r.name} <${r.email}>` : r.email;
+ // The body was sanitized with EMAIL_IFRAME_SANITIZE_CONFIG which permits
+ // ${bodyHtml}
`;
+ }, [bodyHtml, plain]);
+
+ return (
+
+
+
+ {fields.subject}
+
+ {meta.filter(([, value]) => value).map(([label, value]) => (
+
+
- {label}:
+ - {value}
+
+ ))}
+
+
+
+
+
+ );
+}