mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: let plugins print a page they made, such as a readable invoice
This commit is contained in:
@@ -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></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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: '' });
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
Reference in New Issue
Block a user