diff --git a/lib/__tests__/plugin-host-print.test.ts b/lib/__tests__/plugin-host-print.test.ts new file mode 100644 index 000000000..a953c809d --- /dev/null +++ b/lib/__tests__/plugin-host-print.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { InstalledPlugin } from '../plugin-types'; +import { resetPluginRateLimits } from '../plugin-sandbox/host-api-input'; +import { printDocument } from '../plugin-sandbox/host-api-print'; + +vi.mock('@/stores/toast-store', () => ({ + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }, +})); + +import { dispatchApiCall } from '../plugin-sandbox/host-api'; + +const PLUGIN: InstalledPlugin = { + id: 'e-invoice', name: 'E-Invoice', version: '1.0.0', author: 'test', description: '', + type: 'ui-extension', permissions: [], grantedPermissions: [], entrypoint: 'index.js', + enabled: true, status: 'running', settings: {}, +}; +const call = (method: string, ...args: unknown[]) => dispatchApiCall(PLUGIN, method, args); + +describe('api.ui.print', () => { + let printed: string[]; + + beforeEach(() => { + resetPluginRateLimits(); + printed = []; + document.querySelectorAll('iframe').forEach((f) => f.remove()); + // jsdom neither loads srcdoc nor prints: load the frame when it is + // attached and record what its print() would have printed. + vi.spyOn(document.body, 'appendChild').mockImplementation(function (this: HTMLElement, node: Node) { + HTMLElement.prototype.appendChild.call(this, node); + if (node instanceof HTMLIFrameElement) { + const srcdoc = node.srcdoc; + node.contentWindow!.print = () => { printed.push(srcdoc); }; + queueMicrotask(() => node.dispatchEvent(new Event('load'))); + } + return node; + } as never); + }); + + it('prints the page from a frame that runs no scripts and loads nothing', async () => { + expect(await call('ui.print', { + title: 'Rechnung 2026-117', + html: '
Total119,00 €
' + + '
', + })).toBe(true); + expect(printed).toHaveLength(1); + const page = printed[0]; + expect(page).toContain('Rechnung 2026-117'); + expect(page).toContain('119,00 €'); + expect(page).toContain('td { padding: 4px }'); + expect(page).not.toContain(' { + expect(printDocument('', '', 'ltr')).toContain('</title><script>'); + await expect(call('ui.print', { html: 42 })).rejects.toThrow(/html must be a string/); + await expect(call('ui.print', { title: 'a\nb', html: '' })).rejects.toThrow(/title must be one line/); + await expect(call('ui.print', { html: '', url: 'https://x.example' })).rejects.toThrow(/unknown property "url"/); + expect(printed).toEqual([]); + }); + + it('prints at most three times a minute', async () => { + for (let i = 0; i < 3; i++) await call('ui.print', { html: '<p>x</p>' }); + await expect(call('ui.print', { html: '<p>x</p>' })).rejects.toThrow(/at most 3 times a minute/); + expect(printed).toHaveLength(3); + }); +}); diff --git a/lib/plugin-sandbox/host-api-print.ts b/lib/plugin-sandbox/host-api-print.ts new file mode 100644 index 000000000..81e0165b4 --- /dev/null +++ b/lib/plugin-sandbox/host-api-print.ts @@ -0,0 +1,79 @@ +// 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 `<!DOCTYPE html> +<html dir="${dir === 'rtl' ? 'rtl' : 'ltr'}"><head><meta charset="utf-8"> +<meta http-equiv="Content-Security-Policy" content="${emailIframeCsp(true)}"> +<meta name="referrer" content="no-referrer"> +<title>${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; +} diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index acb69b8a6..9bd983963 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -58,6 +58,7 @@ import { import { MAX_PLUGIN_FETCH_BYTES, readFileName, readMediaType, sessionClient } from './host-api-input'; import { doComposeOpen, doComposeReply, doDraftsCreate } from './host-api-compose'; import { doEmailForward } from './host-api-forward'; +import { doUiPrint } from './host-api-print'; import { doNotificationsShow } from './host-api-notify'; import { doAccountDataGet, doAccountDataSet, doUserFindPeople, doUserGetMailAccounts, doUserGetSelf } from './host-api-account'; import { setSectionTitle } from './section-titles'; @@ -192,6 +193,8 @@ export const PERM_PER_METHOD: Readonly> = { 'ui.rerenderEmail': null, 'ui.rerenderFetchedEmails': null, 'ui.openExternalUrl': null, + // Print a page the plugin made (sanitised, no scripts, no network); 3 a minute. + 'ui.print': null, 'ui.downloadFile': 'ui:download-file', // email keyword mutations 'email.setKeyword': 'email:write', @@ -2202,6 +2205,7 @@ export async function dispatchApiCall( window.open(parsed.toString(), '_blank', 'noopener,noreferrer'); return undefined; } + case 'ui.print': return doUiPrint(plugin, args[0]); case 'ui.downloadFile': { const opts = args[0] as { content: string | ArrayBuffer | Uint8Array; filename: string; contentType?: string }; return downloadFile(opts ?? { content: '', filename: '' }); diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 83de8f9b0..e857a6ecf 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -630,6 +630,13 @@ function buildPluginApi(manifest: PluginManifest) { /** Opens an http/https URL in a new tab via host `window.open`. */ openExternalUrl: (url: string, target?: string) => callApi('ui.openExternalUrl', [url, target]) as Promise, + /** + * Print a page the plugin made, such as a document drawn from data: + * HTML with