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 = '
Row
'; + +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}
+
+ ))} +
+
+
+
+
+ ); +}