From 23767b9c80765b962a97f86efa96453f1c5707cd Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Thu, 29 Oct 2026 23:30:43 +0100
Subject: [PATCH] feat: let plugins print a page they made, such as a readable
invoice
---
lib/__tests__/plugin-host-print.test.ts | 70 ++++++++++++++++++++++
lib/plugin-sandbox/host-api-print.ts | 79 +++++++++++++++++++++++++
lib/plugin-sandbox/host-api.ts | 4 ++
lib/plugin-sandbox/runtime.tsx | 7 +++
4 files changed, 160 insertions(+)
create mode 100644 lib/__tests__/plugin-host-print.test.ts
create mode 100644 lib/plugin-sandbox/host-api-print.ts
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: '
'
+ + '
',
+ })).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('', '', '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: 'x
' });
+ await expect(call('ui.print', { html: 'x
' })).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 `
+
+
+
+${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