From 6cbd53c8272d0197c11b6e6e77fbac4e62e78071 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 08:06:49 +0100 Subject: [PATCH 01/17] fix: run plugin send checks for quick replies --- components/mail/mail-app.tsx | 22 ++++++----- lib/outgoing/__tests__/quick-reply.test.ts | 44 +++++++++++++++++++++- lib/outgoing/plugin-send-check.ts | 23 +++++++++++ lib/outgoing/quick-reply.ts | 33 ++++++++++++++-- lib/plugin-types.ts | 7 ++++ 5 files changed, 116 insertions(+), 13 deletions(-) create mode 100644 lib/outgoing/plugin-send-check.ts diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 310ef7dcb..e04c7186d 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -4099,6 +4099,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }); } }; + // RFC 5322 §3.6.4 threading - keep the conversation stitched together (#234). + const threading = computeReplyThreadingHeaders({ + messageId: selectedEmail.messageId, + references: selectedEmail.references, + }); + const replySubject = buildReplySubject(selectedEmail.subject || t('email_viewer.no_subject'), t('email_composer.prefix.reply')); const prepared = await prepareQuickReply({ body, signatureIdentity, @@ -4107,6 +4113,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { to: recipients, fromEmail: headerFromEmail, identityId: sendingIdentity?.id, + subject: replySubject, + inReplyTo: threading?.inReplyTo?.[0], + // A plugin that said no told the user; one that crashed didn't. + onSendCheckStopped: (outcome) => { + if (outcome === 'failed') toast.error(t('email_composer.send_guard_failed')); + }, questions: { // The fill-in words of the UI language and of English, as the composer. placeholderWords: settings.placeholderWarningEnabled @@ -4137,16 +4149,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } } - // RFC 5322 §3.6.4 threading - keep the conversation stitched together (#234). - const threading = computeReplyThreadingHeaders({ - messageId: selectedEmail.messageId, - references: selectedEmail.references, - }); - // The label goes on last, as in the composer. - const labelled = classifyOutgoing(quickLabel.policy, quickLabel.label, { - subject: buildReplySubject(selectedEmail.subject || t('email_viewer.no_subject'), t('email_composer.prefix.reply')), - }); + const labelled = classifyOutgoing(quickLabel.policy, quickLabel.label, { subject: replySubject }); // Send reply with just the body text const result = await sendEmail( diff --git a/lib/outgoing/__tests__/quick-reply.test.ts b/lib/outgoing/__tests__/quick-reply.test.ts index 8ee19a28e..723e96556 100644 --- a/lib/outgoing/__tests__/quick-reply.test.ts +++ b/lib/outgoing/__tests__/quick-reply.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { prepareQuickReply, type QuickReplyQuestion, type QuickReplyQuestions } from '@/lib/outgoing/quick-reply'; import { registerOutgoingFinalizer, registerPreSendCheck, resetSendPipeline } from '@/lib/outgoing/send-pipeline'; import type { Identity } from '@/lib/jmap/types'; +import { emailHooks, pluginErrorTracker } from '@/lib/plugin-hooks'; +import type { OutgoingEmail } from '@/lib/plugin-types'; vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); @@ -14,7 +16,11 @@ const input = (signatureIdentity: Identity) => ({ body: 'Thanks <3', signatureIdentity, separator: true, to: ['bob@example.com'], fromEmail: 'me@example.com', identityId: 'i', }); -afterEach(() => resetSendPipeline()); +afterEach(() => { + resetSendPipeline(); + emailHooks.onBeforeEmailSend.clear(); + pluginErrorTracker.resetAll(); +}); describe('prepareQuickReply', () => { it('builds the text and, for an HTML signature, an escaped HTML body', async () => { @@ -107,3 +113,39 @@ describe('prepareQuickReply warnings', () => { expect(asked).toEqual([]); }); }); + +// Plugins' send checks (onBeforeEmailSend) ran for the composer and a +// plugin's forward, never for a quick reply: a way around a send guard. +describe('prepareQuickReply and plugin send checks', () => { + it('asks the plugins with the reply and stops when one says no', async () => { + const seen: OutgoingEmail[] = []; + emailHooks.onBeforeEmailSend.register('guard', (email: OutgoingEmail) => { seen.push(email); return false; }); + const stopped: string[] = []; + const out = await prepareQuickReply({ + ...input(text), body: 'IBAN DE89370400440532013000', subject: 'Re: Invoice', inReplyTo: '', + onSendCheckStopped: (outcome) => stopped.push(outcome), + }); + expect(out).toBeNull(); + expect(stopped).toEqual(['vetoed']); + expect(seen).toEqual([expect.objectContaining({ + surface: 'quick-reply', mode: 'reply', to: ['bob@example.com'], subject: 'Re: Invoice', + inReplyTo: '', identityId: 'i', fromEmail: 'me@example.com', + })]); + expect(seen[0].textBody).toContain('IBAN DE89370400440532013000'); + }); + + it('fails closed when a check crashes', async () => { + emailHooks.onBeforeEmailSend.register('guard', () => { throw new Error('boom'); }); + const stopped: string[] = []; + expect(await prepareQuickReply({ ...input(text), onSendCheckStopped: (o) => stopped.push(o) })).toBeNull(); + expect(stopped).toEqual(['failed']); + }); + + it('sends when the plugins allow it, with the disclaimer stage after them', async () => { + const order: string[] = []; + emailHooks.onBeforeEmailSend.register('guard', () => { order.push('plugin'); return undefined; }); + registerOutgoingFinalizer('disclaimer', 10, (bodies) => { order.push('last'); return bodies; }, 'last'); + expect(await prepareQuickReply(input(text))).not.toBeNull(); + expect(order).toEqual(['plugin', 'last']); + }); +}); diff --git a/lib/outgoing/plugin-send-check.ts b/lib/outgoing/plugin-send-check.ts new file mode 100644 index 000000000..919657290 --- /dev/null +++ b/lib/outgoing/plugin-send-check.ts @@ -0,0 +1,23 @@ +/** + * Plugins' say before a user's mail goes out (onBeforeEmailSend), the same + * for every surface that sends it: the composer (inline, pop-out, Pro tab), + * quick reply and a plugin's forward (api.email.forward). The bus fails + * closed: a handler that throws or runs out of time stops the send. + * + * Not run for mail Bulwark sends on its own (calendar invitations and RSVP + * replies, read receipts, List-Unsubscribe mails, phishing reports) nor for + * a crypto plugin's raw send, which happens after the composer's check. + */ + +import { emailHooks } from '@/lib/plugin-hooks'; +import type { OutgoingEmail } from '@/lib/plugin-types'; + +/** + * allowed: send. vetoed: a plugin said no and told the user. failed: a + * handler threw or ran out of time (nobody told the user yet). + */ +export type PluginSendCheckOutcome = 'allowed' | 'vetoed' | 'failed'; + +export async function runPluginSendCheck(email: OutgoingEmail): Promise { + return emailHooks.onBeforeEmailSend.interceptOutcome(email); +} diff --git a/lib/outgoing/quick-reply.ts b/lib/outgoing/quick-reply.ts index e4fc39d19..41bf5cf84 100644 --- a/lib/outgoing/quick-reply.ts +++ b/lib/outgoing/quick-reply.ts @@ -3,6 +3,7 @@ import { appendHtmlSignature, appendPlainTextSignature } from '@/lib/signature-u import { currentSignatureIdentity } from '@/lib/org/signature-state'; import { findUnfilledPlaceholders, type PlaceholderWords } from '@/lib/placeholder-check'; import { finalizeOutgoing, runPreSendChecks, type OutgoingBodies } from './send-pipeline'; +import { runPluginSendCheck, type PluginSendCheckOutcome } from './plugin-send-check'; // Registers the organisation signature check and the disclaimer. import '@/lib/org/outgoing'; @@ -29,7 +30,10 @@ export interface QuickReplyQuestions { * checks and finalisers as the composer's send. Null when one stopped the * send. The organisation signature applies as in the composer * (`signatureIdentity` is what the reply would sign with on its own, - * `sendingIdentity` the identity it goes out from). + * `sendingIdentity` the identity it goes out from). Plugins' send checks + * (onBeforeEmailSend) run on it too, after the organisation signature and + * before the disclaimer, as in the composer; `onSendCheckStopped` hears + * why one stopped it. */ export async function prepareQuickReply(input: { body: string; @@ -39,7 +43,11 @@ export async function prepareQuickReply(input: { to: string[]; fromEmail?: string; identityId?: string; + /** The reply's subject (without a classification prefix) and the original's Message-ID, for plugins. */ + subject?: string; + inReplyTo?: string; questions?: QuickReplyQuestions; + onSendCheckStopped?: (outcome: Exclude) => void; }): Promise { const { body, separator, questions } = input; @@ -88,6 +96,25 @@ export async function prepareQuickReply(input: { if (!(await runPreSendChecks(context))) return null; // Asked last, as in the composer, so an answered warning doesn't ask twice. if (questions?.confirmSend && !(await questions.ask({ kind: 'send' }))) return null; - // Quick reply has no plugin step in between: both stages at once. - return finalizeOutgoing(finalizeOutgoing({ html, text }, context), context, 'last'); + const bodies = finalizeOutgoing({ html, text }, context); + // A quick reply used to skip plugins' send checks: a way around them. + const outcome = await runPluginSendCheck({ + to: input.to, + cc: [], + bcc: [], + subject: input.subject ?? '', + htmlBody: bodies.html ?? '', + textBody: bodies.text, + identityId: input.identityId ?? '', + fromEmail: input.fromEmail, + attachments: [], + inReplyTo: input.inReplyTo, + surface: 'quick-reply', + mode: 'reply', + }); + if (outcome !== 'allowed') { + input.onSendCheckStopped?.(outcome); + return null; + } + return finalizeOutgoing(bodies, context, 'last'); } diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 7e404f567..ee62206d8 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -1091,6 +1091,13 @@ export interface OutgoingEmail { inReplyTo?: string; /** Free-form custom headers added by the composer or earlier handlers */ headers?: Record; + /** + * Where the send comes from (onBeforeEmailSend, Bulwark 1.20 and later): + * the composer, a quick reply under a message, or a plugin's forward. + */ + surface?: 'composer' | 'quick-reply' | 'plugin-forward'; + /** compose, reply, replyAll or forward (onBeforeEmailSend, 1.20 and later). */ + mode?: 'compose' | 'reply' | 'replyAll' | 'forward'; } /** * Passed to onBeforeDraftAutoSave handlers as a transform value. From edf619a19d96a8100307dea63b7f44c96eb37b61 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 08:16:39 +0100 Subject: [PATCH 02/17] feat: give send-check plugins the authored text, external recipients and attachment types --- .../composer-send-check-payload.test.tsx | 313 ++++++++++++++++++ components/email/email-composer.tsx | 40 ++- .../plugin-host-email-forward.test.ts | 24 ++ lib/outgoing/__tests__/quick-reply.test.ts | 8 + .../__tests__/send-check-payload.test.ts | 126 +++++++ lib/outgoing/attachment-sniff.ts | 92 +++++ lib/outgoing/quick-reply.ts | 5 + lib/outgoing/send-check-payload.ts | 99 ++++++ lib/plugin-sandbox/hook-payload.ts | 26 ++ lib/plugin-sandbox/host-api-forward.ts | 18 +- lib/plugin-sandbox/loader.ts | 5 +- lib/plugin-types.ts | 46 ++- 12 files changed, 792 insertions(+), 10 deletions(-) create mode 100644 components/email/__tests__/composer-send-check-payload.test.tsx create mode 100644 lib/outgoing/__tests__/send-check-payload.test.ts create mode 100644 lib/outgoing/attachment-sniff.ts create mode 100644 lib/outgoing/send-check-payload.ts create mode 100644 lib/plugin-sandbox/hook-payload.ts diff --git a/components/email/__tests__/composer-send-check-payload.test.tsx b/components/email/__tests__/composer-send-check-payload.test.tsx new file mode 100644 index 000000000..a8986b788 --- /dev/null +++ b/components/email/__tests__/composer-send-check-payload.test.tsx @@ -0,0 +1,313 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import React from 'react'; +import { EmailComposer } from '../email-composer'; +import { usePolicyStore } from '@/stores/policy-store'; +import { resetSendPipeline } from '@/lib/outgoing/send-pipeline'; +import { DEFAULT_POLICY, type SettingsPolicy } from '@/lib/admin/types'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import type { OutgoingEmail } from '@/lib/plugin-types'; + +// ─── Heavy component mocks (mirrors composer-reply-signature.test.tsx) ─ + +// A stand-in for the TipTap editor: it hands the composer an editor object +// whose content is whatever the composer last rendered into it, the way the +// real editor reports itself ready from an effect after mounting. +const editorState = vi.hoisted(() => ({ html: '' })); +// A plugin that rewrites signatures (onSignatureRender), off unless a test turns it on. +const signaturePlugin = vi.hoisted(() => ({ on: false })); +// Send plugins: a footer added by onTransformOutgoingEmail, and a crypto +// plugin taking the send over (onComposeSend). +// What a send check plugin (onBeforeEmailSend) was handed. +const sendChecks = vi.hoisted(() => ({ seen: [] as unknown[] })); +const sendPlugins = vi.hoisted(() => ({ + footer: false, + takeOver: null as null | ((request: { htmlBody: string; textBody: string }) => void), +})); +vi.mock('@/components/email/rich-text-editor', () => ({ + RichTextEditor: ({ content, onChange, onEditorReady }: { + content: string; + onChange: (html: string) => void; + onEditorReady?: (editor: unknown) => void; + }) => { + editorState.html = content; + const onChangeRef = React.useRef(onChange); + onChangeRef.current = onChange; + React.useEffect(() => { + onEditorReady?.({ + isDestroyed: false, + commands: { + focus: () => {}, + setContent: (html: string) => { + editorState.html = html; + onChangeRef.current(html); + }, + }, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return React.createElement('div', { 'data-testid': 'rich-text-editor', 'data-html': content }); + }, +})); + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null })); +vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null })); +vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null })); +vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null })); +vi.mock('@/hooks/use-focus-trap', () => ({ + useFocusTrap: () => ({ current: null }), +})); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ + useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }), + stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }), +})); + +// ─── Store mocks ────────────────────────────────────────────────────────────── + +vi.mock('@/stores/auth-store', () => { + const state = { + client: null, + identities: [], + primaryIdentity: null, + isAuthenticated: false, + isDemoMode: false, + activeAccountId: null, + connectionLost: false, + getClientForAccount: () => undefined, + getAllConnectedClients: () => new Map(), + syncIdentities: () => {}, + refreshIdentities: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAuthStore: hook }; +}); + +vi.mock('@/stores/identity-store', () => { + const state = { + identities: [ + { id: 'id-me', email: 'me@example.com', name: 'Me', htmlSignature: 'Alice', textSignature: 'Alice' }, + { id: 'id-team', email: 'team@example.com', name: 'Team', htmlSignature: '', textSignature: '' }, + ] as Array>, + defaultIdentityId: 'id-me', + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useIdentityStore: hook, notifyIdentitySelected: () => {} }; +}); + +vi.mock('@/stores/account-store', () => { + const state = { accounts: [], getAccountById: () => undefined }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAccountStore: hook }; +}); + +vi.mock('@/stores/email-store', () => { + const state = { + draftSaveEnabled: false, + sendRawEmail: async () => ({ sent: true }), + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useEmailStore: hook }; +}); + +vi.mock('@/stores/settings-store', () => { + const state = { + timeFormat: '24h', + plainTextMode: false, + subAddressDelimiter: '+', + autoSelectReplyIdentity: true, + attachmentReminderEnabled: false, + attachmentReminderKeywords: [], + emptySubjectWarningEnabled: false, + signatureDetails: { title: '', department: '', phone: '', mobile: '' }, + sendDelaySeconds: 0, + // The default position: replies/forwards get the signature appended at + // send time rather than embedded above the quote. + signaturePosition: 'below_quote', + signatureSeparatorEnabled: true, + requestReadReceiptDefault: false, + addTrustedSender: () => {}, + externalContentPolicy: 'block', + isSenderTrusted: () => false, + trustedSendersAddressBook: null, + updateSetting: () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useSettingsStore: hook }; +}); + +vi.mock('@/stores/contact-store', () => { + const state = { + contacts: [], + getAutocomplete: async () => [], + addToTrustedSendersBook: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useContactStore: hook }; +}); + +vi.mock('@/stores/template-store', () => { + const state = { templates: [], addTemplate: async () => {} }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useTemplateStore: hook }; +}); + +// ─── Misc dependency mocks ──────────────────────────────────────────────────── + +vi.mock('@/stores/toast-store', () => ({ + toast: { info: () => {}, error: () => {}, success: () => {} }, +})); + +vi.mock('@/lib/plugin-hooks', () => ({ + identityHooks: { + onSignatureRender: { + get size() { return signaturePlugin.on ? 1 : 0; }, + transform: async (value: { html: string; text: string }) => ({ html: `${value.html}

PLUGIN

`, text: `${value.text} PLUGIN` }), + }, + onIdentitySelect: { size: 0, emit: async () => {} }, + }, + templateHooks: { + onTemplateRender: { size: 0, transform: async (value: unknown) => value }, + onTemplateApply: { size: 0, emit: async () => {} }, + }, + emailHooks: { + onComposerOpen: { call: async () => [] }, + onRecipientChange: { call: async () => [] }, + getRecipientSuggestions: { call: async () => [] }, + onRecipientChipsChange: { transform: async (chips: unknown) => chips }, + onDraftChange: { emit: () => {} }, + onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, + onBeforeEmailSend: { + size: 1, + intercept: async () => true, + interceptOutcome: async (email: unknown) => { sendChecks.seen.push(email); return 'allowed'; }, + }, + onComposeSend: { + takeOver: async (request: { htmlBody: string; textBody: string }) => { + if (!sendPlugins.takeOver) return 'default'; + sendPlugins.takeOver(request); + return 'handled'; + }, + }, + onTransformOutgoingEmail: { + transform: async (email: { htmlBody: string; textBody: string }) => (sendPlugins.footer + ? { ...email, htmlBody: `${email.htmlBody}

Plugin footer

`, textBody: `${email.textBody}\n\nPlugin footer` } + : email), + }, + }, + contactHooks: { + search: { call: async () => [] }, + onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial }, + }, +})); + +vi.mock('@/lib/email-sanitization', () => ({ + sanitizeSignatureHtml: (v: string) => v, + sanitizeSignatureHtmlForDisplay: (v: string) => v, + sanitizeEmailHtml: (v: string) => v, + sanitizePluginBodyHtml: (v: string) => v, + escapeHtml: (v: string) => v, + parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'), +})); + +vi.mock('@/lib/email-threading', () => ({ + computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }), +})); +vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' })); +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); +vi.mock('@/components/email/quoted-html', () => ({ + buildQuotedHtmlBlock: (html: string) => `
${html}
`, + serializeEditorContent: () => editorState.html, +})); +vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s })); + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +/** + * A send check plugin gets what it needs to judge a message without the + * quoted original and without the policy: the user's own text, the + * recipients outside the organisation, where the send comes from. + */ + +const DRAFT = { + to: 'bob@example.com, Carol ', + cc: '', + bcc: 'mallory@evil.test', + subject: 'Re: Invoice', + body: '

Pay to DE89 3704 0044 0532 0130 00 please.

Quoted: DE44 5001 0517 5407 3249 31
', + showCc: false, + showBcc: true, + selectedIdentityId: 'id-me', + subAddressTag: '', + mode: 'compose' as const, + draftId: 'draft-1', +}; + +function setPolicy(domains: string[]) { + const policy: SettingsPolicy = { + ...DEFAULT_POLICY, + organisation: { + ...DEFAULT_ORGANISATION_POLICY, + externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, recipientWarning: false, domains }, + }, + }; + usePolicyStore.setState({ policy, rawPolicy: policy, subject: null }); +} + +describe('composer: what a send check plugin gets', () => { + beforeEach(() => { + resetSendPipeline(); + sendChecks.seen = []; + setPolicy([]); + }); + afterEach(() => { + resetSendPipeline(); + vi.clearAllMocks(); + }); + + it('the authored text without the quote, the external recipients and the surface', async () => { + const onSend = vi.fn(); + render(); + fireEvent.click(screen.getAllByTestId('composer-send')[0]); + await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + expect(sendChecks.seen).toHaveLength(1); + const email = sendChecks.seen[0] as OutgoingEmail; + expect(email.surface).toBe('composer'); + expect(email.mode).toBe('reply'); + expect(email.authoredText).toContain('DE89 3704 0044 0532 0130 00'); + expect(email.authoredText).not.toContain('DE44'); + expect(email.htmlBody).toContain('DE44'); + // Internal: the identity's own domain. The admin's warning is off. + expect(email.externalRecipients).toEqual(['carol@partner.test', 'mallory@evil.test']); + }); + + it("counts the admin's organisation domains as internal", async () => { + setPolicy(['partner.test']); + const onSend = vi.fn(); + render(); + fireEvent.click(screen.getAllByTestId('composer-send')[0]); + await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + expect((sendChecks.seen[0] as OutgoingEmail).externalRecipients).toEqual(['mallory@evil.test']); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 9864a3cce..6febe2b36 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -113,6 +113,8 @@ import { wantsSignatureSeparator } from "@/lib/signature-delimiter"; import { finalizeOutgoing, runPreSendChecks, type OutgoingContext } from "@/lib/outgoing/send-pipeline"; import { classifyOutgoing, isLabelRequiredError, labelMissing, labelsPolicyFor, resolveLabel, type OriginalMessage } from "@/lib/outgoing/classification"; import { beginLabelTakeover, endLabelTakeover } from "@/lib/outgoing/classification-raw"; +import { runPluginSendCheck } from "@/lib/outgoing/plugin-send-check"; +import { externalRecipientsFor, inspectAttachments, sniffFile } from "@/lib/outgoing/send-check-payload"; import { whenPolicySettled } from "@/stores/policy-store"; import { ClassificationLabelPicker, type ClassificationPickerHandle } from "./classification-label-picker"; import { useComposerClassification } from "./use-composer-classification"; @@ -2220,10 +2222,12 @@ export function EmailComposer({ // Let plugins veto each upload before it's queued. const allowedFiles: File[] = []; for (const file of files) { + const sniffedType = emailHooks.onBeforeAttachmentUpload.size > 0 ? await sniffFile(file) : undefined; const ok = await emailHooks.onBeforeAttachmentUpload.intercept({ name: file.name, type: file.type || 'application/octet-stream', size: file.size, + ...(sniffedType ? { sniffedType } : {}), }); if (ok) allowedFiles.push(file); } @@ -3608,8 +3612,16 @@ export function EmailComposer({ try { const effectiveDelayedUntil = await resolveDelayedUntil(delayedUntil); // Let plugins veto the send (external-mail warning, mistyped-domain - // guards, etc.). Returning false from any handler aborts before either - // the S/MIME or standard JMAP path runs. + // guards, send checks). Returning false from any handler aborts before + // either the S/MIME or standard JMAP path runs. What a send check needs + // beyond the bodies (lib/outgoing/send-check-payload.ts) is worked out + // only while a plugin listens. + const checking = emailHooks.onBeforeEmailSend.size > 0; + const sendClient = composerClientRef.current ?? client; + const readBlob = (blobId: string, name: string, type: string, bytes: number) => (sendClient + ? sendClient.fetchBlobArrayBuffer(blobId, name, type, undefined, bytes) + : Promise.reject(new Error('No client'))); + const sentAttachments = attachmentsRef.current.filter(att => att.blobId && !att.uploading && !att.error); const sendablePreview: OutgoingEmail = { to: toAddresses.map(r => formatRecipient(r.name, r.email)), cc: ccAddresses.map(r => formatRecipient(r.name, r.email)), @@ -3619,12 +3631,28 @@ export function EmailComposer({ textBody: finalBody, identityId: currentIdentity?.id || '', fromEmail, - attachments: attachmentsRef.current - .filter(att => att.blobId && !att.uploading && !att.error) - .map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })), + attachments: checking + ? await inspectAttachments([ + ...sentAttachments.map(a => ({ + name: a.name, type: a.type, size: a.size, blobId: a.blobId, file: a.file, + load: (bytes: number) => readBlob(a.blobId!, a.name, a.type, bytes), + })), + ...inlineAttachments.map(a => ({ + name: a.name, type: a.type, size: a.size, blobId: a.blobId, inline: true, + load: (bytes: number) => readBlob(a.blobId, a.name, a.type, bytes), + })), + ]) + : sentAttachments.map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size, blobId: a.blobId })), inReplyTo: threadingHeaders?.inReplyTo?.[0], + surface: 'composer', + mode, + authoredText: extractUserAuthoredText( + plainTextMode ? plainTextBodyWithoutSignature(body, signatureIdentity) : stripEmbeddedSignatureHtml(body), + { plainTextMode, forwardedSeparator: tQuote('forwarded_separator') }, + ), + externalRecipients: checking ? await externalRecipientsFor(outgoingContext) : [], }; - const sendCheck = await emailHooks.onBeforeEmailSend.interceptOutcome(sendablePreview); + const sendCheck = await runPluginSendCheck(sendablePreview); if (sendCheck !== 'allowed') { // A plugin vetoed the send (it is expected to show its own UI). // Leave a trace so a silent no-op send is diagnosable (#592). diff --git a/lib/__tests__/plugin-host-email-forward.test.ts b/lib/__tests__/plugin-host-email-forward.test.ts index 050a26bd9..463ba96c2 100644 --- a/lib/__tests__/plugin-host-email-forward.test.ts +++ b/lib/__tests__/plugin-host-email-forward.test.ts @@ -19,6 +19,7 @@ vi.mock('@/stores/toast-store', () => ({ })); import { dispatchApiCall } from '../plugin-sandbox/host-api'; +import type { OutgoingEmail } from '../plugin-types'; function plugin(perms: string[], id = 'e-invoice'): InstalledPlugin { return { @@ -186,6 +187,29 @@ describe('api.email.forward', () => { } }); + it("gives send checks the note, the recipients outside and what the attachments really are", async () => { + const seen: OutgoingEmail[] = []; + client.fetchBlobArrayBuffer = vi.fn(async (blobId: string) => new TextEncoder().encode(blobId === 'pdf-1' ? 'MZ' : '').buffer); + const off = emailHooks.onBeforeEmailSend.register('send-checks', (outgoing: OutgoingEmail) => { seen.push(outgoing); return false; }); + try { + const pending = call(FORWARDER, 'email.forward', 'E1', { to: ['books@outside.test', 'ann2@example.com'], text: 'Please book this.' }); + await answerDialog(true); + expect(await pending).toEqual({ sent: false, reason: 'stopped' }); + expect(seen[0]).toMatchObject({ + surface: 'plugin-forward', + mode: 'forward', + authoredText: 'Please book this.', + externalRecipients: ['books@outside.test'], + }); + expect(seen[0].attachments).toEqual([ + { name: 'invoice.pdf', type: 'application/pdf', size: 5000, blobId: 'pdf-1', sniffedType: 'application/x-msdownload' }, + { name: 'xrechnung.xml', type: 'application/xml', size: 3000, blobId: 'xml-1', sniffedType: 'text/plain', textExcerpt: '' }, + ]); + } 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"/); diff --git a/lib/outgoing/__tests__/quick-reply.test.ts b/lib/outgoing/__tests__/quick-reply.test.ts index 723e96556..3ff01cb8d 100644 --- a/lib/outgoing/__tests__/quick-reply.test.ts +++ b/lib/outgoing/__tests__/quick-reply.test.ts @@ -134,6 +134,14 @@ describe('prepareQuickReply and plugin send checks', () => { expect(seen[0].textBody).toContain('IBAN DE89370400440532013000'); }); + it('hands over the typed text and the recipients outside the organisation', async () => { + const seen: OutgoingEmail[] = []; + emailHooks.onBeforeEmailSend.register('guard', (email: OutgoingEmail) => { seen.push(email); return undefined; }); + expect(await prepareQuickReply({ ...input(text), body: 'Card 4242 4242 4242 4242', to: ['bob@example.com', 'Eve@Other.test'] })).not.toBeNull(); + expect(seen[0].authoredText).toBe('Card 4242 4242 4242 4242'); + expect(seen[0].externalRecipients).toEqual(['eve@other.test']); + }); + it('fails closed when a check crashes', async () => { emailHooks.onBeforeEmailSend.register('guard', () => { throw new Error('boom'); }); const stopped: string[] = []; diff --git a/lib/outgoing/__tests__/send-check-payload.test.ts b/lib/outgoing/__tests__/send-check-payload.test.ts new file mode 100644 index 000000000..3b1dce85a --- /dev/null +++ b/lib/outgoing/__tests__/send-check-payload.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { externalRecipientsFor, inspectAttachments, sniffFile, TEXT_EXCERPT_BYTES } from '../send-check-payload'; +import { isTextAttachment, sniffType, SNIFF_BYTES } from '../attachment-sniff'; +import type { OutgoingContext } from '../send-pipeline'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { usePolicyStore } from '@/stores/policy-store'; +import { useAuthStore } from '@/stores/auth-store'; +import type { Identity } from '@/lib/jmap/types'; +import { hookArgsFor } from '@/lib/plugin-sandbox/hook-payload'; +import type { InstalledPlugin, OutgoingEmail } from '@/lib/plugin-types'; + +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); + +const bytes = (...parts: (number[] | string)[]) => new Uint8Array(parts.flatMap((p) => (typeof p === 'string' ? Array.from(new TextEncoder().encode(p)) : p))); + +describe('sniffType', () => { + it('names programs and archives by their first bytes, whatever the name says', () => { + expect(sniffType(bytes('MZ', [0x90, 0, 3]))).toBe('application/x-msdownload'); + expect(sniffType(bytes([0x7f], 'ELF', [2, 1]))).toBe('application/x-executable'); + expect(sniffType(bytes('PK', [3, 4, 20, 0]))).toBe('application/zip'); + expect(sniffType(bytes([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1, 0]))).toBe('application/x-ole-storage'); + expect(sniffType(bytes('%PDF-1.7\n'))).toBe('application/pdf'); + expect(sniffType(bytes([0x89], 'PNG', [0x0d, 0x0a, 0x1a, 0x0a]))).toBe('image/png'); + expect(sniffType(bytes('RIFF', [0, 0, 0, 0], 'WEBPVP8 '))).toBe('image/webp'); + expect(sniffType(bytes('#!/bin/sh\nrm -rf /\n'))).toBe('text/x-shellscript'); + }); + + it('tells HTML and plain text from binary data', () => { + expect(sniffType(bytes('\uFEFF '))).toBe('text/html'); + expect(sniffType(bytes('IBAN;Name\nDE89370400440532013000;Alice\n'))).toBe('text/plain'); + // A character cut off at the end of the head is still text. + expect(sniffType(bytes('Grüße', [0xc3]))).toBe('text/plain'); + expect(sniffType(bytes('abc', [0], 'def'))).toBeUndefined(); + expect(sniffType(bytes([0xff, 0xfe, 0x41]))).toBeUndefined(); + expect(sniffType(new Uint8Array())).toBeUndefined(); + }); + + it('knows text attachments by type, else by extension', () => { + expect(isTextAttachment('a.bin', 'text/csv')).toBe(true); + expect(isTextAttachment('export.csv', 'application/octet-stream')).toBe(true); + expect(isTextAttachment('data.json', '')).toBe(true); + expect(isTextAttachment('photo.jpg', 'image/jpeg')).toBe(false); + }); +}); + +describe('inspectAttachments', () => { + it('reads a local text file: its type and the text', async () => { + const file = new Blob(['IBAN DE89 3704 0044 0532 0130 00']); + const [out] = await inspectAttachments([{ name: 'list.csv', type: 'text/csv', size: file.size, blobId: 'b1', file }]); + expect(out).toEqual({ + name: 'list.csv', type: 'text/csv', size: file.size, blobId: 'b1', + sniffedType: 'text/plain', textExcerpt: 'IBAN DE89 3704 0044 0532 0130 00', + }); + }); + + it('reads a server blob: a megabyte of a text file, only the head of anything else', async () => { + const asked: number[] = []; + const load = (content: string) => async (n: number) => { asked.push(n); return new TextEncoder().encode(content).buffer as ArrayBuffer; }; + const out = await inspectAttachments([ + { name: 'notes.txt', type: 'text/plain', size: 10, blobId: 'b1', load: load('hello') }, + { name: 'invoice.pdf', type: 'application/pdf', size: 9_000_000, blobId: 'b2', load: load('MZ\u0090') }, + ]); + expect(asked).toEqual([TEXT_EXCERPT_BYTES, SNIFF_BYTES]); + expect(out[0]).toMatchObject({ sniffedType: 'text/plain', textExcerpt: 'hello' }); + // A renamed program: the name and type say PDF, the bytes don't. + expect(out[1]).toEqual({ name: 'invoice.pdf', type: 'application/pdf', size: 9_000_000, blobId: 'b2', sniffedType: 'application/x-msdownload' }); + }); + + it("doesn't read a program named .txt as text, nor inline images", async () => { + const out = await inspectAttachments([ + { name: 'readme.txt', type: 'text/plain', size: 3, file: new Blob([bytes('MZ', [0x90])]) }, + { name: 'pasted.png', type: 'image/png', size: 8, blobId: 'b3', inline: true, file: new Blob([bytes([0x89], 'PNG', [0x0d, 0x0a, 0x1a, 0x0a])]) }, + ]); + expect(out[0]).toEqual({ name: 'readme.txt', type: 'text/plain', size: 3, sniffedType: 'application/x-msdownload' }); + expect(out[1]).toEqual({ name: 'pasted.png', type: 'image/png', size: 8, blobId: 'b3', inline: true, sniffedType: 'image/png' }); + }); + + it('passes a file it cannot read on without the two fields', async () => { + const [out] = await inspectAttachments([{ name: 'a.txt', type: '', size: 1, blobId: 'b', load: async () => { throw new Error('offline'); } }]); + expect(out).toEqual({ name: 'a.txt', type: 'application/octet-stream', size: 1, blobId: 'b' }); + }); + + it('sniffs a file before upload', async () => { + expect(await sniffFile(new Blob([bytes('MZ', [0x90])]))).toBe('application/x-msdownload'); + }); +}); + +describe('externalRecipientsFor', () => { + const context = (to: string[], bcc: string[] = []): OutgoingContext => ({ + surface: 'composer', to, cc: [], bcc, fromEmail: 'alice@example.org', identityId: 'i1', mode: 'compose', plainText: false, + sender: { name: 'Alice', email: 'alice@example.org' }, + }); + beforeEach(() => { + useAuthStore.setState({ client: null, identities: [{ id: 'i1', name: 'Alice', email: 'alice@example.org' } as unknown as Identity] }); + }); + + it("lists recipients outside the organisation's domains also while the admin's warning is off", async () => { + usePolicyStore.setState({ + policy: { + ...DEFAULT_POLICY, + organisation: { + ...DEFAULT_ORGANISATION_POLICY, + externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, recipientWarning: false, domains: ['partner.example'] }, + }, + }, + }); + expect(await externalRecipientsFor(context(['bob@example.org', 'Carol@Partner.Example', 'eve@other.test'], ['x@sub.example.org', 'Mallory@Evil.test']))) + .toEqual(['eve@other.test', 'mallory@evil.test']); + expect(await externalRecipientsFor(context(['bob@example.org']))).toEqual([]); + }); +}); + +describe('hookArgsFor', () => { + const email = { + to: [], cc: [], bcc: [], subject: '', htmlBody: '', textBody: '', identityId: '', + attachments: [{ name: 'a.txt', type: 'text/plain', size: 1, textExcerpt: 'secret' }], + } as OutgoingEmail; + const plugin = (permissions: string[]) => ({ id: 'p', permissions, managed: true } as unknown as InstalledPlugin); + + it('gives the text of attachments only to a plugin that may read files', () => { + expect((hookArgsFor(plugin(['email:send']), 'onBeforeEmailSend', [email])[0] as OutgoingEmail).attachments[0]).toEqual({ name: 'a.txt', type: 'text/plain', size: 1 }); + expect((hookArgsFor(plugin(['email:send', 'email:blob-read']), 'onBeforeEmailSend', [email])[0] as OutgoingEmail).attachments[0].textExcerpt).toBe('secret'); + expect(email.attachments[0].textExcerpt).toBe('secret'); + }); +}); diff --git a/lib/outgoing/attachment-sniff.ts b/lib/outgoing/attachment-sniff.ts new file mode 100644 index 000000000..1671a1c27 --- /dev/null +++ b/lib/outgoing/attachment-sniff.ts @@ -0,0 +1,92 @@ +/** + * What a file really is, read from its first bytes: a renamed program + * (invoice.pdf that starts with "MZ") is still a program. Used for the + * attachment information plugins get before an upload and before a send + * (AttachmentInfo.sniffedType, OutgoingEmail.attachments[].sniffedType). + * + * Only well-known signatures; anything else is undefined, never a guess + * from the name. + */ + +/** Bytes a sniff needs; read at most this much for a binary file. */ +export const SNIFF_BYTES = 4096; + +const startsWith = (bytes: Uint8Array, sig: readonly number[], offset = 0): boolean => + bytes.length >= offset + sig.length && sig.every((b, i) => bytes[offset + i] === b); + +const ascii = (text: string): number[] => Array.from(text, (c) => c.charCodeAt(0)); + +const SIGNATURES: readonly { sig: readonly number[]; offset?: number; type: string }[] = [ + { sig: ascii('MZ'), type: 'application/x-msdownload' }, + { sig: [0x7f, 0x45, 0x4c, 0x46], type: 'application/x-executable' }, + { sig: [0xcf, 0xfa, 0xed, 0xfe], type: 'application/x-mach-binary' }, + { sig: [0xce, 0xfa, 0xed, 0xfe], type: 'application/x-mach-binary' }, + { sig: [0xfe, 0xed, 0xfa, 0xcf], type: 'application/x-mach-binary' }, + { sig: [0xfe, 0xed, 0xfa, 0xce], type: 'application/x-mach-binary' }, + { sig: [0xca, 0xfe, 0xba, 0xbe], type: 'application/x-mach-binary' }, + { sig: [0x4c, 0x00, 0x00, 0x00, 0x01, 0x14, 0x02, 0x00], type: 'application/x-ms-shortcut' }, + { sig: ascii('MSCF'), type: 'application/vnd.ms-cab-compressed' }, + { sig: [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1], type: 'application/x-ole-storage' }, + { sig: [0x50, 0x4b, 0x03, 0x04], type: 'application/zip' }, + { sig: [0x50, 0x4b, 0x05, 0x06], type: 'application/zip' }, + { sig: [0x50, 0x4b, 0x07, 0x08], type: 'application/zip' }, + { sig: ascii('Rar!\x1a\x07'), type: 'application/x-rar-compressed' }, + { sig: [0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c], type: 'application/x-7z-compressed' }, + { sig: [0x1f, 0x8b], type: 'application/gzip' }, + { sig: ascii('%PDF-'), type: 'application/pdf' }, + { sig: ascii('{\\rtf'), type: 'application/rtf' }, + { sig: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], type: 'image/png' }, + { sig: [0xff, 0xd8, 0xff], type: 'image/jpeg' }, + { sig: ascii('GIF87a'), type: 'image/gif' }, + { sig: ascii('GIF89a'), type: 'image/gif' }, + { sig: ascii('#!'), type: 'text/x-shellscript' }, +]; + +/** The MIME type the bytes start with, or undefined when none is known. */ +export function sniffType(bytes: Uint8Array): string | undefined { + for (const { sig, offset, type } of SIGNATURES) { + if (startsWith(bytes, sig, offset)) return type; + } + if (startsWith(bytes, ascii('RIFF')) && startsWith(bytes, ascii('WEBP'), 8)) return 'image/webp'; + return sniffText(bytes); +} + +/** text/html or text/plain for text without NUL bytes that decodes as UTF-8. */ +function sniffText(bytes: Uint8Array): string | undefined { + if (bytes.length === 0) return undefined; + const head = bytes.subarray(0, Math.min(bytes.length, SNIFF_BYTES)); + if (head.includes(0)) return undefined; + let text: string; + try { + // A multi-byte character cut at the end of the head is fine. + text = new TextDecoder('utf-8', { fatal: true }).decode(head.subarray(0, trimPartialUtf8(head))); + } catch { + return undefined; + } + const start = text.replace(/^\uFEFF/, '').trimStart().slice(0, 64).toLowerCase(); + if (start.startsWith('= 0; back++) { + const b = bytes[end - back]; + if ((b & 0xc0) === 0x80) continue; // continuation byte + const need = b >= 0xf0 ? 4 : b >= 0xe0 ? 3 : b >= 0xc0 ? 2 : 1; + return need > back ? end - back : end; + } + return end; +} + +const TEXT_EXTENSIONS = new Set(['txt', 'csv', 'tsv', 'json', 'xml', 'ics', 'vcf', 'md', 'log', 'htm', 'html', 'yaml', 'yml', 'ini', 'conf']); +const TEXT_TYPES = new Set(['application/json', 'application/xml', 'application/csv', 'application/x-yaml', 'application/yaml']); + +/** Whether a file is text a send check may read: by type, else by extension. */ +export function isTextAttachment(name: string, type: string): boolean { + const t = (type || '').toLowerCase().split(';')[0].trim(); + if (t.startsWith('text/') || TEXT_TYPES.has(t)) return true; + const ext = name.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1]; + return !!ext && TEXT_EXTENSIONS.has(ext); +} diff --git a/lib/outgoing/quick-reply.ts b/lib/outgoing/quick-reply.ts index 41bf5cf84..d0a6ed207 100644 --- a/lib/outgoing/quick-reply.ts +++ b/lib/outgoing/quick-reply.ts @@ -4,6 +4,8 @@ import { currentSignatureIdentity } from '@/lib/org/signature-state'; import { findUnfilledPlaceholders, type PlaceholderWords } from '@/lib/placeholder-check'; import { finalizeOutgoing, runPreSendChecks, type OutgoingBodies } from './send-pipeline'; import { runPluginSendCheck, type PluginSendCheckOutcome } from './plugin-send-check'; +import { externalRecipientsFor } from './send-check-payload'; +import { emailHooks } from '@/lib/plugin-hooks'; // Registers the organisation signature check and the disclaimer. import '@/lib/org/outgoing'; @@ -111,6 +113,9 @@ export async function prepareQuickReply(input: { inReplyTo: input.inReplyTo, surface: 'quick-reply', mode: 'reply', + // The quote isn't part of a quick reply: the typed text is all the user wrote. + authoredText: body, + externalRecipients: emailHooks.onBeforeEmailSend.size > 0 ? await externalRecipientsFor(context) : [], }); if (outcome !== 'allowed') { input.onSendCheckStopped?.(outcome); diff --git a/lib/outgoing/send-check-payload.ts b/lib/outgoing/send-check-payload.ts new file mode 100644 index 000000000..c4799a7ce --- /dev/null +++ b/lib/outgoing/send-check-payload.ts @@ -0,0 +1,99 @@ +/** + * What plugins' send checks (onBeforeEmailSend) get beyond the message + * itself, so a rule such as "warn about an IBAN, only when the mail leaves + * the organisation" works without exposing the policy: + * + * - `externalRecipients`: the recipients outside the organisation of the + * account the mail goes out from (its identity domains plus the admin's + * organisation domains), filled whether or not the admin's recipient + * warning is on; + * - per attachment the blobId, whether it is inline, the type its first + * bytes say (sniffedType) and, for text files, the first megabyte as text + * (textExcerpt; only plugins with `email:blob-read` see it, see + * lib/plugin-sandbox/hook-payload.ts). + * + * Attachments are read only while a plugin listens, and a file that can't + * be read is passed on without the two fields. + */ + +import type { OutgoingAttachment } from '@/lib/plugin-types'; +import { addressDomain, addressKey, contextInternalDomains, externalMailContextFor, externalRecipients } from '@/lib/external-mail'; +import { debug } from '@/lib/debug'; +import { isTextAttachment, sniffType, SNIFF_BYTES } from './attachment-sniff'; +import type { OutgoingContext } from './send-pipeline'; + +/** Most of a text attachment a plugin gets as text. */ +export const TEXT_EXCERPT_BYTES = 1024 * 1024; + +/** The recipients outside the sending account's organisation, as bare lowercase addresses. */ +export async function externalRecipientsFor(context: OutgoingContext): Promise { + const account = await externalMailContextFor(context.sender?.accountId); + const internal = contextInternalDomains(account); + // As the recipient warning: the identity's own domain is internal, a From + // typed over it in the composer is not. + const identityEmail = context.sender?.email ?? (context.surface === 'composer' ? undefined : context.fromEmail); + const identityDomain = addressDomain(identityEmail); + if (identityDomain && !internal.includes(identityDomain)) internal.push(identityDomain); + return externalRecipients(context, internal).map((r) => addressKey(r.address)); +} + +export interface SendCheckAttachmentSource { + name: string; + type: string; + size: number; + blobId?: string; + inline?: boolean; + /** The bytes, when the browser still has them (a file the user picked). */ + file?: Blob; + /** Reads up to `bytes` bytes of the blob from the server otherwise. */ + load?: (bytes: number) => Promise; +} + +async function headOf(source: SendCheckAttachmentSource, bytes: number): Promise { + try { + if (source.file) return new Uint8Array(await source.file.slice(0, bytes).arrayBuffer()); + if (source.load) return new Uint8Array((await source.load(bytes)).slice(0, bytes)); + } catch (error) { + debug.warn('email', `Could not read ${source.name} for the send checks:`, error); + } + return null; +} + +function decodeExcerpt(bytes: Uint8Array): string { + return new TextDecoder('utf-8').decode(bytes).replace(/\uFFFD+$/, ''); +} + +/** The attachment list for onBeforeEmailSend, with sniffedType and text excerpts. */ +export async function inspectAttachments(sources: readonly SendCheckAttachmentSource[]): Promise { + const out: OutgoingAttachment[] = []; + // One at a time: a send rarely has many, and downloads share the + // server's request slots with everything else. + for (const source of sources) { + const entry: OutgoingAttachment = { + name: source.name, + type: source.type || 'application/octet-stream', + size: source.size, + ...(source.blobId ? { blobId: source.blobId } : {}), + ...(source.inline ? { inline: true } : {}), + }; + const text = !source.inline && isTextAttachment(source.name, source.type); + const head = await headOf(source, text ? TEXT_EXCERPT_BYTES : SNIFF_BYTES); + if (head) { + const sniffed = sniffType(head); + if (sniffed) entry.sniffedType = sniffed; + // A "text" file whose bytes say it is a program is not read as text. + if (text && (!sniffed || sniffed.startsWith('text/'))) entry.textExcerpt = decodeExcerpt(head); + } + out.push(entry); + } + return out; +} + +/** sniffedType of a file about to be attached (onBeforeAttachmentUpload). */ +export async function sniffFile(file: Blob): Promise { + try { + return sniffType(new Uint8Array(await file.slice(0, SNIFF_BYTES).arrayBuffer())); + } catch { + return undefined; + } +} diff --git a/lib/plugin-sandbox/hook-payload.ts b/lib/plugin-sandbox/hook-payload.ts new file mode 100644 index 000000000..9847a8c6a --- /dev/null +++ b/lib/plugin-sandbox/hook-payload.ts @@ -0,0 +1,26 @@ +// What one plugin gets of a hook's arguments. The bus hands every handler +// the same value; a part that needs a permission beyond the hook's own is +// cut here, per plugin, before it crosses into the sandbox. + +import type { InstalledPlugin, OutgoingEmail } from '../plugin-types'; +import { pluginHasPermission } from './permissions'; + +/** onBeforeEmailSend without the text of attachments, for a plugin that may not read files. */ +function withoutTextExcerpts(email: OutgoingEmail): OutgoingEmail { + if (!Array.isArray(email?.attachments) || !email.attachments.some((a) => a && 'textExcerpt' in a)) return email; + return { + ...email, + attachments: email.attachments.map((attachment) => { + const { textExcerpt: _omit, ...rest } = attachment; + void _omit; + return rest; + }), + }; +} + +export function hookArgsFor(plugin: InstalledPlugin, hookName: string, args: unknown[]): unknown[] { + if (hookName === 'onBeforeEmailSend' && !pluginHasPermission(plugin, 'email:blob-read') && args[0]) { + return [withoutTextExcerpts(args[0] as OutgoingEmail), ...args.slice(1)]; + } + return args; +} diff --git a/lib/plugin-sandbox/host-api-forward.ts b/lib/plugin-sandbox/host-api-forward.ts index 98ae6c4ef..aeeec84d3 100644 --- a/lib/plugin-sandbox/host-api-forward.ts +++ b/lib/plugin-sandbox/host-api-forward.ts @@ -26,6 +26,8 @@ import { emailHooks } from '../plugin-hooks'; import { announceSent } from '../plugin-after-send'; import { buildForwardSubject } from '../subject-prefix'; import { finalizeOutgoing, runPreSendChecks } from '../outgoing/send-pipeline'; +import { runPluginSendCheck } from '../outgoing/plugin-send-check'; +import { externalRecipientsFor, inspectAttachments } from '../outgoing/send-check-payload'; import { automaticLabel, classifyOutgoing } from '../outgoing/classification'; import { whenPolicySettled } from '@/stores/policy-store'; import { toast } from '@/stores/toast-store'; @@ -193,9 +195,21 @@ export async function doEmailForward( 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 })), + // The original's parts, read where they are (the message's own account). + attachments: await inspectAttachments(attachments.map((a) => ({ + name: a.name || 'attachment', + type: a.type || 'application/octet-stream', + size: a.size, + blobId: a.blobId, + load: (bytes: number) => client.fetchBlobArrayBuffer(a.blobId, a.name || undefined, a.type || undefined, sourceAccountId, bytes), + }))), + surface: 'plugin-forward', + mode: 'forward', + // The plugin's note is the only text written for this forward. + authoredText: note, + externalRecipients: await externalRecipientsFor(context), }; - if ((await emailHooks.onBeforeEmailSend.interceptOutcome(outgoing)) !== 'allowed') return { sent: false, reason: 'stopped' }; + if ((await runPluginSendCheck(outgoing)) !== 'allowed') return { sent: false, reason: 'stopped' }; } // The attachments are blobs of the account the original is in; a message diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index 2fee26ee0..4db15dc85 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -33,6 +33,7 @@ import { clearSectionTitle } from './section-titles'; import { cancelPluginProxyRequests } from './proxy-client'; import { cancelConnectFor } from './connect-dialog'; import { isHookHandedOver } from './host-features'; +import { hookArgsFor } from './hook-payload'; import type { SlotName } from '../plugin-types'; import { PLUGIN_REFUSED_ERROR, pluginAllowed } from '../outgoing-hosts'; @@ -294,8 +295,10 @@ async function startSandboxedPlugin(plugin: InstalledPlugin): Promise { // hook is skipped: copies older than the release that steps back on // its own would ask twice (host-features.ts). Asked per call, so // turning the core switch off gives the plugin its hook back. + // Parts of the arguments that need another permission are cut per + // plugin (hook-payload.ts). const pluginId = plugin.id; - const proxy = (...args: unknown[]) => (isHookHandedOver(pluginId, hookName) ? undefined : bg.invokeHook(hookName, args)); + const proxy = (...args: unknown[]) => (isHookHandedOver(pluginId, hookName) ? undefined : bg.invokeHook(hookName, hookArgsFor(plugin, hookName, args))); hookDisposables.push(bus.register(plugin.id, proxy as (...a: unknown[]) => unknown)); if (hookName === 'onEmailOpen') openHandler = proxy; } diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index ee62206d8..746fe09e2 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -1076,6 +1076,30 @@ export interface MailtoContext { * copy. Use to inject signatures, rewrite links, strip tracking pixels from * forwards, encrypt the body, etc. Return undefined to pass through unchanged. */ +/** An attachment of an OutgoingEmail. */ +export interface OutgoingAttachment { + name: string; + /** The type the file was attached with (often the browser's guess from the extension). */ + type: string; + size: number; + /** onBeforeEmailSend, 1.20 and later: the uploaded blob. */ + blobId?: string; + /** onBeforeEmailSend, 1.20 and later: an image shown in the body (pasted or quoted). */ + inline?: boolean; + /** + * onBeforeEmailSend, 1.20 and later: the type the file's first bytes say + * it is (application/x-msdownload for a program, application/zip, ...); + * absent when Bulwark knows no signature for them or couldn't read them. + */ + sniffedType?: string; + /** + * onBeforeEmailSend, 1.20 and later, plugins with `email:blob-read` only: + * up to the first megabyte of a text attachment (text/*, CSV, JSON, XML, + * calendar and contact files) as text. + */ + textExcerpt?: string; +} + export interface OutgoingEmail { to: string[]; cc: string[]; @@ -1086,7 +1110,7 @@ export interface OutgoingEmail { identityId: string; /** Sender email derived from the active identity (incl. sub-address tag, when set) */ fromEmail?: string; - attachments: { name: string; type: string; size: number }[]; + attachments: OutgoingAttachment[]; /** Original message id when this is a reply or forward */ inReplyTo?: string; /** Free-form custom headers added by the composer or earlier handlers */ @@ -1098,6 +1122,21 @@ export interface OutgoingEmail { surface?: 'composer' | 'quick-reply' | 'plugin-forward'; /** compose, reply, replyAll or forward (onBeforeEmailSend, 1.20 and later). */ mode?: 'compose' | 'reply' | 'replyAll' | 'forward'; + /** + * onBeforeEmailSend, 1.20 and later: what the user wrote, as plain text, + * without the quoted original of a reply or forward and without the + * signature. Check this rather than the bodies, or a number in the quoted + * mail warns on every reply. + */ + authoredText?: string; + /** + * onBeforeEmailSend, 1.20 and later: the recipients (To, Cc and Bcc) + * outside the organisation of the account the mail goes out from, as bare + * lowercase addresses. The host works it out from the account's identity + * domains and the admin's organisation domains, also when the admin's + * recipient warning is off. Empty when every recipient is internal. + */ + externalRecipients?: string[]; } /** * Passed to onBeforeDraftAutoSave handlers as a transform value. @@ -1184,6 +1223,11 @@ export interface AttachmentInfo { blobId?: string; /** The email this attachment belongs to (download / preview) */ emailId?: string; + /** + * onBeforeAttachmentUpload, 1.20 and later, for a file from this device: + * the type its first bytes say it is (see OutgoingAttachment.sniffedType). + */ + sniffedType?: string; } /** From c004587158983df0819d72a9772e7c1436e2f2ad Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 08:22:32 +0100 Subject: [PATCH 03/17] feat: keep sending blocked while a required send check isn't running --- .../composer-send-check-payload.test.tsx | 30 +++- components/email/email-composer.tsx | 2 + components/mail/mail-app.tsx | 1 + lib/__tests__/plugin-send-guards.test.ts | 122 +++++++++++++++ lib/outgoing/plugin-send-check.ts | 13 +- lib/plugin-sandbox/host-api-forward.ts | 4 +- lib/plugin-sandbox/loader.ts | 3 + lib/plugin-send-guards.ts | 146 ++++++++++++++++++ locales/ar/common.json | 3 + locales/ca/common.json | 3 + locales/cs/common.json | 3 + locales/da/common.json | 3 + locales/de/common.json | 3 + locales/en/common.json | 3 + locales/es/common.json | 3 + locales/et/common.json | 3 + locales/fa/common.json | 3 + locales/fr/common.json | 3 + locales/he/common.json | 3 + locales/hu/common.json | 3 + locales/it/common.json | 3 + locales/ja/common.json | 3 + locales/ko/common.json | 3 + locales/lv/common.json | 3 + locales/mn/common.json | 3 + locales/nb/common.json | 3 + locales/nl/common.json | 3 + locales/pl/common.json | 3 + locales/pt/common.json | 3 + locales/ro/common.json | 3 + locales/ru/common.json | 3 + locales/sk/common.json | 3 + locales/th/common.json | 3 + locales/tr/common.json | 3 + locales/uk/common.json | 3 + locales/vi/common.json | 3 + locales/zh-TW/common.json | 3 + locales/zh/common.json | 3 + stores/plugin-store.ts | 14 ++ 39 files changed, 420 insertions(+), 5 deletions(-) create mode 100644 lib/__tests__/plugin-send-guards.test.ts create mode 100644 lib/plugin-send-guards.ts diff --git a/components/email/__tests__/composer-send-check-payload.test.tsx b/components/email/__tests__/composer-send-check-payload.test.tsx index a8986b788..3db8388d8 100644 --- a/components/email/__tests__/composer-send-check-payload.test.tsx +++ b/components/email/__tests__/composer-send-check-payload.test.tsx @@ -6,7 +6,8 @@ import { usePolicyStore } from '@/stores/policy-store'; import { resetSendPipeline } from '@/lib/outgoing/send-pipeline'; import { DEFAULT_POLICY, type SettingsPolicy } from '@/lib/admin/types'; import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; -import type { OutgoingEmail } from '@/lib/plugin-types'; +import type { InstalledPlugin, OutgoingEmail } from '@/lib/plugin-types'; +import { setSendGuardSource } from '@/lib/plugin-send-guards'; // ─── Heavy component mocks (mirrors composer-reply-signature.test.tsx) ─ @@ -175,8 +176,9 @@ vi.mock('@/stores/template-store', () => { // ─── Misc dependency mocks ──────────────────────────────────────────────────── +const toasts = vi.hoisted(() => ({ error: [] as string[] })); vi.mock('@/stores/toast-store', () => ({ - toast: { info: () => {}, error: () => {}, success: () => {} }, + toast: { info: () => {}, error: (message: string) => { toasts.error.push(message); }, success: () => {} }, })); vi.mock('@/lib/plugin-hooks', () => ({ @@ -311,3 +313,27 @@ describe('composer: what a send check plugin gets', () => { expect((sendChecks.seen[0] as OutgoingEmail).externalRecipients).toEqual(['mallory@evil.test']); }); }); + +// A send check the admin requires that isn't running holds the send. +describe('composer: a required send check that is down', () => { + afterEach(() => { + setSendGuardSource(null); + toasts.error = []; + vi.clearAllMocks(); + }); + + it('sends nothing and says why', async () => { + const down = { + id: 'send-checks', name: 'Send checks', version: '1.0.0', permissions: ['email:send'], + status: 'error', error: 'auto-disabled', enabled: false, forceEnabled: true, managed: true, + } as unknown as InstalledPlugin; + setSendGuardSource({ snapshot: () => ({ plugins: [down], forced: () => true }), subscribe: () => () => {} }); + sendChecks.seen = []; + const onSend = vi.fn(); + render(); + fireEvent.click(screen.getAllByTestId('composer-send')[0]); + await waitFor(() => expect(toasts.error).toContain('not_running')); + expect(onSend).not.toHaveBeenCalled(); + expect(sendChecks.seen).toEqual([]); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 6febe2b36..eedd3d713 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -489,6 +489,7 @@ export function EmailComposer({ const tQuote = useTranslations('quote_header'); const tViewer = useTranslations('email_viewer'); const tClassification = useTranslations('classification'); + const tSendGuards = useTranslations('send_guards'); const timeFormat = useSettingsStore((state) => state.timeFormat); const plainTextModeDefault = useSettingsStore((state) => state.plainTextMode); // Per-message format (#1022). Seeded from the "plain text only" setting - @@ -3660,6 +3661,7 @@ export function EmailComposer({ // A guard that crashed or never answered showed nothing; say why // the message is still here. if (sendCheck === 'failed') toast.error(t('send_guard_failed')); + if (sendCheck === 'guard-down') toast.error(tSendGuards('not_running')); return; } diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index e04c7186d..0bccfb3e0 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -4118,6 +4118,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // A plugin that said no told the user; one that crashed didn't. onSendCheckStopped: (outcome) => { if (outcome === 'failed') toast.error(t('email_composer.send_guard_failed')); + if (outcome === 'guard-down') toast.error(t('send_guards.not_running')); }, questions: { // The fill-in words of the UI language and of English, as the composer. diff --git a/lib/__tests__/plugin-send-guards.test.ts b/lib/__tests__/plugin-send-guards.test.ts new file mode 100644 index 000000000..8b53f4496 --- /dev/null +++ b/lib/__tests__/plugin-send-guards.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + awaitSendGuards, + downSendGuards, + guardsSending, + rememberDeclaredHooks, + setSendGuardSource, +} from '@/lib/plugin-send-guards'; +import { runPluginSendCheck } from '@/lib/outgoing/plugin-send-check'; +import { emailHooks } from '@/lib/plugin-hooks'; +import type { InstalledPlugin, OutgoingEmail } from '@/lib/plugin-types'; +import { usePluginStore } from '@/stores/plugin-store'; +import { usePolicyStore } from '@/stores/policy-store'; + +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); + +// A send check the admin force-enabled that is starting, failed to start or +// was turned off after errors used to let every message through. + +const guard = (patch: Partial = {}): InstalledPlugin => ({ + id: 'send-checks', name: 'Send checks', version: '1.0.0', author: '', description: '', type: 'hook', + permissions: ['email:send', 'email:read'], entrypoint: 'index.js', enabled: true, status: 'running', + settings: {}, managed: true, forceEnabled: true, bundleHash: 'abc', ...patch, +}); + +const email: OutgoingEmail = { to: ['x@example.org'], cc: [], bcc: [], subject: 's', htmlBody: '', textBody: 't', identityId: 'i', attachments: [] }; + +beforeEach(() => { + localStorage.clear(); +}); +afterEach(() => { + usePluginStore.setState({ plugins: [] }); + emailHooks.onBeforeEmailSend.clear(); + vi.useRealTimers(); +}); + +describe('downSendGuards', () => { + it('counts a required guard that is starting, failed to start or was turned off after errors', () => { + usePluginStore.setState({ + plugins: [ + guard({ id: 'a', status: 'enabled' }), + guard({ id: 'b', status: 'error', error: 'Bundle integrity mismatch: expected x, got y' }), + guard({ id: 'c', status: 'error', error: 'auto-disabled', enabled: false }), + guard({ id: 'd', status: 'running' }), + ], + }); + expect(downSendGuards().map((g) => [g.id, g.state])).toEqual([['a', 'loading'], ['b', 'failed'], ['c', 'failed']]); + }); + + it("doesn't count a plugin Bulwark refused on purpose, nor one the user turned on", () => { + usePluginStore.setState({ + plugins: [ + guard({ id: 'a', status: 'error', error: 'third-party-off' }), + guard({ id: 'b', status: 'error', error: 'approval-denied' }), + guard({ id: 'c', status: 'error', error: 'Privileged tier requires the "crypto:full" permission' }), + guard({ id: 'd', status: 'enabled', forceEnabled: false, managed: false }), + ], + }); + expect(downSendGuards()).toEqual([]); + }); + + it('counts a plugin the policy forces on', () => { + const spy = vi.spyOn(usePolicyStore.getState(), 'isPluginForceEnabled').mockImplementation((id: string) => id === 'p'); + usePluginStore.setState({ plugins: [guard({ id: 'p', forceEnabled: false, status: 'enabled' })] }); + expect(downSendGuards().map((g) => g.id)).toEqual(['p']); + spy.mockRestore(); + }); + + it('knows a guard by the hooks of its last start, else by the email:send permission', () => { + expect(guardsSending(guard())).toBe(true); + expect(guardsSending(guard({ permissions: ['email:read'] }))).toBe(false); + rememberDeclaredHooks(guard(), 'abc', ['onComposeSend']); + expect(guardsSending(guard())).toBe(false); + // Another bundle: what the old one did no longer says anything. + expect(guardsSending(guard({ bundleHash: '"DEF"' }))).toBe(true); + rememberDeclaredHooks(guard({ bundleHash: 'def' }), 'def', ['onBeforeEmailSend']); + expect(guardsSending(guard({ bundleHash: '"DEF"' }))).toBe(true); + }); +}); + +describe('awaitSendGuards', () => { + it('waits for a guard that is starting and goes on once it runs', async () => { + usePluginStore.setState({ plugins: [guard({ status: 'enabled' })] }); + const waiting = awaitSendGuards(5000); + usePluginStore.setState({ plugins: [guard({ status: 'running' })] }); + expect(await waiting).toEqual([]); + }); + + it('gives up after the wait and reports it down', async () => { + vi.useFakeTimers(); + usePluginStore.setState({ plugins: [guard({ status: 'enabled' })] }); + const waiting = awaitSendGuards(5000); + await vi.advanceTimersByTimeAsync(5000); + expect(await waiting).toEqual([{ id: 'send-checks', name: 'Send checks', state: 'loading' }]); + }); + + it('sees nothing without a source (no plugin store, as in Lite)', async () => { + setSendGuardSource(null); + try { + usePluginStore.setState({ plugins: [guard({ status: 'error', error: 'auto-disabled' })] }); + expect(await awaitSendGuards(10)).toEqual([]); + } finally { + setSendGuardSource({ + snapshot: () => ({ plugins: usePluginStore.getState().plugins, forced: (p) => !!p.forceEnabled }), + subscribe: (l) => usePluginStore.subscribe(l), + }); + } + }); +}); + +describe('runPluginSendCheck', () => { + it('holds the send while a required guard is down, without asking the others', async () => { + const handler = vi.fn(() => undefined); + emailHooks.onBeforeEmailSend.register('other', handler); + usePluginStore.setState({ plugins: [guard({ status: 'error', error: 'auto-disabled', enabled: false })] }); + expect(await runPluginSendCheck(email)).toBe('guard-down'); + expect(handler).not.toHaveBeenCalled(); + usePluginStore.setState({ plugins: [guard()] }); + expect(await runPluginSendCheck(email)).toBe('allowed'); + expect(handler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/outgoing/plugin-send-check.ts b/lib/outgoing/plugin-send-check.ts index 919657290..4bbdfe8a6 100644 --- a/lib/outgoing/plugin-send-check.ts +++ b/lib/outgoing/plugin-send-check.ts @@ -11,13 +11,22 @@ import { emailHooks } from '@/lib/plugin-hooks'; import type { OutgoingEmail } from '@/lib/plugin-types'; +import { awaitSendGuards } from '@/lib/plugin-send-guards'; +import { debug } from '@/lib/debug'; /** * allowed: send. vetoed: a plugin said no and told the user. failed: a - * handler threw or ran out of time (nobody told the user yet). + * handler threw or ran out of time. guard-down: a send check the admin + * requires isn't running (lib/plugin-send-guards.ts). Nobody told the user + * about the last two yet. */ -export type PluginSendCheckOutcome = 'allowed' | 'vetoed' | 'failed'; +export type PluginSendCheckOutcome = 'allowed' | 'vetoed' | 'failed' | 'guard-down'; export async function runPluginSendCheck(email: OutgoingEmail): Promise { + const down = await awaitSendGuards(); + if (down.length > 0) { + debug.warn('email', `Send held: required send checks not running: ${down.map((g) => `${g.id} (${g.state})`).join(', ')}`); + return 'guard-down'; + } return emailHooks.onBeforeEmailSend.interceptOutcome(email); } diff --git a/lib/plugin-sandbox/host-api-forward.ts b/lib/plugin-sandbox/host-api-forward.ts index aeeec84d3..212a108f8 100644 --- a/lib/plugin-sandbox/host-api-forward.ts +++ b/lib/plugin-sandbox/host-api-forward.ts @@ -209,7 +209,9 @@ export async function doEmailForward( authoredText: note, externalRecipients: await externalRecipientsFor(context), }; - if ((await runPluginSendCheck(outgoing)) !== 'allowed') return { sent: false, reason: 'stopped' }; + const outcome = await runPluginSendCheck(outgoing); + if (outcome === 'guard-down') toast.error(text(messages, 'send_guards.not_running')); + if (outcome !== 'allowed') return { sent: false, reason: 'stopped' }; } // The attachments are blobs of the account the original is in; a message diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index 4db15dc85..ec61ff6e7 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -34,6 +34,7 @@ import { cancelPluginProxyRequests } from './proxy-client'; import { cancelConnectFor } from './connect-dialog'; import { isHookHandedOver } from './host-features'; import { hookArgsFor } from './hook-payload'; +import { rememberDeclaredHooks } from '../plugin-send-guards'; import type { SlotName } from '../plugin-types'; import { PLUGIN_REFUSED_ERROR, pluginAllowed } from '../outgoing-hosts'; @@ -316,6 +317,8 @@ async function startSandboxedPlugin(plugin: InstalledPlugin): Promise { const slotOffers = permittedOffers(plugin, info.slots, true); writeSlotOffers(plugin.id, hash, info.slots); + // Whether it checks mail before sending, for the next start (plugin-send-guards.ts). + rememberDeclaredHooks(plugin, hash, info.hooks); registerActive({ plugin, diff --git a/lib/plugin-send-guards.ts b/lib/plugin-send-guards.ts new file mode 100644 index 000000000..ee6f27643 --- /dev/null +++ b/lib/plugin-send-guards.ts @@ -0,0 +1,146 @@ +/** + * Send guards the admin requires that aren't running (enterprise pack R8). + * + * A plugin's onBeforeEmailSend can only stop mail while it runs. One the + * admin force-enabled that is still starting, failed to start or was turned + * off after errors (the hook bus's three-strikes breaker) used to let every + * message through. While such a guard is down, sending waits a few seconds + * for it and then stays blocked. + * + * Doesn't count: a plugin that never runs on purpose, because Bulwark + * refused it (the no-third-party mode, the admin's approval or user-plugin + * rules, a tier it may not have). The Security page lists those for the + * admin. And a plugin the user turned on themselves: that is their choice. + * + * Which plugins guard sending: those the last start saw registering + * onBeforeEmailSend (remembered per bundle), and before any start a plugin + * with the `email:send` permission, to be safe. + * + * The plugin store hands in its state through setSendGuardSource, so the + * send paths need no import of it (lib/outgoing/plugin-send-check.ts). + */ + +import type { InstalledPlugin } from './plugin-types'; + +export interface SendGuardSource { + /** The installed plugins and whether the admin forces each on. */ + snapshot(): { plugins: readonly InstalledPlugin[]; forced: (plugin: InstalledPlugin) => boolean }; + subscribe(listener: () => void): () => void; +} + +export type SendGuardState = 'loading' | 'failed'; + +export interface DownSendGuard { + id: string; + name: string; + state: SendGuardState; +} + +/** Errors that mean Bulwark refused to start the plugin, not that it broke. */ +const REFUSALS = new Set([ + 'third-party-off', + 'user-plugins-off', + 'approval-unavailable', + 'approval-denied', + 'approval-pending', +]); +const TIER_REFUSAL = /^Privileged tier requires /; + +/** How long a send waits for guards that are still starting. */ +export const SEND_GUARD_WAIT_MS = 8000; + +let source: SendGuardSource | null = null; + +export function setSendGuardSource(next: SendGuardSource | null): void { + source = next; +} + +// ─── Hooks seen at the last start ───────────────────────────── + +const HOOKS_KEY_PREFIX = 'plugin-declared-hooks:'; + +const normalHash = (hash: string | null | undefined) => (hash ?? '').replace(/^"|"$/g, '').trim().toLowerCase(); + +/** Called by the loader once a plugin has started: the hooks it registered. */ +export function rememberDeclaredHooks(plugin: Pick, hash: string, hooks: readonly string[]): void { + try { + localStorage.setItem(HOOKS_KEY_PREFIX + plugin.id, JSON.stringify({ hash: normalHash(hash), version: plugin.version, hooks })); + } catch { + // Storage blocked or full: the permission decides until next time. + } +} + +export function forgetDeclaredHooks(pluginId: string): void { + try { localStorage.removeItem(HOOKS_KEY_PREFIX + pluginId); } catch { /* ignore */ } +} + +function rememberedHooks(plugin: InstalledPlugin): string[] | null { + try { + const raw = localStorage.getItem(HOOKS_KEY_PREFIX + plugin.id); + if (!raw) return null; + const rec = JSON.parse(raw) as { hash?: unknown; version?: unknown; hooks?: unknown }; + const same = plugin.bundleHash ? rec.hash === normalHash(plugin.bundleHash) : rec.version === plugin.version; + if (!same || !Array.isArray(rec.hooks)) return null; + return rec.hooks.filter((h): h is string => typeof h === 'string'); + } catch { + return null; + } +} + +/** Whether a plugin checks mail before it is sent. */ +export function guardsSending(plugin: InstalledPlugin): boolean { + if (!plugin.permissions?.includes('email:send')) return false; + const hooks = rememberedHooks(plugin); + return hooks === null || hooks.includes('onBeforeEmailSend'); +} + +/** The state of one required guard, or null when it runs or doesn't count. */ +export function guardState(plugin: InstalledPlugin): SendGuardState | null { + switch (plugin.status) { + case 'running': + return null; + case 'error': { + const error = plugin.error ?? ''; + if (REFUSALS.has(error) || TIER_REFUSAL.test(error)) return null; + return 'failed'; + } + default: + // Enabled and not running yet, or waiting for the server sync to turn it on. + return 'loading'; + } +} + +/** The force-enabled send guards that aren't running now. */ +export function downSendGuards(): DownSendGuard[] { + if (!source) return []; + const { plugins, forced } = source.snapshot(); + const out: DownSendGuard[] = []; + for (const plugin of plugins) { + if (!forced(plugin) || !guardsSending(plugin)) continue; + const state = guardState(plugin); + if (state) out.push({ id: plugin.id, name: plugin.name, state }); + } + return out; +} + +/** + * The required guards that are still down after waiting up to `waitMs` for + * those that are starting. Empty: sending may go on. + */ +export async function awaitSendGuards(waitMs: number = SEND_GUARD_WAIT_MS): Promise { + const down = downSendGuards(); + if (!source || !down.some((guard) => guard.state === 'loading')) return down; + const current = source; + return new Promise((resolve) => { + let unsubscribe = () => {}; + const timer = setTimeout(() => finish(), waitMs); + const finish = () => { + clearTimeout(timer); + unsubscribe(); + resolve(downSendGuards()); + }; + unsubscribe = current.subscribe(() => { + if (!downSendGuards().some((guard) => guard.state === 'loading')) finish(); + }); + }); +} diff --git a/locales/ar/common.json b/locales/ar/common.json index e68debcc5..af0a02f95 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6001,6 +6001,9 @@ "error_save": "تعذّر الحفظ: {error}" } }, + "send_guards": { + "not_running": "لم تُرسل. هناك فحص تشترطه مؤسستك لا يعمل حاليًا. حاول مرة أخرى بعد قليل." + }, "plugin_list": { "filters": "المرشحات", "remove_filter": "إزالة المرشح {label}" diff --git a/locales/ca/common.json b/locales/ca/common.json index c29e04d6d..e7d195f3f 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -6001,6 +6001,9 @@ "error_save": "No s'ha pogut desar: {error}" } }, + "send_guards": { + "not_running": "No s'ha enviat. Una comprovació que exigeix la vostra organització no s'està executant. Torneu-ho a provar d'aquí a un moment." + }, "plugin_list": { "filters": "Filtres", "remove_filter": "Treu el filtre {label}" diff --git a/locales/cs/common.json b/locales/cs/common.json index 8ff06fa62..42841c483 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -6001,6 +6001,9 @@ "error_save": "Uložení se nezdařilo: {error}" } }, + "send_guards": { + "not_running": "Neodesláno. Kontrola, kterou vaše organizace vyžaduje, právě neběží. Zkuste to za chvíli znovu." + }, "plugin_list": { "filters": "Filtry", "remove_filter": "Odebrat filtr {label}" diff --git a/locales/da/common.json b/locales/da/common.json index 721ff04c3..c8ede86b8 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -6001,6 +6001,9 @@ "error_save": "Kunne ikke gemme: {error}" } }, + "send_guards": { + "not_running": "Ikke sendt. En kontrol, som din organisation kræver, kører ikke. Prøv igen om et øjeblik." + }, "plugin_list": { "filters": "Filtre", "remove_filter": "Fjern filteret {label}" diff --git a/locales/de/common.json b/locales/de/common.json index e82dd8086..dadd10d1c 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -6001,6 +6001,9 @@ "error_save": "Speichern fehlgeschlagen: {error}" } }, + "send_guards": { + "not_running": "Nicht gesendet. Eine Prüfung, die Ihre Organisation vorschreibt, läuft gerade nicht. Versuchen Sie es gleich noch einmal." + }, "plugin_list": { "filters": "Filter", "remove_filter": "Filter {label} entfernen" diff --git a/locales/en/common.json b/locales/en/common.json index 89d25e3c8..637e6e7bb 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6001,6 +6001,9 @@ "error_save": "Could not save: {error}" } }, + "send_guards": { + "not_running": "Not sent. A check your organisation requires isn't running. Try again in a moment." + }, "plugin_list": { "filters": "Filters", "remove_filter": "Remove filter {label}" diff --git a/locales/es/common.json b/locales/es/common.json index 71e689bda..0b0ffcfa9 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -6001,6 +6001,9 @@ "error_save": "No se pudo guardar: {error}" } }, + "send_guards": { + "not_running": "No enviado. Una comprobación que exige tu organización no está en marcha. Vuelve a intentarlo en un momento." + }, "plugin_list": { "filters": "Filtros", "remove_filter": "Quitar el filtro {label}" diff --git a/locales/et/common.json b/locales/et/common.json index d0fc45749..4f941156c 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -6001,6 +6001,9 @@ "error_save": "Salvestamine ebaõnnestus: {error}" } }, + "send_guards": { + "not_running": "Ei saadetud. Teie organisatsiooni nõutud kontroll praegu ei tööta. Proovige hetke pärast uuesti." + }, "plugin_list": { "filters": "Filtrid", "remove_filter": "Eemalda filter {label}" diff --git a/locales/fa/common.json b/locales/fa/common.json index 120bf77a9..6d33d0d9d 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6001,6 +6001,9 @@ "error_save": "ذخیره نشد: {error}" } }, + "send_guards": { + "not_running": "ارسال نشد. بررسی‌ای که سازمان شما الزامی کرده در حال اجرا نیست. چند لحظه دیگر دوباره تلاش کنید." + }, "plugin_list": { "filters": "فیلترها", "remove_filter": "حذف فیلتر {label}" diff --git a/locales/fr/common.json b/locales/fr/common.json index 187cbadbc..61c388afb 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -6001,6 +6001,9 @@ "error_save": "Impossible d'enregistrer : {error}" } }, + "send_guards": { + "not_running": "Non envoyé. Une vérification exigée par votre organisation ne fonctionne pas. Réessayez dans un instant." + }, "plugin_list": { "filters": "Filtres", "remove_filter": "Retirer le filtre {label}" diff --git a/locales/he/common.json b/locales/he/common.json index 9a9471d72..c924bb9f1 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6002,6 +6002,9 @@ "error_save": "השמירה נכשלה: {error}" } }, + "send_guards": { + "not_running": "לא נשלח. בדיקה שהארגון שלך מחייב לא פועלת כרגע. נסו שוב בעוד רגע." + }, "plugin_list": { "filters": "מסננים", "remove_filter": "הסר את המסנן {label}" diff --git a/locales/hu/common.json b/locales/hu/common.json index 50ce8a1e6..af40c1100 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -6001,6 +6001,9 @@ "error_save": "Nem sikerült menteni: {error}" } }, + "send_guards": { + "not_running": "Nincs elküldve. Egy, a szervezete által előírt ellenőrzés most nem fut. Próbálja újra egy pillanat múlva." + }, "plugin_list": { "filters": "Szűrők", "remove_filter": "{label} szűrő eltávolítása" diff --git a/locales/it/common.json b/locales/it/common.json index c643485f0..2d4bc8a88 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -6001,6 +6001,9 @@ "error_save": "Impossibile salvare: {error}" } }, + "send_guards": { + "not_running": "Non inviato. Un controllo richiesto dalla tua organizzazione non è attivo. Riprova tra un momento." + }, "plugin_list": { "filters": "Filtri", "remove_filter": "Rimuovi il filtro {label}" diff --git a/locales/ja/common.json b/locales/ja/common.json index 0c1c3767f..f4728eb48 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6001,6 +6001,9 @@ "error_save": "保存できませんでした: {error}" } }, + "send_guards": { + "not_running": "送信されませんでした。組織で必須のチェックが動作していません。しばらくしてからもう一度お試しください。" + }, "plugin_list": { "filters": "フィルター", "remove_filter": "フィルター「{label}」を解除" diff --git a/locales/ko/common.json b/locales/ko/common.json index 6d5b983ef..2447ec2b2 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6001,6 +6001,9 @@ "error_save": "저장하지 못했어요: {error}" } }, + "send_guards": { + "not_running": "보내지 않았습니다. 조직에서 요구하는 검사가 실행되고 있지 않습니다. 잠시 후 다시 시도하세요." + }, "plugin_list": { "filters": "필터", "remove_filter": "{label} 필터 제거" diff --git a/locales/lv/common.json b/locales/lv/common.json index 41c0fd87d..f7ee1227e 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -6001,6 +6001,9 @@ "error_save": "Neizdevās saglabāt: {error}" } }, + "send_guards": { + "not_running": "Nav nosūtīts. Jūsu organizācijas pieprasītā pārbaude pašlaik nedarbojas. Mēģiniet vēlreiz pēc brīža." + }, "plugin_list": { "filters": "Filtri", "remove_filter": "Noņemt filtru {label}" diff --git a/locales/mn/common.json b/locales/mn/common.json index c25d9cc2c..a25d7c1ab 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6001,6 +6001,9 @@ "error_save": "Хадгалж чадсангүй: {error}" } }, + "send_guards": { + "not_running": "Илгээгдсэнгүй. Танай байгууллагын шаарддаг шалгалт одоогоор ажиллахгүй байна. Хэсэг хугацааны дараа дахин оролдоно уу." + }, "plugin_list": { "filters": "Шүүлтүүр", "remove_filter": "{label} шүүлтүүрийг хасах" diff --git a/locales/nb/common.json b/locales/nb/common.json index f0c843b6f..12bea3c7e 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -6001,6 +6001,9 @@ "error_save": "Kunne ikke lagre: {error}" } }, + "send_guards": { + "not_running": "Ikke sendt. En kontroll organisasjonen din krever, kjører ikke. Prøv igjen om et øyeblikk." + }, "plugin_list": { "filters": "Filtre", "remove_filter": "Fjern filteret {label}" diff --git a/locales/nl/common.json b/locales/nl/common.json index cd0d8acb3..d1b377ab6 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -6001,6 +6001,9 @@ "error_save": "Opslaan mislukt: {error}" } }, + "send_guards": { + "not_running": "Niet verzonden. Een controle die je organisatie verplicht stelt, draait niet. Probeer het zo meteen opnieuw." + }, "plugin_list": { "filters": "Filters", "remove_filter": "Filter {label} verwijderen" diff --git a/locales/pl/common.json b/locales/pl/common.json index dda8917a2..629244756 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -6001,6 +6001,9 @@ "error_save": "Nie udało się zapisać: {error}" } }, + "send_guards": { + "not_running": "Nie wysłano. Kontrola wymagana przez Twoją organizację nie działa. Spróbuj ponownie za chwilę." + }, "plugin_list": { "filters": "Filtry", "remove_filter": "Usuń filtr {label}" diff --git a/locales/pt/common.json b/locales/pt/common.json index 1a07a0714..56cd5a626 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -6001,6 +6001,9 @@ "error_save": "Não foi possível salvar: {error}" } }, + "send_guards": { + "not_running": "Não enviado. Uma verificação exigida pela sua organização não está em execução. Tente novamente daqui a pouco." + }, "plugin_list": { "filters": "Filtros", "remove_filter": "Remover o filtro {label}" diff --git a/locales/ro/common.json b/locales/ro/common.json index 9b26d94d2..47000dcb5 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -6001,6 +6001,9 @@ "error_save": "Nu s-a putut salva: {error}" } }, + "send_guards": { + "not_running": "Nu a fost trimis. O verificare cerută de organizația ta nu rulează. Încearcă din nou peste o clipă." + }, "plugin_list": { "filters": "Filtre", "remove_filter": "Eliminați filtrul {label}" diff --git a/locales/ru/common.json b/locales/ru/common.json index ad2517e4b..5d6d20b6e 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6001,6 +6001,9 @@ "error_save": "Не удалось сохранить: {error}" } }, + "send_guards": { + "not_running": "Не отправлено. Проверка, которую требует ваша организация, сейчас не работает. Повторите попытку через минуту." + }, "plugin_list": { "filters": "Фильтры", "remove_filter": "Убрать фильтр {label}" diff --git a/locales/sk/common.json b/locales/sk/common.json index 34e7791cb..0709c73bc 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -6001,6 +6001,9 @@ "error_save": "Uloženie zlyhalo: {error}" } }, + "send_guards": { + "not_running": "Neodoslané. Kontrola, ktorú vyžaduje vaša organizácia, práve nebeží. Skúste to o chvíľu znova." + }, "plugin_list": { "filters": "Filtre", "remove_filter": "Odstrániť filter {label}" diff --git a/locales/th/common.json b/locales/th/common.json index 0c7a1c589..ae6aa2b6a 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2945,6 +2945,9 @@ "error_save": "บันทึกไม่สำเร็จ: {error}" } }, + "send_guards": { + "not_running": "ยังไม่ได้ส่ง การตรวจสอบที่องค์กรของคุณกำหนดไม่ได้ทำงานอยู่ ลองอีกครั้งในอีกสักครู่" + }, "plugin_list": { "filters": "ตัวกรอง", "remove_filter": "นำตัวกรอง {label} ออก" diff --git a/locales/tr/common.json b/locales/tr/common.json index 11d0a04cc..12bfd09c1 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -6001,6 +6001,9 @@ "error_save": "Kaydedilemedi: {error}" } }, + "send_guards": { + "not_running": "Gönderilmedi. Kuruluşunuzun zorunlu tuttuğu bir denetim şu anda çalışmıyor. Biraz sonra yeniden deneyin." + }, "plugin_list": { "filters": "Filtreler", "remove_filter": "{label} filtresini kaldır" diff --git a/locales/uk/common.json b/locales/uk/common.json index 587e9b6af..918367fd7 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6001,6 +6001,9 @@ "error_save": "Не вдалося зберегти: {error}" } }, + "send_guards": { + "not_running": "Не надіслано. Перевірка, якої вимагає ваша організація, зараз не працює. Спробуйте ще раз за мить." + }, "plugin_list": { "filters": "Фільтри", "remove_filter": "Прибрати фільтр {label}" diff --git a/locales/vi/common.json b/locales/vi/common.json index af8be5861..b5dba61d7 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -6013,6 +6013,9 @@ "error_save": "Không thể lưu: {error}" } }, + "send_guards": { + "not_running": "Chưa gửi. Một bước kiểm tra mà tổ chức của bạn yêu cầu hiện không chạy. Hãy thử lại sau giây lát." + }, "plugin_list": { "filters": "Bộ lọc", "remove_filter": "Xóa bộ lọc {label}" diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index b6fe8d43c..bd463b83c 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6001,6 +6001,9 @@ "error_save": "無法儲存:{error}" } }, + "send_guards": { + "not_running": "未傳送。貴組織要求的一項檢查目前未執行。請稍後再試。" + }, "plugin_list": { "filters": "篩選", "remove_filter": "移除篩選條件 {label}" diff --git a/locales/zh/common.json b/locales/zh/common.json index 96d9ac219..c014c081e 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6001,6 +6001,9 @@ "error_save": "无法保存:{error}" } }, + "send_guards": { + "not_running": "未发送。你的组织要求的一项检查当前未运行。请稍后再试。" + }, "plugin_list": { "filters": "筛选", "remove_filter": "移除筛选条件 {label}" diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index 7f894689b..9fb26514c 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -27,6 +27,7 @@ import { IMPLICIT_PERMISSIONS } from '@/lib/plugin-types'; import type { Permission } from '@/lib/plugin-types'; import { IS_LITE } from '@/lib/lite'; import { onFullSignOut } from '@/lib/sign-out-cleanup'; +import { forgetDeclaredHooks, setSendGuardSource } from '@/lib/plugin-send-guards'; let pluginInitializationPromise: Promise | null = null; // One-time guard so we attach the locale and regional subscriptions only once. @@ -427,6 +428,7 @@ function removePluginData(id: string): void { deactivatePlugin(id); removeAllPluginHooks(id); forgetSlotOffers(id); + forgetDeclaredHooks(id); void pluginStorage.deleteCode(id).catch(() => {}); void pluginStorage.deletePreview(id).catch(() => {}); // Accounts the user connected for it live on the server (this mail account's). @@ -838,3 +840,15 @@ onFullSignOut(() => { }), }); }); + +// Send guards the admin requires hold sends while they aren't running +// (lib/plugin-send-guards.ts). No plugins run in Lite. +if (!IS_LITE) { + setSendGuardSource({ + snapshot: () => ({ + plugins: usePluginStore.getState().plugins, + forced: (plugin) => !!plugin.forceEnabled || usePolicyStore.getState().isPluginForceEnabled(plugin.id), + }), + subscribe: (listener) => usePluginStore.subscribe(listener), + }); +} From b3369c7e783270073bc50847ca13c443ac36c3e7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 08:31:27 +0100 Subject: [PATCH 04/17] feat: send every link click through the link check --- .../__tests__/link-check-surfaces.test.tsx | 130 ++++++++++++++++++ components/calendar/event-detail-popover.tsx | 5 + components/calendar/event-modal.tsx | 5 +- components/contacts/contact-detail.tsx | 11 +- .../thread-conversation-link-gate.test.tsx | 13 ++ .../__tests__/unsubscribe-banner.test.tsx | 30 ++++ components/email/email-viewer.tsx | 8 +- components/email/reader-view-dialog.tsx | 4 + .../email/structured-mail/card-parts.tsx | 14 +- components/email/thread-conversation-view.tsx | 12 +- components/email/unsubscribe-banner.tsx | 9 +- .../notifications/notification-center.tsx | 4 +- components/ui/linkified-text.tsx | 7 +- lib/plugin-hook-result.ts | 52 ++++++- lib/plugin-hooks.ts | 15 +- lib/plugin-sandbox/host-api.ts | 8 +- lib/plugin-types.ts | 6 + 17 files changed, 299 insertions(+), 34 deletions(-) create mode 100644 components/__tests__/link-check-surfaces.test.tsx diff --git a/components/__tests__/link-check-surfaces.test.tsx b/components/__tests__/link-check-surfaces.test.tsx new file mode 100644 index 000000000..39f615287 --- /dev/null +++ b/components/__tests__/link-check-surfaces.test.tsx @@ -0,0 +1,130 @@ +// Links outside message bodies opened without the plugins' link check +// (onBeforeExternalLink): the reader view, event descriptions, locations +// and meeting links, contact addresses, notification links, the +// List-Unsubscribe page, a middle-click, and other plugins' +// ui.openExternalUrl. A link check that warned in the reader was no check. +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { clearAllHooks, pluginErrorTracker, uiHooks } from '@/lib/plugin-hooks'; +import { gateLinkClick, linkText, openExternalLink, resolveExternalLink } from '@/lib/plugin-hook-result'; +import { LinkifiedText } from '@/components/ui/linkified-text'; +import { ReaderViewDialog } from '@/components/email/reader-view-dialog'; +import type { ExternalLinkContext } from '@/lib/plugin-types'; + +vi.mock('@/hooks/use-focus-trap', () => ({ useFocusTrap: () => ({ current: null }) })); + +afterEach(() => { + cleanup(); + clearAllHooks(); + pluginErrorTracker.resetAll(); + vi.restoreAllMocks(); +}); + +function watchLinks(answer: (ctx: ExternalLinkContext) => unknown = () => undefined) { + const seen: ExternalLinkContext[] = []; + uiHooks.onBeforeExternalLink.register('link-check', (ctx: ExternalLinkContext) => { seen.push(ctx); return answer(ctx); }); + const open = vi.spyOn(window, 'open').mockReturnValue(null); + return { seen, open }; +} + +describe('gateLinkClick', () => { + it('sends a click on a web link through the check with the link text, then opens it', async () => { + const { seen, open } = watchLinks(); + render(); + expect(fireEvent.click(screen.getByRole('link'))).toBe(false); + await waitFor(() => expect(open).toHaveBeenCalledWith('https://evil.example/x', '_blank', 'noopener,noreferrer')); + expect(seen).toEqual([{ href: 'https://evil.example/x', target: '_blank', text: 'https://bank.example' }]); + }); + + it('opens nothing when the check says no', async () => { + const { seen, open } = watchLinks(() => false); + render(); + fireEvent.click(screen.getByRole('link')); + await waitFor(() => expect(seen).toHaveLength(1)); + await new Promise((r) => setTimeout(r, 10)); + expect(open).not.toHaveBeenCalled(); + }); + + it('gates a middle-click, and leaves right-clicks, mailto: and tel: to the browser', async () => { + const { seen } = watchLinks(() => false); + render( +
+ m + mail + call +
, + ); + const [web, mail, tel] = screen.getAllByRole('link'); + expect(fireEvent(web, new MouseEvent('auxclick', { bubbles: true, cancelable: true, button: 1 }))).toBe(false); + expect(fireEvent(web, new MouseEvent('auxclick', { bubbles: true, cancelable: true, button: 2 }))).toBe(true); + expect(fireEvent.click(mail)).toBe(true); + expect(fireEvent.click(tel)).toBe(true); + await waitFor(() => expect(seen.map((c) => c.href)).toEqual(['https://evil.example/m'])); + }); + + it('names an image link by its alt text', () => { + const a = document.createElement('a'); + a.innerHTML = '  Sign in to   PayPal '; + expect(linkText(a)).toBe('Sign in to PayPal'); + a.textContent = 'x'.repeat(300); + expect(linkText(a)).toHaveLength(200); + }); +}); + +describe('resolveExternalLink and openExternalLink', () => { + it("doesn't ask the plugin that opens the link itself", async () => { + const own = vi.fn(() => false); + const other = vi.fn(() => undefined); + uiHooks.onBeforeExternalLink.register('link-check', own); + uiHooks.onBeforeExternalLink.register('other', other); + expect(await resolveExternalLink({ href: 'https://a.example/' }, { skipPluginId: 'link-check' })).toBe('https://a.example/'); + expect(own).not.toHaveBeenCalled(); + expect(other).toHaveBeenCalledTimes(1); + expect(await resolveExternalLink({ href: 'https://a.example/' })).toBeNull(); + }); + + it('opens only web addresses', async () => { + const { open } = watchLinks(); + expect(await openExternalLink({ href: 'javascript:alert(1)' })).toBe(false); + expect(await openExternalLink({ href: 'https://a.example/' })).toBe(true); + expect(open).toHaveBeenCalledTimes(1); + }); +}); + +describe('surfaces', () => { + it('event descriptions (LinkifiedText)', async () => { + const { seen, open } = watchLinks(() => false); + render(); + expect(fireEvent.click(screen.getByRole('link'))).toBe(false); + await waitFor(() => expect(seen.map((c) => c.href)).toEqual(['https://meet.example/abc'])); + expect(open).not.toHaveBeenCalled(); + }); + + it('the reader view', async () => { + const { seen } = watchLinks(() => false); + render( + {}} title="t" html={'click'} />, + ); + const link = screen.getByTestId('reader-view-body').querySelector('a')!; + expect(fireEvent.click(link)).toBe(false); + await waitFor(() => expect(seen).toEqual([expect.objectContaining({ href: 'https://evil.example/r', text: 'click' })])); + }); +}); + +describe("other plugins' ui.openExternalUrl", () => { + it('goes through the link check, except for the plugin asking', async () => { + const { dispatchApiCall } = await import('@/lib/plugin-sandbox/host-api'); + const { seen, open } = watchLinks(() => false); + const caller = (id: string) => ({ + id, name: id, version: '1', author: '', description: '', type: 'hook', permissions: [], entrypoint: 'index.js', + enabled: true, status: 'running', settings: {}, + }) as never; + await dispatchApiCall(caller('other'), 'ui.openExternalUrl', ['https://evil.example/p']); + expect(seen.map((c) => c.href)).toEqual(['https://evil.example/p']); + expect(open).not.toHaveBeenCalled(); + // The link check opening the link after its own dialog. + await dispatchApiCall(caller('link-check'), 'ui.openExternalUrl', ['https://evil.example/p']); + expect(seen).toHaveLength(1); + expect(open).toHaveBeenCalledWith('https://evil.example/p', '_blank', 'noopener,noreferrer'); + }); +}); diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index 4c995ab3b..2d3def25a 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -34,6 +34,7 @@ import type { AnswerTarget } from "@/lib/calendar-delegate"; import { isEditableEventTarget } from "@/lib/keyboard"; import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts"; import { useCvdColor } from "@/hooks/use-cvd-color"; +import { gateLinkClick } from "@/lib/plugin-hook-result"; interface EventDetailPopoverProps { event: CalendarEvent; @@ -524,6 +525,8 @@ export function EventDetailPopover({ href={locationHref ?? undefined} target="_blank" rel="noreferrer" + onClick={gateLinkClick} + onAuxClick={gateLinkClick} className={cn("text-sm min-w-0 break-words", locationHref ? "text-primary hover:underline" : "text-foreground")} dir="auto" title={locationName} @@ -554,6 +557,8 @@ export function EventDetailPopover({ href={virtualLocation} target="_blank" rel="noreferrer" + onClick={gateLinkClick} + onAuxClick={gateLinkClick} className="text-sm text-primary hover:underline truncate" title={virtualLocation} > diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 71d61d6f7..08a70b9ef 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -53,6 +53,7 @@ import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useFocusTrap } from "@/hooks/use-focus-trap"; import { useCvdColor } from "@/hooks/use-cvd-color"; +import { gateLinkClick } from "@/lib/plugin-hook-result"; export interface PendingEventPreview { start: Date; @@ -1128,7 +1129,7 @@ export function EventModal({
{/* A URL opens as a link, a postal address in the maps, as on the card. */} - + {/^https?:\/\//i.test(locationName) ? (() => { try { return new URL(locationName).hostname; } catch { return locationName; } })() : locationName} @@ -1140,7 +1141,7 @@ export function EventModal({ {virtualLoc && ( diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx index a2f1ea4e4..2b0b382d3 100644 --- a/components/contacts/contact-detail.tsx +++ b/components/contacts/contact-detail.tsx @@ -19,6 +19,7 @@ import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { contactHooks } from "@/lib/plugin-hooks"; import { contactToView } from "@/lib/plugin-projection"; +import { gateLinkClick } from "@/lib/plugin-hook-result"; interface ContactDetailProps { contact: ContactCard | null; @@ -283,7 +284,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli >
{typeof svc.uri === 'string' && svc.uri.startsWith("http") ? ( - + {svc.user || svc.uri} ) : ( @@ -419,7 +420,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
{typeof key.uri === 'string' && key.uri.startsWith("http") ? ( - + {key.uri} ) : ( @@ -437,21 +438,21 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
{contact.calendarUri && ( - + {contact.calendarUri} )} {contact.schedulingUri && ( - + {contact.schedulingUri} )} {contact.freeBusyUri && ( - + {contact.freeBusyUri} diff --git a/components/email/__tests__/thread-conversation-link-gate.test.tsx b/components/email/__tests__/thread-conversation-link-gate.test.tsx index 00bef1b12..870003337 100644 --- a/components/email/__tests__/thread-conversation-link-gate.test.tsx +++ b/components/email/__tests__/thread-conversation-link-gate.test.tsx @@ -75,6 +75,19 @@ describe('conversation view link gate', () => { expect(hook).toHaveBeenCalledTimes(1); }, 30_000); + it('sends a middle-click through the check too, with the link text', async () => { + const open = vi.spyOn(window, 'open').mockReturnValue(null); + const hook = vi.fn(() => false); + uiHooks.onBeforeExternalLink.register('p1', hook); + const doc = renderBody(); + + const ev = new MouseEvent('auxclick', { bubbles: true, cancelable: true, button: 1 }); + doc.getElementById('web')!.dispatchEvent(ev); + expect(ev.defaultPrevented).toBe(true); + await waitFor(() => expect(hook).toHaveBeenCalledWith(expect.objectContaining({ href: 'https://site.example/a', text: 'web' }))); + expect(open).not.toHaveBeenCalled(); + }, 30_000); + it('opens nothing when a plugin stops the link', async () => { const open = vi.spyOn(window, 'open').mockReturnValue(null); const hook = vi.fn(() => false); diff --git a/components/email/__tests__/unsubscribe-banner.test.tsx b/components/email/__tests__/unsubscribe-banner.test.tsx index 6ca90a263..28d4ace65 100644 --- a/components/email/__tests__/unsubscribe-banner.test.tsx +++ b/components/email/__tests__/unsubscribe-banner.test.tsx @@ -50,3 +50,33 @@ describe('UnsubscribeBanner mailto', () => { expect(container.textContent).toBe(''); }); }); + +// The https unsubscribe page is a link from the sender like any other in +// the mail: it opened without the plugins' link check. +describe('UnsubscribeBanner https', () => { + it('opens the page through the link check and stays put when the check says no', async () => { + const { uiHooks, clearAllHooks } = await import('@/lib/plugin-hooks'); + const open = vi.spyOn(window, 'open').mockReturnValue(null); + const check = vi.fn(() => false); + uiHooks.onBeforeExternalLink.register('link-check', check); + try { + render( + {}} + />, + ); + fireEvent.click(screen.getByText('email_viewer.unsubscribe_banner.button')); + fireEvent.click(screen.getByText('email_viewer.unsubscribe_banner.confirm_button')); + await waitFor(() => expect(check).toHaveBeenCalledWith(expect.objectContaining({ href: 'https://evil.example/unsub?u=1' }))); + await new Promise((r) => setTimeout(r, 10)); + expect(open).not.toHaveBeenCalled(); + expect(screen.queryByText('email_viewer.unsubscribe_banner.success')).toBeNull(); + } finally { + clearAllHooks(); + open.mockRestore(); + } + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index ae8b25a55..16cd5dfc1 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -169,7 +169,7 @@ import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { emailHooks, renderHooks } from "@/lib/plugin-hooks"; import { emitThreadExpand } from "@/lib/plugin-thread-expand"; -import { resolveExternalLink } from "@/lib/plugin-hook-result"; +import { linkText, resolveExternalLink } from "@/lib/plugin-hook-result"; import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types"; import { useAttachmentDrag, isDragOutSupported, type AttachmentDragSource } from "@/hooks/use-attachment-drag"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; @@ -3521,7 +3521,9 @@ export function EmailViewer({ // Plugin intercept: let plugins cancel or rewrite external links inside // the email body before navigation happens. Bound on the iframe doc so // it survives later DOM mutations. - const onLinkClick = async (ev: Event) => { + const onLinkClick = async (ev: MouseEvent) => { + // A middle-click opens a link too; other buttons are left alone. + if (ev.type === 'auxclick' && ev.button !== 1) return; const targetEl = (ev.target as Element | null)?.closest?.('a[href], area[href]') as HTMLAnchorElement | HTMLAreaElement | null; if (!targetEl) return; const href = targetEl.getAttribute('href') || ''; @@ -3567,6 +3569,7 @@ export function EmailViewer({ href, target: targetEl.getAttribute('target') ?? undefined, emailId: email?.id, + text: linkText(targetEl), }; // A plugin returns false to stop the link or { href } to send it // somewhere else (a web address only). @@ -3575,6 +3578,7 @@ export function EmailViewer({ window.open(target, '_blank', 'noopener,noreferrer'); }; doc.addEventListener('click', onLinkClick, true); + doc.addEventListener('auxclick', onLinkClick, true); // A press in the body closes a mention's card. The page never sees // it: the focus is already inside the iframe, so there is no blur. doc.addEventListener('mousedown', () => setBodyMention(null), true); diff --git a/components/email/reader-view-dialog.tsx b/components/email/reader-view-dialog.tsx index cde105f44..7e1a55c1c 100644 --- a/components/email/reader-view-dialog.tsx +++ b/components/email/reader-view-dialog.tsx @@ -9,6 +9,7 @@ import { useMailReading } from "@/hooks/use-mail-reading"; import { useReadAloud } from "@/hooks/use-read-aloud"; import { sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; import { ReadAloudBar } from "./read-aloud-bar"; +import { gateLinkClick } from "@/lib/plugin-hook-result"; interface ReaderViewDialogProps { isOpen: boolean; @@ -85,6 +86,9 @@ export function ReaderViewDialog({ isOpen, onClose, title, description, html, la ...(reading.comfortableWidth && { maxWidth: '80ch' }), }} data-testid="reader-view-body" + // Links open through the plugins' link check, as in the message. + onClick={gateLinkClick} + onAuxClick={gateLinkClick} dangerouslySetInnerHTML={body} />
diff --git a/components/email/structured-mail/card-parts.tsx b/components/email/structured-mail/card-parts.tsx index 4c3b30323..95b5c077e 100644 --- a/components/email/structured-mail/card-parts.tsx +++ b/components/email/structured-mail/card-parts.tsx @@ -3,7 +3,7 @@ import type { ComponentType, ReactNode } from "react"; import { useLocale, useTranslations } from "next-intl"; import { ExternalLink } from "@/components/icons"; -import { resolveExternalLink } from "@/lib/plugin-hook-result"; +import { openExternalLink } from "@/lib/plugin-hook-result"; import { formatMoment, formatMomentTime, isOtherZone, isWebLink, type FormatOptions } from "@/lib/structured-mail/format"; import type { Link, Moment, ReservationStatus } from "@/lib/structured-mail/types"; import { resolveTimeZone } from "@/lib/timezone"; @@ -89,12 +89,16 @@ export function CardLink({ link, label, icon: Icon = ExternalLink, emailId }: { rel="noopener noreferrer" className={cn(ACTION_CLASS, "flex-wrap gap-y-0")} data-testid="structured-mail-link" - onClick={async (e) => { + onClick={(e) => { e.preventDefault(); e.stopPropagation(); - const target = await resolveExternalLink({ href: link.url, target: "_blank", emailId }); - if (!target) return; - window.open(target, "_blank", "noopener,noreferrer"); + void openExternalLink({ href: link.url, target: "_blank", emailId, text: label }); + }} + onAuxClick={(e) => { + if (e.button !== 1) return; + e.preventDefault(); + e.stopPropagation(); + void openExternalLink({ href: link.url, target: "_blank", emailId, text: label }); }} >