mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 00:14:12 +00:00
fix: print only the open message on Ctrl+P, without plugin panels or the app around it
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = '<p class="hidden" id="main">Season one starts today</p><table border="1"><tr><td>Row</td></tr></table>';
|
||||
|
||||
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(<EmailViewer email={htmlMail()} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
|
||||
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 <news@example.com>');
|
||||
expect(copy.textContent).toContain('Bob <bob@example.com>');
|
||||
|
||||
// The body keeps out of the app's CSS: it sits in a shadow root.
|
||||
const body = copy.querySelector<HTMLElement>('[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: '<p>Hello</p><img src="https://tracker.example/pixel.png"><div style="background:url(https://tracker.example/bg.png)">x</div>', isTruncated: false } } as Email['bodyValues'];
|
||||
render(<EmailViewer email={mail} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
|
||||
|
||||
act(() => { window.dispatchEvent(new Event('beforeprint')); });
|
||||
|
||||
const shadow = printCopy()!.querySelector<HTMLElement>('[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(<EmailViewer email={mail} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
|
||||
|
||||
act(() => { window.dispatchEvent(new Event('beforeprint')); });
|
||||
|
||||
const shadow = printCopy()!.querySelector<HTMLElement>('[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(<EmailViewer email={htmlMail()} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
|
||||
|
||||
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(<EmailViewer email={null} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
|
||||
|
||||
act(() => { window.dispatchEvent(new Event('beforeprint')); });
|
||||
|
||||
expect(printCopy()).toBeNull();
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -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
|
||||
// <style>. A printout has no iframe isolation, so re-sanitize with the
|
||||
// stricter config that forbids <style> before injecting into the DOM.
|
||||
// A plain-text body is escaped text with its own newlines: it prints its
|
||||
// trailing quote opened instead of behind the collapsed toggle.
|
||||
const plain = !effectiveEmailContent.isHtml;
|
||||
return {
|
||||
subject: email.subject || t('no_subject'),
|
||||
sender: printSender?.name
|
||||
? `${printSender.name} <${printSender.email}>`
|
||||
: printSender?.email || t('unknown_sender'),
|
||||
to: email.to?.map(formatRecipient).join(', ') || '',
|
||||
cc: email.cc?.map(formatRecipient).join(', ') || '',
|
||||
date: formatDateTime(emailDisplayDate(email), timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }),
|
||||
bodyHtml: plain
|
||||
? effectiveEmailContent.html.replace(/<details>/g, '<details open>')
|
||||
: sanitizeEmailHtml(effectiveEmailContent.html),
|
||||
plain,
|
||||
};
|
||||
};
|
||||
|
||||
// Print only the email content in a new window. A print asked for from
|
||||
// the list's row menu brings its own window, opened in that click.
|
||||
const handlePrint = (openedWindow?: Window) => {
|
||||
@@ -3652,26 +3688,16 @@ export function EmailViewer({
|
||||
openedWindow?.close();
|
||||
return;
|
||||
}
|
||||
const fields = messagePrintFields();
|
||||
if (!fields) return;
|
||||
const printWatermark = watermark ? watermarkForPrint(watermark) : null;
|
||||
const printSender = email.from?.[0];
|
||||
const date = formatDateTime(emailDisplayDate(email), timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' });
|
||||
const formatRecipient = (r: { name?: string | null; email: string }) =>
|
||||
r.name ? `${escapeHtml(r.name)} <${escapeHtml(r.email)}>` : escapeHtml(r.email);
|
||||
const toList = email.to?.map(formatRecipient).join(', ') || '';
|
||||
const ccList = email.cc?.map(formatRecipient).join(', ') || '';
|
||||
const subjectText = email.subject || t('no_subject');
|
||||
const senderText = printSender?.name
|
||||
? `${printSender.name} <${printSender.email}>`
|
||||
: printSender?.email || t('unknown_sender');
|
||||
// The body was sanitized with EMAIL_IFRAME_SANITIZE_CONFIG which permits
|
||||
// <style>. The print window has no iframe isolation, so re-sanitize with
|
||||
// the stricter config that forbids <style> before injecting into the DOM.
|
||||
// A plain-text body is escaped text with its own newlines: it prints its
|
||||
// trailing quote opened instead of behind the collapsed toggle.
|
||||
const plainBody = !effectiveEmailContent.isHtml;
|
||||
const printableBody = plainBody
|
||||
? effectiveEmailContent.html.replace(/<details>/g, '<details open>')
|
||||
: sanitizeEmailHtml(effectiveEmailContent.html);
|
||||
const date = fields.date;
|
||||
const toList = escapeHtml(fields.to);
|
||||
const ccList = escapeHtml(fields.cc);
|
||||
const subjectText = fields.subject;
|
||||
const senderText = fields.sender;
|
||||
const plainBody = fields.plain;
|
||||
const printableBody = fields.bodyHtml;
|
||||
|
||||
const printWindow = openedWindow ?? window.open('', '_blank');
|
||||
if (!printWindow) return;
|
||||
@@ -3738,6 +3764,20 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
if (!printRequest.win.closed) handlePrintRef.current(printRequest.win);
|
||||
}, [printReady, printRequest]);
|
||||
|
||||
// Ctrl+P and the browser's Print menu print the message alone (app/print.css),
|
||||
// not the app around it with its plugin panels and fixed heights. Only a
|
||||
// reader on screen when printing starts: a Pro tab in the background keeps
|
||||
// its message off the paper. Checked once, as the app is hidden while printing.
|
||||
usePrintListener();
|
||||
const printing = usePrinting();
|
||||
const printShown = useMemo(
|
||||
() => printing && !!viewerRootEl && isShownOnScreen(viewerRootEl),
|
||||
[printing, viewerRootEl],
|
||||
);
|
||||
const printCopyFields = printShown && !devices.noPrint && !isLoading && !!email?.bodyValues
|
||||
? messagePrintFields()
|
||||
: null;
|
||||
|
||||
// Detect List-Unsubscribe header for newsletter banners
|
||||
const listHeaders = useMemo(() => {
|
||||
if (!email?.headers) return null;
|
||||
@@ -4544,6 +4584,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
data-tour="email-viewer"
|
||||
className={cn("flex-1 flex flex-col h-full bg-background overflow-hidden relative", className)}
|
||||
>
|
||||
{printCopyFields && <MessagePrint fields={printCopyFields} />}
|
||||
{toolbarPosition === 'top' && liftToolbar ? topToolbar : toggleOnlyToolbar}
|
||||
{/* The message and the side panels, below the toolbar. Overlays anchor here. */}
|
||||
<div className="flex-1 min-h-0 flex flex-row overflow-hidden relative">
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { PrintRoot } from "@/components/print/print-root";
|
||||
|
||||
export interface MessagePrintFields {
|
||||
subject: string;
|
||||
sender: string;
|
||||
to: string;
|
||||
cc: string;
|
||||
date: string;
|
||||
/** Sanitized body HTML without <style>: a re-sanitized HTML body, or the escaped plain-text one. */
|
||||
bodyHtml: string;
|
||||
plain: boolean;
|
||||
}
|
||||
|
||||
// The reader's image rule: cap only images that set no max-width of their own.
|
||||
const BODY_CSS = `
|
||||
:host { display: block; font-size: 10.5pt; line-height: 1.5; overflow-wrap: break-word; }
|
||||
img { height: auto !important; }
|
||||
img:not([style*="max-width"]) { max-width: 100% !important; }
|
||||
img[style*="max-width"] { max-width: 100%; }
|
||||
table { max-width: 100%; }
|
||||
* { print-color-adjust: exact; -webkit-print-color-adjust: exact; }
|
||||
.plain { white-space: pre-wrap; word-break: break-word; }
|
||||
.plain summary { display: none !important; }
|
||||
`;
|
||||
|
||||
/**
|
||||
* The open message as Ctrl+P prints it: header and body on white, with the
|
||||
* app around it (toolbar, list, plugin panels) left off the paper. The same
|
||||
* fields the reader's Print action puts in its window.
|
||||
*
|
||||
* The body goes into a shadow root: in the app's document it would get the
|
||||
* app's CSS reset (no paragraph margins, no table borders) and its classes.
|
||||
* It is filled in the commit, so it is there before the browser lays the
|
||||
* page out for printing.
|
||||
*/
|
||||
export function MessagePrint({ fields }: { fields: MessagePrintFields }) {
|
||||
const t = useTranslations("email_viewer");
|
||||
const meta: Array<[string, string]> = [
|
||||
[t("from"), fields.sender],
|
||||
[t("to"), fields.to],
|
||||
[t("cc"), fields.cc],
|
||||
[t("date"), fields.date],
|
||||
];
|
||||
const { bodyHtml, plain } = fields;
|
||||
const fillBody = useCallback((host: HTMLDivElement | null) => {
|
||||
if (!host) return;
|
||||
const root = host.shadowRoot ?? host.attachShadow({ mode: "open" });
|
||||
root.innerHTML = `<style>${BODY_CSS}</style><div dir="auto"${plain ? ' class="plain"' : ""}>${bodyHtml}</div>`;
|
||||
}, [bodyHtml, plain]);
|
||||
|
||||
return (
|
||||
<PrintRoot orientation="portrait" title={fields.subject}>
|
||||
<article className="pr-message">
|
||||
<header className="pr-header">
|
||||
<h1 className="pr-title" dir="auto">{fields.subject}</h1>
|
||||
<dl className="pr-message-meta">
|
||||
{meta.filter(([, value]) => value).map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<dt>{label}:</dt>
|
||||
<dd dir="auto">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</header>
|
||||
<div className="pr-message-body" data-message-print-body="" ref={fillBody} />
|
||||
</article>
|
||||
</PrintRoot>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user