diff --git a/components/email/__tests__/plugin-row-badges.test.tsx b/components/email/__tests__/plugin-row-badges.test.tsx index 5db64285b..8ac012a9a 100644 --- a/components/email/__tests__/plugin-row-badges.test.tsx +++ b/components/email/__tests__/plugin-row-badges.test.tsx @@ -168,6 +168,17 @@ describe('plugin badges in message-list rows', () => { expect(screen.queryByTestId('plugin-row-badges')).toBeNull(); transform.mockRestore(); }); + + it('asks rows drawn before the plugin started once its handler arrives, and drops the badge when it goes', async () => { + render(row([makeEmail()])); + await act(async () => {}); + expect(screen.queryByTestId('plugin-row-badges')).toBeNull(); + let off: { dispose: () => void } = { dispose: () => {} }; + act(() => { off = renderHooks.onEmailListItemRender.register('e-invoice', () => [{ key: 'e', label: 'E-invoice' }]); }); + expect(await screen.findByText('E-invoice')).toBeInTheDocument(); + act(() => { off.dispose(); }); + await waitFor(() => expect(screen.queryByText('E-invoice')).toBeNull()); + }); }); describe('normalizeBadges', () => { diff --git a/components/settings/plugins-settings.tsx b/components/settings/plugins-settings.tsx index 783b90d02..e1ddcd46b 100644 --- a/components/settings/plugins-settings.tsx +++ b/components/settings/plugins-settings.tsx @@ -17,6 +17,7 @@ import { effectiveSettings } from '@/lib/plugin-settings'; import { PLUGIN_REFUSED_ERROR } from '@/lib/outgoing-hosts'; import { lookup } from '@/lib/plugin-sandbox/plugin-i18n'; import { forgetCredentials, listConnectedAccounts, type ConnectedAccount } from '@/lib/plugin-sandbox/connect-dialog'; +import { forwardApprovals, revokeForwardApproval } from '@/lib/plugin-sandbox/forward-approvals'; const STATUS_COLORS: Record = { installed: 'bg-muted text-muted-foreground', @@ -326,6 +327,8 @@ function PluginCard({ plugin, isExpanded, isForceEnabled, controlsDisabled, onTo )} + {plugin.permissions.includes('email:forward') && } + {!isManaged && (
+
+ ))} + + ); +} + // ─── Auto-generated Setting Field ──────────────────────────── interface PluginSettingFieldProps { diff --git a/lib/__tests__/plugin-host-email-forward.test.ts b/lib/__tests__/plugin-host-email-forward.test.ts new file mode 100644 index 000000000..050a26bd9 --- /dev/null +++ b/lib/__tests__/plugin-host-email-forward.test.ts @@ -0,0 +1,230 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { InstalledPlugin } from '../plugin-types'; +import type { IJMAPClient } from '../jmap/client-interface'; +import type { Email } from '../jmap/types'; +import { useAuthStore } from '@/stores/auth-store'; +import { useAccountStore } from '@/stores/account-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useIdentityStore } from '@/stores/identity-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { emailHooks } from '../plugin-hooks'; +import { head, resolveHead, resetDialogLimits } from '../plugin-sandbox/host-dialog'; +import { resetPluginRateLimits } from '../plugin-sandbox/host-api-input'; +import { forwardApprovals, revokeForwardApproval } from '../plugin-sandbox/forward-approvals'; +import { clearPluginStorageForAccount } from '../plugin-sandbox/storage-scope'; +import { HIGH_RISK_PERMISSIONS } from '../plugin-sandbox/permission-text'; + +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'; + +function plugin(perms: string[], id = 'e-invoice'): InstalledPlugin { + return { + id, name: 'E-Invoice', version: '1.0.0', author: 'test', description: '', + type: 'ui-extension', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js', + enabled: true, status: 'running', settings: {}, + }; +} + +const FORWARDER = plugin(['email:forward']); +const call = (p: InstalledPlugin, method: string, ...args: unknown[]) => dispatchApiCall(p, method, args); + +const invoice: Email = { + id: 'E1', + threadId: 'T1', + mailboxIds: { inbox: true }, + keywords: {}, + size: 9000, + receivedAt: '2026-10-20T08:00:00Z', + from: [{ name: 'Supplier GmbH', email: 'billing@supplier.example' }], + to: [{ name: 'Ann', email: 'ann@example.com' }], + subject: 'Rechnung 2026-117', + textBody: [{ partId: '1', blobId: 'tb', size: 20, type: 'text/plain' }], + bodyValues: { 1: { value: 'Please find the invoice attached.' } }, + attachments: [ + { partId: '2', blobId: 'pdf-1', name: 'invoice.pdf', type: 'application/pdf', size: 5000, disposition: 'attachment' }, + { partId: '3', blobId: 'xml-1', name: 'xrechnung.xml', type: 'application/xml', size: 3000, disposition: 'attachment' }, + { partId: '4', blobId: 'logo', name: 'logo.png', type: 'image/png', size: 900, disposition: 'inline', cid: 'logo@x' }, + ], +} as unknown as Email; + +/** Answer the host's confirm once it shows. */ +async function answerDialog(allow: boolean): Promise { + await vi.waitFor(() => expect(head()).not.toBeNull()); + const shown = head()!; + resolveHead(allow); + return `${shown.title}\n${shown.message}`; +} + +describe('api.email.forward', () => { + let client: Record>; + let sendEmail: ReturnType; + + beforeEach(() => { + resetPluginRateLimits(); + resetDialogLimits(); + localStorage.clear(); + client = { + getAccountId: vi.fn(() => 'own'), + getEmail: vi.fn(async (id: string, accountId?: string) => (id === 'E1' ? { ...invoice, accountId } : null)), + getMailboxes: vi.fn(async () => []), + resolveSendAccountId: vi.fn(async () => 'own'), + copyBlobs: vi.fn(async (_from: string, ids: string[]) => Object.fromEntries(ids.map((id) => [id, `copy-${id}`]))), + hasDelayedSend: vi.fn(() => true), + batchUpdateKeywords: vi.fn(async () => undefined), + }; + sendEmail = vi.fn(async () => ({ emailId: 'S1', emailSubmissionId: 'sub-1', scheduled: false })); + useAuthStore.setState({ client: client as unknown as IJMAPClient }); + useAccountStore.setState({ activeAccountId: 'acct-1' }); + useEmailStore.setState({ sendEmail, emails: [], selectedEmail: null, mailboxes: [] } as never); + useIdentityStore.setState({ + identities: [{ id: 'id-a', name: 'Ann', email: 'ann@example.com' }] as never, + preferredPrimaryId: 'id-a', + }); + useSettingsStore.setState({ sendDelaySeconds: 0 }); + }); + afterEach(() => { + while (head()) resolveHead(false); + useAuthStore.setState({ client: null }); + useIdentityStore.setState({ identities: [], preferredPrimaryId: null }); + }); + + it('asks before the first forward to an address, then sends with the attachments and marks the original', async () => { + const pending = call(FORWARDER, 'email.forward', 'E1', { to: ['Accounting '], text: 'For booking.' }); + const shown = await answerDialog(true); + expect(shown).toContain('E-Invoice'); + expect(shown).toContain('buchhaltung@firma.example'); + expect(shown).toContain('Rechnung 2026-117'); + expect(await pending).toEqual({ sent: true, emailId: 'S1', to: ['buchhaltung@firma.example'], scheduledFor: null }); + + expect(sendEmail).toHaveBeenCalledTimes(1); + const [, to, subject, body, , , identityId, fromEmail, , , html, attachments, , , delayedUntil] = sendEmail.mock.calls[0]; + expect({ to, subject, identityId, fromEmail, html, delayedUntil }).toEqual({ + to: ['Accounting '], subject: 'Fwd: Rechnung 2026-117', + identityId: 'id-a', fromEmail: 'ann@example.com', html: undefined, delayedUntil: undefined, + }); + expect(body).toMatch(/^For booking\.\n\n---------- Forwarded message ----------\nFrom: Supplier GmbH \n/); + expect(body).toContain('Subject: Rechnung 2026-117'); + expect(body).toContain('Please find the invoice attached.'); + // The inline logo of the body stays behind. + expect(attachments).toEqual([ + { blobId: 'pdf-1', name: 'invoice.pdf', type: 'application/pdf', size: 5000, disposition: 'attachment' }, + { blobId: 'xml-1', name: 'xrechnung.xml', type: 'application/xml', size: 3000, disposition: 'attachment' }, + ]); + expect(client.batchUpdateKeywords).toHaveBeenCalledWith(['E1'], { 'keywords/$forwarded': true }, undefined); + expect(forwardApprovals('e-invoice')).toEqual(['buchhaltung@firma.example']); + + // The next forward there goes out without a question. + expect(await call(FORWARDER, 'email.forward', 'E1', { to: ['buchhaltung@firma.example'] })).toMatchObject({ sent: true }); + expect(head()).toBeNull(); + expect(sendEmail).toHaveBeenCalledTimes(2); + }); + + it('sends nothing and remembers nothing when the user says no', async () => { + const pending = call(FORWARDER, 'email.forward', 'E1', { to: ['eve@evil.example'] }); + await answerDialog(false); + expect(await pending).toEqual({ sent: false, reason: 'declined' }); + expect(sendEmail).not.toHaveBeenCalled(); + expect(forwardApprovals('e-invoice')).toEqual([]); + }); + + it('asks again for an address that is new, even next to an allowed one', async () => { + revokeForwardApproval('e-invoice', 'nobody@example.com'); + const first = call(FORWARDER, 'email.forward', 'E1', { to: ['books@example.com'] }); + await answerDialog(true); + await first; + const second = call(FORWARDER, 'email.forward', 'E1', { to: ['books@example.com', 'eve@evil.example'] }); + const shown = await answerDialog(false); + expect(shown).toContain('eve@evil.example'); + expect(shown).not.toContain('books@example.com'); + expect(await second).toEqual({ sent: false, reason: 'declined' }); + expect(sendEmail).toHaveBeenCalledTimes(1); + }); + + it('sends only the attachments named, and only attachments of that message', async () => { + const pending = call(FORWARDER, 'email.forward', 'E1', { to: ['books@example.com'], attachments: ['xml-1'] }); + await answerDialog(true); + await pending; + expect(sendEmail.mock.calls[0][11]).toEqual([expect.objectContaining({ blobId: 'xml-1' })]); + await expect(call(FORWARDER, 'email.forward', 'E1', { to: ['books@example.com'], attachments: ['someone-elses-blob'] })) + .rejects.toThrow(/not an attachment of this message/); + }); + + it('copies the attachments when the message is in a team mailbox', async () => { + useEmailStore.setState({ mailboxes: [{ id: 'team-inbox', name: 'Inbox', role: 'inbox', isShared: true, accountId: 'team' }] } as never); + const pending = call(FORWARDER, 'email.forward', 'E1', { to: ['books@example.com'], accountId: 'team' }); + await answerDialog(true); + await pending; + expect(client.getEmail).toHaveBeenCalledWith('E1', 'team'); + expect(client.copyBlobs).toHaveBeenCalledWith('team', ['pdf-1', 'xml-1'], 'own'); + expect(sendEmail.mock.calls[0][11].map((a: { blobId: string }) => a.blobId)).toEqual(['copy-pdf-1', 'copy-xml-1']); + expect(client.batchUpdateKeywords).toHaveBeenCalledWith(['E1'], { 'keywords/$forwarded': true }, 'team'); + }); + + it('holds the forward for the undo delay like any other send', async () => { + useSettingsStore.setState({ sendDelaySeconds: 10 }); + sendEmail.mockResolvedValueOnce({ emailId: 'S1', emailSubmissionId: 'sub-1', scheduled: true, sendAt: '2026-10-29T10:00:10Z' }); + const pending = call(FORWARDER, 'email.forward', 'E1', { to: ['books@example.com'] }); + await answerDialog(true); + expect(await pending).toMatchObject({ sent: true, scheduledFor: '2026-10-29T10:00:10Z' }); + expect(sendEmail.mock.calls[0][14]).toEqual(expect.any(String)); + }); + + it('lets another plugin stop it in onBeforeEmailSend', async () => { + const seen: unknown[] = []; + const off = emailHooks.onBeforeEmailSend.register('guardrail', (outgoing) => { seen.push(outgoing); return false; }); + try { + const pending = call(FORWARDER, 'email.forward', 'E1', { to: ['books@example.com'] }); + await answerDialog(true); + expect(await pending).toEqual({ sent: false, reason: 'stopped' }); + expect(seen).toEqual([expect.objectContaining({ subject: 'Fwd: Rechnung 2026-117', to: ['books@example.com'] })]); + expect(sendEmail).not.toHaveBeenCalled(); + } finally { + off.dispose(); + } + }); + + it('needs email:forward and sound input', async () => { + await expect(call(plugin(['email:read', 'email:send']), 'email.forward', 'E1', { to: ['a@example.com'] })) + .rejects.toThrow(/lacks permission "email:forward"/); + await expect(call(FORWARDER, 'email.forward', 'E1', { to: [] })).rejects.toThrow(/at least one address/); + await expect(call(FORWARDER, 'email.forward', 'E1', { to: Array.from({ length: 11 }, (_, i) => `u${i}@example.com`) })) + .rejects.toThrow(/at most 10 addresses/); + await expect(call(FORWARDER, 'email.forward', 'E1', { to: ['a@example.com'], bcc: ['eve@evil.example'] })) + .rejects.toThrow(/unknown property "bcc"/); + await expect(call(FORWARDER, 'email.forward', 'missing', { to: ['a@example.com'] })).rejects.toThrow(/no message/); + expect(sendEmail).not.toHaveBeenCalled(); + }); + + it('is high risk in the consent dialog', () => { + expect(HIGH_RISK_PERMISSIONS.has('email:forward')).toBe(true); + }); +}); + +describe('forward approvals', () => { + beforeEach(() => { + localStorage.clear(); + useAccountStore.setState({ activeAccountId: 'acct-1' }); + }); + + it('are out of reach of the plugin’s own storage', async () => { + const p = plugin(['email:forward']); + localStorage.setItem('plugin:e-invoice:!forward-to:@acct-1:', JSON.stringify(['books@example.com'])); + expect(await call(p, 'storage.keys')).toEqual([]); + await call(p, 'storage.set', '!forward-to', ['eve@evil.example']); + await call(p, 'storage.set', ':!forward-to:@acct-1:', ['eve@evil.example']); + expect(forwardApprovals('e-invoice')).toEqual(['books@example.com']); + }); + + it('go when the account signs out or the user removes them', () => { + localStorage.setItem('plugin:e-invoice:!forward-to:@acct-1:', JSON.stringify(['a@example.com', 'b@example.com'])); + localStorage.setItem('plugin:e-invoice:!forward-to:@acct-2:', JSON.stringify(['c@example.com'])); + revokeForwardApproval('e-invoice', 'A@example.com'); + expect(forwardApprovals('e-invoice')).toEqual(['b@example.com']); + clearPluginStorageForAccount('acct-1'); + expect(forwardApprovals('e-invoice')).toEqual([]); + expect(forwardApprovals('e-invoice', 'acct-2')).toEqual(['c@example.com']); + }); +}); 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/__tests__/plugin-permission-coverage.test.ts b/lib/__tests__/plugin-permission-coverage.test.ts index 71e78ad4f..c34925f2f 100644 --- a/lib/__tests__/plugin-permission-coverage.test.ts +++ b/lib/__tests__/plugin-permission-coverage.test.ts @@ -79,6 +79,7 @@ describe('plugin permissions', () => { 'email.getRaw': 'email:blob-read', 'compose.open': 'compose:open', 'drafts.create': 'email:write', + 'email.forward': 'email:forward', 'notifications.show': 'ui:notifications', }); }); diff --git a/lib/__tests__/plugin-ui-kit.test.tsx b/lib/__tests__/plugin-ui-kit.test.tsx index 08746956e..f11c92a9d 100644 --- a/lib/__tests__/plugin-ui-kit.test.tsx +++ b/lib/__tests__/plugin-ui-kit.test.tsx @@ -127,6 +127,35 @@ describe('plugin UI kit components', () => { expect(container.querySelector('dd')).toHaveTextContent('2 MB'); }); + it('draws a table with amounts aligned to the end and totals under the last column', () => { + render(<kit.Table + label="Items" + columns={[ + { key: 'name', label: 'Item' }, + { key: 'qty', label: 'Qty', align: 'end', hideNarrow: true }, + { key: 'net', label: 'Net', align: 'end' }, + ]} + rows={[{ key: 'a', name: 'Consulting', qty: '8 h', net: '800.00' }, { name: 'Travel', qty: '1', net: '120.50' }]} + summary={[{ label: 'VAT 19 %', value: '174.80' }, { label: 'Total', value: '1,095.30', strong: true }]} + />); + const table = screen.getByRole('table', { name: 'Items' }); + expect(screen.getAllByRole('columnheader').map((h) => h.textContent)).toEqual(['Item', 'Qty', 'Net']); + const travel = screen.getByText('Travel').closest('tr')!; + expect([...travel.children].map((c) => c.textContent)).toEqual(['Travel', '1', '120.50']); + expect(screen.getByText('120.50').className).toContain('text-end'); + expect(screen.getByText('1').className).toContain('hidden sm:table-cell'); + const total = screen.getByRole('rowheader', { name: 'Total' }); + expect(total.getAttribute('colspan')).toBe('2'); + expect(total.closest('tr')!.lastElementChild!).toHaveTextContent('1,095.30'); + expect(total.className).toContain('font-semibold'); + expect(table.closest('.bk')).not.toBeNull(); + }); + + it('says so when a table has no rows', () => { + render(<kit.Table columns={[{ key: 'a', label: 'A' }, { key: 'b', label: 'B' }]} rows={[]} empty="No items" />); + expect(screen.getByText('No items').getAttribute('colspan')).toBe('2'); + }); + it('makes sidebar rows as tall as folder rows on touch screens', () => { // The page sets --touch-row-min-h to 44px below lg, where folder rows // are max-lg:min-h-[44px]; the frame can't see the page's width itself. diff --git a/lib/outgoing/send-pipeline.ts b/lib/outgoing/send-pipeline.ts index 826a4b988..c49f3e0b4 100644 --- a/lib/outgoing/send-pipeline.ts +++ b/lib/outgoing/send-pipeline.ts @@ -18,7 +18,8 @@ import { debug } from '@/lib/debug'; -export type SendSurface = 'composer' | 'quick-reply' | 'calendar-invitation'; +// 'plugin-forward': a plugin forwarding a message (api.email.forward). +export type SendSurface = 'composer' | 'quick-reply' | 'calendar-invitation' | 'plugin-forward'; export interface OutgoingContext { surface: SendSurface; diff --git a/lib/plugin-hooks.ts b/lib/plugin-hooks.ts index 6b83e5fa5..b46b5ba01 100644 --- a/lib/plugin-hooks.ts +++ b/lib/plugin-hooks.ts @@ -1,6 +1,7 @@ // Plugin Hook Bus - event bus system for plugin lifecycle hooks import type { CalendarEventFormView, Disposable } from './plugin-types'; +import { rowBadgeHandlersChanged } from './plugin-sandbox/row-badges'; // ─── Error Tracker (Circuit Breaker) ───────────────────────── @@ -158,31 +159,38 @@ export class HookBus<T extends (...args: any[]) => any> { * @param opts.failClosed For intercepts guarding an action that can't be * taken back (sending mail): a handler that throws or runs out of time * cancels the action instead of letting it through. + * @param opts.onChange Called when handlers come or go, for UI that asked + * the bus before a plugin started (row badges). */ constructor( private readonly timeoutMs: number = DEFAULT_TIMEOUT_MS, - private readonly opts: { failClosed?: boolean } = {}, + private readonly opts: { failClosed?: boolean; onChange?: () => void } = {}, ) {} + private setHandlers(next: HookEntry<T>[]): void { + const changed = next.length !== this.handlers.length; + this.handlers = next; + if (changed) this.opts.onChange?.(); + } + register(pluginId: string, handler: T, order: number = 100): Disposable { const entry: HookEntry<T> = { pluginId, handler, order }; - this.handlers.push(entry); - this.handlers.sort((a, b) => a.order - b.order); + this.setHandlers([...this.handlers, entry].sort((a, b) => a.order - b.order)); return { dispose: () => { - this.handlers = this.handlers.filter(h => h !== entry); + this.setHandlers(this.handlers.filter(h => h !== entry)); }, }; } /** Remove all handlers for a given plugin */ removePlugin(pluginId: string): void { - this.handlers = this.handlers.filter(h => h.pluginId !== pluginId); + this.setHandlers(this.handlers.filter(h => h.pluginId !== pluginId)); } /** Remove all handlers */ clear(): void { - this.handlers = []; + this.setHandlers([]); } get size(): number { @@ -837,7 +845,8 @@ export const renderHooks = { // Second argument: { emailId: string; email: EmailReadView } // Handlers return a new (or extended) badges array. // Rendered by the email list row component next to the subject line. - onEmailListItemRender: new HookBus(), + // Rows drawn before a plugin started ask again once its handler is there. + onEmailListItemRender: new HookBus(undefined, { onChange: rowBadgeHandlersChanged }), // Transform hook - runs when an email is opened, BEFORE the viewer computes // the body it will render. Initial value: RenderableBody { html, text, // attachments, handledBy? }. Second argument: MessageContext { id, diff --git a/lib/plugin-projection.ts b/lib/plugin-projection.ts index 3aa5a58c6..c950b4d02 100644 --- a/lib/plugin-projection.ts +++ b/lib/plugin-projection.ts @@ -16,7 +16,7 @@ import { isServerSetKeyword } from '@/lib/jmap/server-keywords'; // Resolve a message's plain-text body from its JMAP body parts. Plugins that // translate or scan content need the real body, not just the short `preview` // snippet. Prefers text/plain parts; falls back to stripped HTML, then preview. -function plainTextFromEmail(email: Email): string { +export function plainTextFromEmail(email: Email): string { const values = email.bodyValues || {}; const collect = (parts?: { partId: string }[]) => (parts || []) diff --git a/lib/plugin-sandbox/forward-approvals.ts b/lib/plugin-sandbox/forward-approvals.ts new file mode 100644 index 000000000..eae9d0f67 --- /dev/null +++ b/lib/plugin-sandbox/forward-approvals.ts @@ -0,0 +1,39 @@ +// The addresses the user let a plugin forward mail to (api.email.forward), +// per plugin and signed-in account, in a key the plugin can't write +// (hostOnlyPluginKey). Settings > Plugins lists them and takes them back. + +import { useAccountStore } from '@/stores/account-store'; +import { hostOnlyPluginKey } from './storage-scope'; + +const APPROVALS = 'forward-to'; + +export function approvalAccount(): string { + return useAccountStore.getState().activeAccountId ?? 'signed-out'; +} + +/** The addresses the user let `pluginId` forward to, in this account, lower case. */ +export function forwardApprovals(pluginId: string, accountId = approvalAccount()): string[] { + try { + const raw = window.localStorage.getItem(hostOnlyPluginKey(pluginId, accountId, APPROVALS)); + const list: unknown = raw ? JSON.parse(raw) : []; + return Array.isArray(list) ? list.filter((a): a is string => typeof a === 'string') : []; + } catch { + return []; + } +} + +export function saveForwardApprovals(pluginId: string, accountId: string, list: string[]): void { + const key = hostOnlyPluginKey(pluginId, accountId, APPROVALS); + try { + if (list.length) window.localStorage.setItem(key, JSON.stringify([...new Set(list)].sort())); + else window.localStorage.removeItem(key); + } catch { + // Storage unavailable: the user is asked again next time. + } +} + +/** Take back the permission to forward to `address`. */ +export function revokeForwardApproval(pluginId: string, address: string, accountId = approvalAccount()): void { + const lower = address.toLowerCase(); + saveForwardApprovals(pluginId, accountId, forwardApprovals(pluginId, accountId).filter((a) => a !== lower)); +} diff --git a/lib/plugin-sandbox/host-api-app.ts b/lib/plugin-sandbox/host-api-app.ts index 4015e2310..3e795ec63 100644 --- a/lib/plugin-sandbox/host-api-app.ts +++ b/lib/plugin-sandbox/host-api-app.ts @@ -25,7 +25,7 @@ type Messages = Record<string, unknown>; const MAX_QUERY = 200; const MAX_RESULTS = 20; -async function messagesFor(locale: string): Promise<Messages> { +export async function messagesFor(locale: string): Promise<Messages> { const loader = LOCALE_LOADERS[locale]; if (locale === 'en' || !loader) return enMessages as Messages; try { diff --git a/lib/plugin-sandbox/host-api-forward.ts b/lib/plugin-sandbox/host-api-forward.ts new file mode 100644 index 000000000..09d2cbb53 --- /dev/null +++ b/lib/plugin-sandbox/host-api-forward.ts @@ -0,0 +1,230 @@ +// Host side of api.email.forward: a plugin forwards a message of the account, +// with its attachments, without opening the composer. The e-invoice plugin +// uses it to send an invoice to the accounting address in one click. +// +// Sending in the user's name is the risky part, so: +// - the first forward to an address asks the user in a dialog the host +// draws (naming the plugin, the message and the address); "Allow" is +// remembered for that plugin, address and account, and Settings > Plugins +// lists and removes what was allowed; +// - the forward goes through the same pre-send checks and finalisers as a +// quick reply (external recipients, the organisation's disclaimer) and +// other plugins' onBeforeEmailSend, and is held for the user's undo delay; +// - the copy lands in Sent and the original is marked $forwarded, as after +// a forward from the composer. + +import type { EmailForwardResult, InstalledPlugin, OutgoingEmail } from '../plugin-types'; +import type { Attachment, Email } from '../jmap/types'; +import { useIdentityStore } from '@/stores/identity-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useLocaleStore } from '@/stores/locale-store'; +import { detectBrowserLocale } from '@/i18n/detect-locale'; +import { emailHooks } from '../plugin-hooks'; +import { announceSent } from '../plugin-after-send'; +import { buildForwardSubject } from '../subject-prefix'; +import { finalizeOutgoing, runPreSendChecks } from '../outgoing/send-pipeline'; +// Registers the organisation signature check and the disclaimer. +import '@/lib/org/outgoing'; +import { plainTextFromEmail } from '../plugin-projection'; +import { getByPath } from '../settings-search'; +import { readNamedEmail } from './host-api-mail'; +import { messagesFor } from './host-api-app'; +import { approvalAccount, forwardApprovals, saveForwardApprovals } from './forward-approvals'; +import { addressOf, readAddressList, readId, readObject, readString, sessionClient, withinRate } from './host-api-input'; + +/** Most recipients of one forward. */ +export const MAX_FORWARD_RECIPIENTS = 10; +/** Forwards a plugin may send per minute. */ +export const MAX_FORWARDS_PER_MINUTE = 10; +const MAX_NOTE = 64 * 1024; +const MAX_SELECTED_ATTACHMENTS = 50; + +// ─── Text in the user's language ────────────────────────────── + +type Messages = Record<string, unknown>; + +async function hostMessages(): Promise<Messages> { + return messagesFor(useLocaleStore.getState().locale || detectBrowserLocale('en')); +} + +function text(messages: Messages, path: string, vars: Record<string, string> = {}): string { + const value = getByPath(messages, path); + const template = typeof value === 'string' ? value : path; + return template.replace(/\{(\w+)\}/g, (whole, name: string) => vars[name] ?? whole); +} + +// ─── The forward ────────────────────────────────────────────── + +export interface ForwardDeps { + /** Shows a confirm the host draws for the plugin; resolves to the user's answer. */ + confirm: (input: { title: string; message: string; confirmLabel: string; cancelLabel: string }) => Promise<boolean>; + /** Sets keywords on the original the way api.email.updateKeywords does. */ + markForwarded: (emailId: string, accountId: string | undefined) => Promise<unknown>; +} + +function formatAddress(entry: { name?: string | null; email?: string | null } | undefined): string { + if (!entry?.email) return ''; + return entry.name ? `${entry.name} <${entry.email}>` : entry.email; +} + +function attachmentsOf(email: Email): Attachment[] { + return (email.attachments ?? []).filter((a) => !!a.blobId); +} + +/** An inline image of the body, which a forward of the text leaves out. */ +function isInline(part: Attachment): boolean { + return part.disposition === 'inline' && !!part.cid; +} + +function pickAttachments(email: Email, selection: unknown, method: string): Attachment[] { + const all = attachmentsOf(email); + if (selection === undefined) return all.filter((a) => !isInline(a)); + if (!Array.isArray(selection)) throw new Error(`${method}: attachments must be an array of blobIds`); + if (selection.length > MAX_SELECTED_ATTACHMENTS) throw new Error(`${method}: at most ${MAX_SELECTED_ATTACHMENTS} attachments`); + return [...new Set(selection.map((id, i) => readId(id, method, `attachments[${i}]`)))].map((blobId) => { + const part = all.find((a) => a.blobId === blobId); + if (!part) throw new Error(`${method}: ${blobId} is not an attachment of this message`); + return part; + }); +} + +/** The forwarded message as text: the note, the original's header lines and its text. */ +export function forwardBody(email: Email, note: string, messages: Messages, locale: string): string { + const label = (key: string) => text(messages, `quote_header.${key}`); + const date = email.receivedAt ? new Date(email.receivedAt) : null; + const lines = [ + label('forwarded_separator'), + `${label('from_label')}: ${(email.from ?? []).map(formatAddress).join(', ')}`, + `${label('date_label')}: ${date && !Number.isNaN(date.getTime()) ? date.toLocaleString(locale) : ''}`, + `${label('subject_label')}: ${email.subject ?? ''}`, + ]; + const to = (email.to ?? []).map(formatAddress).filter(Boolean).join(', '); + if (to) lines.push(`${text(messages, 'email_viewer.to')}: ${to}`); + const head = note.trim() ? `${note.trim()}\n\n` : ''; + return `${head}${lines.join('\n')}\n\n${plainTextFromEmail(email)}`; +} + +/** + * Forward `emailId` to `input.to` with the original's attachments. Resolves + * to what happened; throws on bad input, a missing message or a failed send. + */ +export async function doEmailForward( + plugin: InstalledPlugin, + emailIdArg: unknown, + inputArg: unknown, + deps: ForwardDeps, +): Promise<EmailForwardResult> { + const method = 'email.forward'; + const emailId = readId(emailIdArg, method, 'emailId'); + const input = readObject(inputArg, method, 'forward', ['to', 'text', 'attachments', 'accountId']); + const to = readAddressList(input.to, method, 'to', MAX_FORWARD_RECIPIENTS); + if (to.length === 0) throw new Error(`${method}: to must name at least one address`); + const note = input.text === undefined ? '' : readString(input.text, method, 'text', MAX_NOTE); + if (!withinRate(method, plugin.id, MAX_FORWARDS_PER_MINUTE)) { + throw new Error(`${method}: at most ${MAX_FORWARDS_PER_MINUTE} forwards a minute`); + } + + const client = sessionClient(method); + const found = await readNamedEmail(client, emailId, method, input.accountId); + if (!found) throw new Error(`${method}: no message with id "${emailId}"`); + const { email, accountId: sourceAccountId } = found; + const attachments = pickAttachments(email, input.attachments, method); + + const { identities, preferredPrimaryId } = useIdentityStore.getState(); + const identity = identities.find((i) => i.id === preferredPrimaryId) ?? identities[0]; + if (!identity) throw new Error(`${method}: the account has no sender address to forward from`); + + const locale = useLocaleStore.getState().locale || detectBrowserLocale('en'); + const messages = await hostMessages(); + const subject = buildForwardSubject(email.subject || text(messages, 'email_viewer.no_subject'), text(messages, 'email_composer.prefix.forward')); + + // Ask before the first forward to an address. + const addresses = to.map((entry) => addressOf(entry).toLowerCase()); + const account = approvalAccount(); + const approved = new Set(forwardApprovals(plugin.id, account)); + const unknown = [...new Set(addresses.filter((a) => !approved.has(a)))]; + if (unknown.length > 0) { + const vars = { plugin: plugin.name || plugin.id, addresses: unknown.join(', '), subject: email.subject || text(messages, 'email_viewer.no_subject') }; + const allowed = await deps.confirm({ + title: text(messages, 'plugins.forward_approval.title', vars), + message: text(messages, 'plugins.forward_approval.message', vars), + confirmLabel: text(messages, 'plugins.forward_approval.allow'), + cancelLabel: text(messages, 'plugins.dialog.cancel'), + }); + if (!allowed) return { sent: false, reason: 'declined' }; + saveForwardApprovals(plugin.id, account, [...approved, ...unknown]); + } + + const context = { + surface: 'plugin-forward' as const, + to: addresses, + cc: [], + bcc: [], + fromEmail: identity.email, + identityId: identity.id, + sender: { name: identity.name, email: identity.email }, + mode: 'forward' as const, + plainText: true, + forwardedSeparator: text(messages, 'quote_header.forwarded_separator'), + }; + if (!(await runPreSendChecks(context))) return { sent: false, reason: 'stopped' }; + // No plugin step in between, as for a quick reply: both stages at once. + const body = finalizeOutgoing(finalizeOutgoing({ text: forwardBody(email, note, messages, locale) }, context), context, 'last').text; + + if (emailHooks.onBeforeEmailSend.size > 0) { + const outgoing: OutgoingEmail = { + to, cc: [], bcc: [], subject, + htmlBody: '', + textBody: body, + identityId: identity.id, + fromEmail: identity.email, + attachments: attachments.map((a) => ({ name: a.name || 'attachment', type: a.type || 'application/octet-stream', size: a.size })), + }; + if ((await emailHooks.onBeforeEmailSend.interceptOutcome(outgoing)) !== 'allowed') return { sent: false, reason: 'stopped' }; + } + + // The attachments are blobs of the account the original is in; a message + // of a team or shared mailbox goes out from the user's own account. + const sendAccountId = (await client.resolveSendAccountId?.(identity.email)) ?? client.getAccountId(); + const fromAccountId = sourceAccountId ?? client.getAccountId(); + let blobIds = attachments.map((a) => a.blobId); + if (blobIds.length && fromAccountId !== sendAccountId) { + const copied = await client.copyBlobs(fromAccountId, blobIds, sendAccountId); + blobIds = blobIds.map((id) => { + const copy = copied[id]; + if (!copy) throw new Error(`${method}: the attachments could not be copied to the account that sends`); + return copy; + }); + } + + const delaySeconds = useSettingsStore.getState().sendDelaySeconds; + const delayedUntil = delaySeconds > 0 && client.hasDelayedSend() + ? new Date(Date.now() + delaySeconds * 1000).toISOString() + : undefined; + const result = await useEmailStore.getState().sendEmail( + client, to, subject, body, undefined, undefined, identity.id, identity.email, undefined, + identity.name || undefined, undefined, + attachments.map((a, i) => ({ + blobId: blobIds[i], + name: a.name || 'attachment', + type: a.type || 'application/octet-stream', + size: a.size, + disposition: 'attachment' as const, + })), + undefined, undefined, delayedUntil, + ); + announceSent(result, { to: addresses, cc: [], bcc: [], subject }); + + try { + await deps.markForwarded(emailId, sourceAccountId); + } catch { + // The forward went out; a missing $forwarded mark is not worth failing it. + } + return { + sent: true, + emailId: result.emailId ?? null, + to: addresses, + scheduledFor: result.scheduled && result.sendAt ? result.sendAt : null, + }; +} diff --git a/lib/plugin-sandbox/host-api-mail.ts b/lib/plugin-sandbox/host-api-mail.ts index 5b8466409..bf36629e7 100644 --- a/lib/plugin-sandbox/host-api-mail.ts +++ b/lib/plugin-sandbox/host-api-mail.ts @@ -66,6 +66,23 @@ async function sourceAccount(client: IJMAPClient, emailId: string, method: strin return listed ? accountId : null; } +/** + * The message a plugin named, read the way email.get reads it, with the + * account it is in (undefined: the signed-in account). Null when there is + * no such message this login may read. + */ +export async function readNamedEmail( + client: IJMAPClient, + emailId: string, + method: string, + accountArg?: unknown, +): Promise<{ email: Email; accountId: string | undefined } | null> { + const accountId = await sourceAccount(client, emailId, method, accountArg); + if (accountId === null) return null; + const email = await loadEmail(client, emailId, accountId, accountArg !== undefined); + return email ? { email, accountId } : null; +} + export async function doEmailGet(id: unknown, accountArg?: unknown): Promise<EmailReadView | null> { const method = 'email.get'; const emailId = readId(id, method, 'emailId'); 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 4aa0e3253..9bd983963 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -57,6 +57,8 @@ import { } from './host-api-mail'; 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'; @@ -191,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', @@ -288,6 +292,9 @@ export const PERM_PER_METHOD: Readonly> = { // Moving messages between the account's folders, archive included. 'email.move': 'email:write', 'email.archive': 'email:write', + // Forward a message with its attachments, without the composer. The + // host asks the user before the first forward to each address. + 'email.forward': 'email:forward', // Open the composer filled in; the user still decides whether to send. 'compose.open': 'compose:open', // Reply to the message open in the reader, with text to start from. @@ -2198,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: '' }); @@ -2300,6 +2308,10 @@ export async function dispatchApiCall( case 'email.getRaw': return doEmailGetRaw(args[0], args[1]); case 'email.move': return doEmailMove(plugin.id, args[0], args[1]); case 'email.archive': return doEmailArchive(plugin.id, args[0]); + case 'email.forward': return doEmailForward(plugin, args[0], args[1], { + confirm: (input) => awaitDialog({ pluginId: plugin.id, kind: 'confirm', ...input }), + markForwarded: (emailId, accountId) => doEmailUpdateKeywords(emailId, { $forwarded: true }, accountId), + }); case 'compose.open': return doComposeOpen(plugin, args[0]); case 'compose.reply': return doComposeReply(plugin, args[0], args[1]); diff --git a/lib/plugin-sandbox/permission-text.ts b/lib/plugin-sandbox/permission-text.ts index c5c7fe0cd..b5089b10e 100644 --- a/lib/plugin-sandbox/permission-text.ts +++ b/lib/plugin-sandbox/permission-text.ts @@ -15,6 +15,8 @@ export const HIGH_RISK_PERMISSIONS: ReadonlySet = new Set([ 'email:read', 'email:send', 'email:raw-send', + // Sends mail in the user's name; the host asks once per address. + 'email:forward', 'email:blob-read', 'email:blob-write', 'email:render-takeover', diff --git a/lib/plugin-sandbox/row-badges.ts b/lib/plugin-sandbox/row-badges.ts index af35949f5..269439ae2 100644 --- a/lib/plugin-sandbox/row-badges.ts +++ b/lib/plugin-sandbox/row-badges.ts @@ -18,14 +18,27 @@ export function subscribeRowBadges(listener: () => void): () => void { return () => { listeners.delete(listener); }; } -/** Ask every visible row again. At most once a second; extra calls are dropped. */ -export function refreshRowBadges(now = Date.now()): boolean { - if (now - lastRefresh < ROW_BADGE_REFRESH_INTERVAL_MS) return false; - lastRefresh = now; +function bump(): void { generation += 1; for (const l of listeners) { try { l(); } catch { /* ignore */ } } +} + +/** + * A plugin's onEmailListItemRender handler came or went: ask every row + * again. The list usually renders before the plugins have started, and + * those rows would otherwise never show the plugin's badges. + */ +export function rowBadgeHandlersChanged(): void { + bump(); +} + +/** Ask every visible row again. At most once a second; extra calls are dropped. */ +export function refreshRowBadges(now = Date.now()): boolean { + if (now - lastRefresh < ROW_BADGE_REFRESH_INTERVAL_MS) return false; + lastRefresh = now; + bump(); return true; } diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 4f98353fc..e857a6ecf 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -33,7 +33,7 @@ import { initFloatingDropdownBridge, dispatchFloatingDropdownEvent } from './ui- import { interpolate, lookup, textDirection, type LocaleTables } from './plugin-i18n'; import { isFillableSlot, isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout'; import type { - AppInfoView, AppSettingsView, HostFeaturesView, SettingsSearchResult, CalendarEventInput, CalendarEventView, CalendarView, ComposeInput, DraftInput, EmailReadView, + AppInfoView, AppSettingsView, HostFeaturesView, SettingsSearchResult, CalendarEventInput, CalendarEventView, CalendarView, ComposeInput, DraftInput, EmailForwardInput, EmailForwardResult, EmailReadView, EmailSummary, IdentityPatch, IdentityView, MailboxView, NotificationInput, SlotName, TaskInput, TaskView, TemplateInput, TemplateView, VacationInput, VacationView, } from '../plugin-types'; @@ -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