// Host side of api.ui.print: print a page a plugin made, such as an // invoice drawn from its XML. A plugin frame can't print (its sandbox has // no allow-modals, and the user would get the whole frame), so the host // prints the page from a hidden frame of its own. // // The page is treated like a message body: sanitised as for the reader // (styles allowed, no scripts, forms or frames), shown in a frame that // may not run scripts, under the reader's strict CSP, so it loads nothing // from the network. Images have to come as data: URLs. import type { InstalledPlugin } from '../plugin-types'; import { emailIframeCsp, escapeHtml, sanitizeEmailHtmlForIframe } from '../email-sanitization'; import { readObject, readString, withinRate } from './host-api-input'; const MAX_TITLE = 200; const MAX_HTML = 2 * 1024 * 1024; /** Print dialogs a plugin may open per minute. */ export const MAX_PRINTS_PER_MINUTE = 3; /** How long the frame stays when the browser never says printing ended. */ const CLEANUP_MS = 60_000; /** The document the frame prints. Exported for tests. */ export function printDocument(title: string, html: string, dir: string): string { return ` ${escapeHtml(title)} ${sanitizeEmailHtmlForIframe(html)}`; } /** * Open the browser's print dialog for `page.html`. Resolves to true once * the dialog was asked for (the user may still cancel it there). */ export async function doUiPrint(plugin: InstalledPlugin, input: unknown): Promise { const method = 'ui.print'; const page = readObject(input, method, 'page', ['title', 'html']); const title = page.title === undefined ? plugin.name || plugin.id : readString(page.title, method, 'title', MAX_TITLE); // eslint-disable-next-line no-control-regex -- a title is one line if (/[\u0000-\u001f\u007f]/.test(title)) throw new Error(`${method}: title must be one line`); const html = readString(page.html, method, 'html', MAX_HTML); if (!withinRate(method, plugin.id, MAX_PRINTS_PER_MINUTE)) { throw new Error(`${method}: at most ${MAX_PRINTS_PER_MINUTE} times a minute`); } const frame = document.createElement('iframe'); // Same origin so the host can call print() on it; no allow-scripts, so // nothing in the page runs. frame.setAttribute('sandbox', 'allow-same-origin allow-modals'); frame.setAttribute('aria-hidden', 'true'); frame.tabIndex = -1; // Off screen but laid out: some browsers print an empty page from a // frame with display: none. frame.style.cssText = 'position:fixed;left:-10000px;top:0;width:800px;height:600px;border:0;visibility:hidden;'; frame.srcdoc = printDocument(title, html, document.documentElement.dir); await new Promise((resolve, reject) => { frame.addEventListener('load', () => resolve(), { once: true }); frame.addEventListener('error', () => reject(new Error(`${method}: the page could not be prepared`)), { once: true }); document.body.appendChild(frame); }); const view = frame.contentWindow; if (!view) { frame.remove(); throw new Error(`${method}: the page could not be prepared`); } const remove = () => frame.remove(); view.addEventListener('afterprint', remove, { once: true }); setTimeout(remove, CLEANUP_MS); view.focus(); view.print(); return true; }