feat: let plugins print a page they made, such as a readable invoice

This commit is contained in:
Linus Rath
2026-10-29 23:30:43 +01:00
parent f6b641c94e
commit 23767b9c80
4 changed files with 160 additions and 0 deletions
+70
View File
@@ -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: '<style>td { padding: 4px }</style><table><tr><td>Total</td><td>119,00 €</td></tr></table>'
+ '<script>parent.steal()</script><img src="https://tracker.example/p.gif"><form action="https://x.example"><input name="a"></form>',
})).toBe(true);
expect(printed).toHaveLength(1);
const page = printed[0];
expect(page).toContain('<title>Rechnung 2026-117</title>');
expect(page).toContain('<td>119,00 €</td>');
expect(page).toContain('td { padding: 4px }');
expect(page).not.toContain('<script');
expect(page).not.toContain('<form');
expect(page).toMatch(/default-src 'none'; img-src data: blob:;/);
const frame = document.querySelector('iframe')!;
expect(frame.getAttribute('sandbox')).toBe('allow-same-origin allow-modals');
});
it('escapes the title and refuses bad input', async () => {
expect(printDocument('</title><script>x()</script>', '', 'ltr')).toContain('<title>&lt;/title&gt;&lt;script&gt;');
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);
});
});
+79
View File
@@ -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)}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #000; background: #fff; margin: 0; }
img { max-width: 100%; height: auto; }
@page { margin: 15mm; }
</style></head><body>${sanitizeEmailHtmlForIframe(html)}</body></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<boolean> {
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<void>((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;
}
+4
View File
@@ -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<Record<string, Permission | null>> = {
'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: '' });
+7
View File
@@ -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<void>,
/**
* Print a page the plugin made, such as a document drawn from data:
* HTML with <style>, sanitised like a message body, no scripts, images
* as data: URLs only. Opens the browser's print dialog (where the user
* can also save a PDF); 3 times a minute.
*/
print: (page: { title?: string; html: string }) => callApi('ui.print', [page], 0) as Promise<boolean>,
/** Downloads a file generated by the plugin. Not a user's file or attachment. */
/** `content` is text, or the file's bytes (ArrayBuffer / Uint8Array). */
downloadFile: (opts: { content: string | ArrayBuffer | Uint8Array; filename: string; contentType?: string }) =>