diff --git a/app/api/admin/outgoing-hosts/route.ts b/app/api/admin/outgoing-hosts/route.ts index d7eaeccce..67597c3ac 100644 --- a/app/api/admin/outgoing-hosts/route.ts +++ b/app/api/admin/outgoing-hosts/route.ts @@ -5,7 +5,9 @@ import { isConfigReadOnly } from '@/lib/admin/paths'; import { getPluginRegistry } from '@/lib/admin/plugin-registry'; import { noThirdPartyTrafficState } from '@/lib/admin/third-party-mode'; import { listReachableHosts } from '@/lib/admin/reachable-hosts'; -import { pluginDeclaresNetwork } from '@/lib/outgoing-hosts'; +import { pluginAllowedInMode, pluginDeclaresNetwork } from '@/lib/outgoing-hosts'; +import { adminEnteredOrigins } from '@/lib/plugin-secrets/proxy'; +import type { CredentialSpec } from '@/lib/plugin-secrets/credentials'; import { OUTGOING_HOSTS } from '@/lib/admin/outgoing-hosts-registry'; import { logger } from '@/lib/logger'; @@ -25,6 +27,17 @@ export async function GET(request: NextRequest) { const state = noThirdPartyTrafficState(); const policy = configManager.getPolicy(); const registry = await getPluginRegistry().catch(() => ({ plugins: [] })); + const enabled = registry.plugins.filter((plugin) => plugin.enabled); + // Plugins that run in the mode with the servers the admin entered for + // them; their manifest-fixed servers (Google Safe Browsing) stay off. + const modePlugins = enabled.filter((plugin) => pluginDeclaresNetwork(plugin) && pluginAllowedInMode(plugin)); + const pluginHosts = await Promise.all(modePlugins.map(async (plugin) => ({ + id: plugin.id, + name: plugin.name, + origins: await adminEnteredOrigins(plugin.id).catch(() => [] as string[]), + fixed: [...new Set(Object.values((plugin as { credentials?: Record }).credentials ?? {}) + .flatMap((spec) => (spec.origin ? [new URL(spec.origin).host] : [])))], + }))); return NextResponse.json( { @@ -35,10 +48,13 @@ export async function GET(request: NextRequest) { readOnly: isConfigReadOnly(), envLocked: state.source === 'env', builtIn: OUTGOING_HOSTS, - reachable: listReachableHosts({ get: (key) => configManager.get(key), policy, env: process.env }), - networkPlugins: registry.plugins - .filter((plugin) => plugin.enabled && pluginDeclaresNetwork(plugin)) + reachable: listReachableHosts({ get: (key) => configManager.get(key), policy, env: process.env, plugins: pluginHosts }), + networkPlugins: enabled + .filter((plugin) => pluginDeclaresNetwork(plugin) && !pluginAllowedInMode(plugin)) .map((plugin) => ({ id: plugin.id, name: plugin.name })), + partlyOffPlugins: pluginHosts + .filter((plugin) => plugin.fixed.length > 0) + .map(({ id, name, fixed }) => ({ id, name, hosts: fixed })), }, { headers: { 'Cache-Control': 'no-store' } }, ); diff --git a/app/api/plugin-proxy/route.ts b/app/api/plugin-proxy/route.ts index 979eb4c26..c46f88640 100644 --- a/app/api/plugin-proxy/route.ts +++ b/app/api/plugin-proxy/route.ts @@ -3,8 +3,11 @@ import { configManager } from '@/lib/admin/config-manager'; import { isNoThirdPartyTrafficOn, thirdPartyRefusal } from '@/lib/admin/third-party-mode'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; -import { CREDENTIAL_ID_RE } from '@/lib/plugin-secrets/credentials'; -import { ProxyError, proxyRequest, type CaptureSpec } from '@/lib/plugin-secrets/proxy'; +import { CREDENTIAL_ID_RE, type CredentialSpec } from '@/lib/plugin-secrets/credentials'; +import { credentialAllowedInMode } from '@/lib/outgoing-hosts'; +import { getPlugin } from '@/lib/admin/plugin-registry'; +import { ProxyError, adminEnteredOrigin, proxyRequest, type CaptureSpec } from '@/lib/plugin-secrets/proxy'; +import { getPluginConfig } from '@/lib/admin/plugin-config'; import { bootGateResponse } from '@/lib/shared-store/boot-gate'; const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/; @@ -46,8 +49,7 @@ export async function POST(request: NextRequest) { if (!configManager.getPolicy().features.pluginsEnabled) { return NextResponse.json({ error: 'Plugins are turned off', code: 'disabled' }, { status: 403 }); } - // Every proxied request goes to a host outside this deployment (#903). - if (await isNoThirdPartyTrafficOn()) return thirdPartyRefusal('The plugin proxy'); + const thirdPartyOff = await isNoThirdPartyTrafficOn(); const creds = await getStalwartCredentials(request); if (!creds) return NextResponse.json({ error: 'Not authenticated', code: 'unauthenticated' }, { status: 401 }); @@ -59,6 +61,16 @@ export async function POST(request: NextRequest) { if (!PLUGIN_ID_RE.test(pluginId) || !CREDENTIAL_ID_RE.test(credentialId) || !url) { return NextResponse.json({ error: 'plugin, credential and url are required', code: 'bad_request' }, { status: 400 }); } + // With third-party traffic off only a server the admin typed on the + // plugin's admin page stays reachable (a company link check); servers + // fixed in a manifest or brought as a field's default, and accounts users + // connect, are outside hosts (#903). + if (thirdPartyOff) { + const plugin = await getPlugin(pluginId); + const spec = (plugin as { credentials?: Record } | null)?.credentials?.[credentialId]; + if (!credentialAllowedInMode(spec)) return thirdPartyRefusal('The plugin proxy'); + if (!adminEnteredOrigin(spec, await getPluginConfig(pluginId), plugin?.configSchema)) return thirdPartyRefusal('The plugin proxy'); + } const capture = parseCapture(request.headers.get('x-bulwark-capture')); if (capture === null) { return NextResponse.json({ error: 'Invalid X-Bulwark-Capture header', code: 'bad_capture' }, { status: 400 }); diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts index 39dd01460..8a91d2e4b 100644 --- a/app/api/plugins/route.ts +++ b/app/api/plugins/route.ts @@ -6,7 +6,7 @@ import { configManager } from '@/lib/admin/config-manager'; import { logger } from '@/lib/logger'; import { storeUnavailableResponse } from '@/lib/admin/store-unavailable-response'; import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode'; -import { pluginDeclaresNetwork } from '@/lib/outgoing-hosts'; +import { pluginAllowedInMode } from '@/lib/outgoing-hosts'; /** * GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes @@ -36,13 +36,14 @@ export async function GET(request: NextRequest) { const devIds = new Set(devEntries.map(e => e.plugin.id)); const installedEnabled = pluginRegistry.plugins.filter(p => p.enabled && !devIds.has(p.id)); // Plugins that declare network access never reach a browser while the - // server must stay off outside hosts (#903); the rest run as usual. + // server must stay off outside hosts (#903); the rest run as usual, and + // so do those that reach only servers the admin entered for them. const noThirdParty = await isNoThirdPartyTrafficOn(); const plugins = [ ...devEntries.map(e => ({ ...e.plugin, dev: true })), ...installedEnabled.map(p => ({ ...p, dev: false })), - ].filter(p => !(noThirdParty && pluginDeclaresNetwork(p))).map(p => ({ + ].filter(p => !noThirdParty || pluginAllowedInMode(p)).map(p => ({ id: p.id, name: p.name, version: p.version, diff --git a/components/__tests__/link-check-surfaces.test.tsx b/components/__tests__/link-check-surfaces.test.tsx new file mode 100644 index 000000000..0fafcac70 --- /dev/null +++ b/components/__tests__/link-check-surfaces.test.tsx @@ -0,0 +1,174 @@ +// 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(
https://bank.example
); + 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(
x
); + 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'); + }); +}); + +// What is checked is what opens: the host hands plugins the address as the +// browser parses it, and opens exactly that. +describe('one address end to end', () => { + it('checks and opens the address the browser really goes to', async () => { + const { seen, open } = watchLinks(); + render(
login
); + fireEvent.click(screen.getByRole('link')); + await waitFor(() => expect(open).toHaveBeenCalledWith('http://evil.example/@good.example/login', '_blank', 'noopener,noreferrer')); + expect(seen[0].href).toBe('http://evil.example/@good.example/login'); + }); + + it('hands over international names in their punycode form', async () => { + const { seen } = watchLinks(() => false); + expect(await resolveExternalLink({ href: 'https://b\u00fccher.example/a' })).toBeNull(); + expect(seen[0].href).toBe('https://xn--bcher-kva.example/a'); + }); +}); + +// Without a link check listening, clicks stay exactly as they were. +describe('no link check listening', () => { + it('leaves clicks and middle-clicks to the browser', () => { + const open = vi.spyOn(window, 'open').mockReturnValue(null); + render(
a
); + expect(fireEvent.click(screen.getByRole('link'))).toBe(true); + expect(fireEvent(screen.getByRole('link'), new MouseEvent('auxclick', { bubbles: true, cancelable: true, button: 1 }))).toBe(true); + expect(open).not.toHaveBeenCalled(); + }); + + it('opens at once, inside the click, from code that opens a link itself', () => { + const open = vi.spyOn(window, 'open').mockReturnValue(null); + void openExternalLink({ href: 'https://a.example/' }); + expect(open).toHaveBeenCalledWith('https://a.example/', '_blank', 'noopener,noreferrer'); + }); + + it("opens a plugin's own link at once when only that plugin listens", async () => { + const { dispatchApiCall } = await import('@/lib/plugin-sandbox/host-api'); + uiHooks.onBeforeExternalLink.register('link-check', () => false); + const open = vi.spyOn(window, 'open').mockReturnValue(null); + const call = dispatchApiCall({ id: 'link-check', name: 'x', version: '1', author: '', description: '', type: 'hook', permissions: [], entrypoint: 'index.js', enabled: true, status: 'running', settings: {} } as never, 'ui.openExternalUrl', ['https://a.example/']); + expect(open).toHaveBeenCalledTimes(1); + await call; + }); +}); diff --git a/components/admin/org/sections/third-party-section.tsx b/components/admin/org/sections/third-party-section.tsx index 9280d8b48..7dc8ec34b 100644 --- a/components/admin/org/sections/third-party-section.tsx +++ b/components/admin/org/sections/third-party-section.tsx @@ -18,6 +18,8 @@ interface OutgoingHostsState { builtIn: OutgoingHost[]; reachable: ReachableHost[]; networkPlugins: { id: string; name: string }[]; + /** Running in the mode with the servers entered on their admin page; these manifest servers stay off. */ + partlyOffPlugins?: { id: string; name: string; hosts: string[] }[]; } const SIDE_LABEL: Record = { @@ -146,6 +148,11 @@ export function ThirdPartySection({ draft }: { draft: OrgPolicyDraft }) { Not running because they declare network access: {state.networkPlugins.map((p) => p.name).join(', ')}. )} + {on && (state.partlyOffPlugins?.length ?? 0) > 0 && ( + + Running with only the servers entered on their admin page: {state.partlyOffPlugins!.map((p) => `${p.name} (${p.hosts.join(', ')} stays off)`).join('; ')}. + + )}

{on ? 'Still reachable' : 'Stays reachable with the switch on'}

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__/composer-send-check-payload.test.tsx b/components/email/__tests__/composer-send-check-payload.test.tsx new file mode 100644 index 000000000..ca7c7dc72 --- /dev/null +++ b/components/email/__tests__/composer-send-check-payload.test.tsx @@ -0,0 +1,429 @@ +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 { InstalledPlugin, OutgoingEmail } from '@/lib/plugin-types'; +import { setSendGuardSource } from '@/lib/plugin-send-guards'; + +// ─── 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[], size: 1, answers: [] as string[], uploads: [] as unknown[] })); +const sendPlugins = vi.hoisted(() => ({ + footer: false, + addBcc: '' as string, + 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 ──────────────────────────────────────────────────── + +const toasts = vi.hoisted(() => ({ error: [] as string[] })); +vi.mock('@/stores/toast-store', () => ({ + toast: { info: () => {}, error: (message: string) => { toasts.error.push(message); }, 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: { + get size() { return sendChecks.size; }, + intercept: async () => true, + interceptOutcome: async (email: unknown) => { sendChecks.seen.push(email); return sendChecks.answers.shift() ?? 'allowed'; }, + }, + onBeforeAttachmentUpload: { + size: 1, + intercept: async (info: unknown) => { sendChecks.uploads.push(info); return false; }, + }, + 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; bcc: string[] }) => { + const withBcc = sendPlugins.addBcc ? { ...email, bcc: [...email.bcc, sendPlugins.addBcc] } : email; + return sendPlugins.footer + ? { ...withBcc, htmlBody: `${withBcc.htmlBody}

Plugin footer

`, textBody: `${withBcc.textBody}\n\nPlugin footer` } + : withBcc; + }, + }, + }, + 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']); + }); +}); + +// 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]); + // Turned off after errors: waiting doesn't help, reloading may. + await waitFor(() => expect(toasts.error).toContain('stopped_working')); + expect(onSend).not.toHaveBeenCalled(); + expect(sendChecks.seen).toEqual([]); + }); + + it('gives a check that was still starting at the click the whole message once it runs', async () => { + let status = 'enabled'; + const listeners = new Set<() => void>(); + const guard = () => ({ + id: 'send-checks', name: 'Send checks', version: '1.0.0', permissions: ['email:send'], + status, enabled: true, forceEnabled: true, managed: true, + }) as unknown as InstalledPlugin; + setSendGuardSource({ snapshot: () => ({ plugins: [guard()], forced: () => true }), subscribe: (l) => { listeners.add(l); return () => listeners.delete(l); } }); + setPolicy([]); + // Nothing listens at the click; the check registers when it has started. + sendChecks.size = 0; + sendChecks.seen = []; + const onSend = vi.fn(); + render(); + fireEvent.click(screen.getAllByTestId('composer-send')[0]); + await new Promise((r) => setTimeout(r, 50)); + expect(onSend).not.toHaveBeenCalled(); + sendChecks.size = 1; + status = 'running'; + listeners.forEach((l) => l()); + await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + expect((sendChecks.seen[0] as OutgoingEmail).externalRecipients).toEqual(['carol@partner.test', 'mallory@evil.test']); + }); +}); + +// A plugin's onTransformOutgoingEmail ran after the check: an automatic Bcc +// to an outside address went out after an "outside only" rule had passed. +describe('composer: recipients a plugin adds after the check', () => { + beforeEach(() => { + sendChecks.size = 1; + sendChecks.seen = []; + sendChecks.answers = []; + setPolicy([]); + }); + afterEach(() => { + sendPlugins.addBcc = ''; + vi.clearAllMocks(); + }); + + it('are checked again, and a no then stops the send', async () => { + sendPlugins.addBcc = 'archive@outside.test'; + sendChecks.answers = ['allowed', 'vetoed']; + const onSend = vi.fn(); + render(); + fireEvent.click(screen.getAllByTestId('composer-send')[0]); + await waitFor(() => expect(sendChecks.seen).toHaveLength(2)); + expect((sendChecks.seen[0] as OutgoingEmail).externalRecipients).toEqual([]); + expect((sendChecks.seen[1] as OutgoingEmail).externalRecipients).toEqual(['archive@outside.test']); + await new Promise((r) => setTimeout(r, 50)); + expect(onSend).not.toHaveBeenCalled(); + }); + + it('are not asked about twice when nobody changed the recipients', async () => { + const onSend = vi.fn(); + render(); + fireEvent.click(screen.getAllByTestId('composer-send')[0]); + await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + expect(sendChecks.seen).toHaveLength(1); + }); +}); + +// A renamed program is refused before it is uploaded: the plugin learns +// what the file's first bytes say, not only its name. +describe('composer: attaching a file from this device', () => { + it('tells onBeforeAttachmentUpload what the file really is', async () => { + const { useAuthStore } = await import('@/stores/auth-store'); + // Any client method answers "nothing": only the hook matters here. + useAuthStore.setState({ client: new Proxy({}, { get: () => () => undefined }) as never }); + try { + sendChecks.uploads = []; + render(); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const program = new File([new Uint8Array([0x4d, 0x5a, 0x90, 0, 3, 0])], 'invoice.pdf', { type: 'application/pdf' }); + fireEvent.change(input, { target: { files: [program] } }); + await waitFor(() => expect(sendChecks.uploads).toHaveLength(1)); + expect(sendChecks.uploads[0]).toEqual({ name: 'invoice.pdf', type: 'application/pdf', size: 6, sniffedType: 'application/x-msdownload' }); + } finally { + useAuthStore.setState({ client: null } as never); + } + }); +}); diff --git a/components/email/__tests__/thread-conversation-link-gate.test.tsx b/components/email/__tests__/thread-conversation-link-gate.test.tsx index 00bef1b12..4499d6dbf 100644 --- a/components/email/__tests__/thread-conversation-link-gate.test.tsx +++ b/components/email/__tests__/thread-conversation-link-gate.test.tsx @@ -26,8 +26,14 @@ const { clearAllHooks, uiHooks } = await import('@/lib/plugin-hooks'); const BODY = '

hi

webapp'; -function renderBody() { - const email = { +function renderBody(plain?: string) { + const email = plain ? { + id: 'e1', threadId: 't1', mailboxIds: { a: true }, keywords: { $seen: true }, + from: [{ name: 'Sender', email: 'sender@t.example' }], to: [{ email: 'me@example.org' }], + subject: 's', receivedAt: '2026-09-26T10:00:00Z', size: 1, preview: 'hi', hasAttachment: false, + htmlBody: [{ partId: '1', type: 'text/plain' }], textBody: [{ partId: '1', type: 'text/plain' }], attachments: [], + bodyValues: { '1': { value: plain } }, + } : { id: 'e1', threadId: 't1', mailboxIds: { a: true }, keywords: { $seen: true }, from: [{ name: 'Sender', email: 'sender@t.example' }], to: [{ email: 'me@example.org' }], subject: 's', receivedAt: '2026-09-26T10:00:00Z', size: 1, preview: 'hi', hasAttachment: false, @@ -41,6 +47,7 @@ function renderBody() { onBack={() => {}} />, ); + if (plain) return container as unknown as Document; const iframe = container.querySelector('iframe')!; // jsdom does not load srcdoc: put the sanitised body in by hand. const srcdoc = iframe.getAttribute('srcdoc') ?? ''; @@ -75,6 +82,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); @@ -87,3 +107,25 @@ describe('conversation view link gate', () => { expect(open).not.toHaveBeenCalled(); }, 30_000); }); + +describe('conversation view link gate, more paths', () => { + it('sends a link in a plain-text message through the check', async () => { + const open = vi.spyOn(window, 'open').mockReturnValue(null); + const hook = vi.fn(() => false); + uiHooks.onBeforeExternalLink.register('p1', hook); + const root = renderBody('Pay at https://pay.example/x now'); + const link = root.querySelector('[data-plugin-source="email-body"] a')!; + expect(click(link)).toBe(true); + await waitFor(() => expect(hook).toHaveBeenCalledWith(expect.objectContaining({ href: 'https://pay.example/x', emailId: 'e1' }))); + expect(open).not.toHaveBeenCalled(); + }, 30_000); + + it('leaves a middle-click to the browser while no link check listens', async () => { + const open = vi.spyOn(window, 'open').mockReturnValue(null); + const doc = renderBody(); + const ev = new MouseEvent('auxclick', { bubbles: true, cancelable: true, button: 1 }); + doc.getElementById('web')!.dispatchEvent(ev); + expect(ev.defaultPrevented).toBe(false); + expect(open).not.toHaveBeenCalled(); + }, 30_000); +}); 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/__tests__/viewer-link-gate.test.tsx b/components/email/__tests__/viewer-link-gate.test.tsx new file mode 100644 index 000000000..6e720f721 --- /dev/null +++ b/components/email/__tests__/viewer-link-gate.test.tsx @@ -0,0 +1,84 @@ +// The reader's link check: links in a plain-text mail render in the page, +// not in the body iframe, and opened without the check; a middle-click in +// the iframe opened the link in a new tab without it too. With no link +// check listening, a click stays the browser's. +import { render, act, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { clearAllHooks, pluginErrorTracker, uiHooks } from '@/lib/plugin-hooks'; +import type { ExternalLinkContext } from '@/lib/plugin-types'; + +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, +})); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), +})); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); + +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +vi.stubGlobal('ResizeObserver', NoopResizeObserver); + +const { EmailViewer } = await import('../email-viewer'); + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +const message = (type: 'text/plain' | 'text/html', value: string) => ({ + id: 'm1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'Sender', email: 'sender@other.test' }], to: [{ email: 'me@example.org' }], + subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100, + textBody: [{ partId: '1', type }], htmlBody: [{ partId: '1', type }], attachments: [], + bodyValues: { '1': { value } }, +}) as unknown as Email; + +async function open(email: Email) { + const view = render(); + await act(async () => { await new Promise((r) => setTimeout(r, 10)); }); + return view; +} + +function watch(answer: unknown) { + const seen: ExternalLinkContext[] = []; + uiHooks.onBeforeExternalLink.register('link-check', (ctx: ExternalLinkContext) => { seen.push(ctx); return answer; }); + return seen; +} + +let open$: ReturnType; +beforeEach(() => { + clearAllHooks(); + pluginErrorTracker.resetAll(); + open$ = vi.spyOn(window, 'open').mockReturnValue(null); +}); +afterEach(() => vi.restoreAllMocks()); + +describe('plain-text mail', () => { + it('sends a link click through the link check, with the message and the address the browser opens', async () => { + const seen = watch(false); + const { container } = await open(message('text/plain', 'Log in at http://evil.example\\@good.example/login today')); + const link = container.querySelector('[data-plugin-source="email-body"] a') as HTMLAnchorElement; + expect(link).not.toBeNull(); + expect(fireEvent.click(link)).toBe(false); + await waitFor(() => expect(seen).toHaveLength(1)); + expect(seen[0]).toMatchObject({ href: 'http://evil.example/@good.example/login', emailId: 'm1' }); + expect(open$).not.toHaveBeenCalled(); + }); + + it('leaves the click to the browser while no link check listens', async () => { + const { container } = await open(message('text/plain', 'See https://site.example/a')); + const link = container.querySelector('[data-plugin-source="email-body"] a') as HTMLAnchorElement; + expect(fireEvent.click(link)).toBe(true); + expect(open$).not.toHaveBeenCalled(); + }); +}); + +// The body iframe's middle-click (jsdom doesn't load srcdoc, so the viewer +// never wires it here) is covered by the conversation view's test of the +// same handler and by integration/tests/22-ent-plugins.spec.ts. diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 9864a3cce..4efbbf971 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 { recipientsChanged, runPluginSendCheck, sendGuardMessageKey } 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"; @@ -487,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 - @@ -2220,10 +2223,12 @@ export function EmailComposer({ // Let plugins veto each upload before it's queued. const allowedFiles: File[] = []; for (const file of files) { + const sniffed = emailHooks.onBeforeAttachmentUpload.size > 0 ? await sniffFile(file) : {}; const ok = await emailHooks.onBeforeAttachmentUpload.intercept({ name: file.name, type: file.type || 'application/octet-stream', size: file.size, + ...sniffed, }); if (ok) allowedFiles.push(file); } @@ -3608,23 +3613,50 @@ 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. - const sendablePreview: OutgoingEmail = { + // 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 + // after waiting for required checks that are still starting, and only + // while a plugin listens. + 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'))); + // What is checked is what goes: a file added while the check waits or + // asks isn't sent with this message. + const sentAttachments = attachmentsRef.current.filter(att => att.blobId && !att.uploading && !att.error); + const recipientsAtCheck = { to: toAddresses.map(r => formatRecipient(r.name, r.email)), cc: ccAddresses.map(r => formatRecipient(r.name, r.email)), bcc: bccAddresses.map(r => formatRecipient(r.name, r.email)), + }; + const buildCheck = async (recipients: { to: string[]; cc: string[]; bcc: string[] }): Promise => ({ + ...recipients, subject, htmlBody: finalHtmlBody || '', 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: 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), + })), + ]), inReplyTo: threadingHeaders?.inReplyTo?.[0], - }; - const sendCheck = await emailHooks.onBeforeEmailSend.interceptOutcome(sendablePreview); + surface: 'composer', + mode, + authoredText: extractUserAuthoredText( + plainTextMode ? plainTextBodyWithoutSignature(body, signatureIdentity) : stripEmbeddedSignatureHtml(body), + { plainTextMode, forwardedSeparator: tQuote('forwarded_separator') }, + ), + externalRecipients: await externalRecipientsFor({ ...outgoingContext, ...recipients }), + }); + const sendCheck = await runPluginSendCheck(() => buildCheck(recipientsAtCheck)); 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). @@ -3632,6 +3664,8 @@ 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')); + const guardKey = sendGuardMessageKey(sendCheck); + if (guardKey) toast.error(tSendGuards(guardKey)); return; } @@ -3667,8 +3701,7 @@ export function EmailComposer({ references: threadingHeaders?.references, delayedUntil: effectiveDelayedUntil, attachments: [ - ...attachmentsRef.current - .filter(att => att.blobId && !att.uploading && !att.error) + ...sentAttachments .map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size, blobId: a.blobId, cid: a.cid })), ...inlineAttachments.map(a => ({ name: a.name, type: a.type, size: a.size, blobId: a.blobId, cid: a.cid })), ], @@ -3700,8 +3733,7 @@ export function EmailComposer({ } else { // Standard JMAP send path // Collect uploaded attachment blobIds for the send request - const uploadedAttachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> = attachmentsRef.current - .filter(att => att.blobId && !att.uploading && !att.error) + const uploadedAttachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> = sentAttachments .map(att => ({ blobId: att.blobId!, name: att.name, @@ -3729,6 +3761,20 @@ export function EmailComposer({ inReplyTo: threadingHeaders?.inReplyTo?.[0], }; const outgoing = await emailHooks.onTransformOutgoingEmail.transform(transformInput); + // A plugin that adds or changes recipients after the send checks + // (an automatic Bcc) could take the message outside after an + // "only outside the organisation" rule passed: check it again for + // the recipients it really goes to. + if (recipientsChanged(recipientsAtCheck, outgoing)) { + const recheck = await runPluginSendCheck(() => buildCheck({ to: outgoing.to ?? [], cc: outgoing.cc ?? [], bcc: outgoing.bcc ?? [] })); + if (recheck !== 'allowed') { + debug.log('email', `Send stopped on the second check after a plugin changed the recipients (${recheck})`); + if (recheck === 'failed') toast.error(t('send_guard_failed')); + const recheckKey = sendGuardMessageKey(recheck); + if (recheckKey) toast.error(tSendGuards(recheckKey)); + return; + } + } // Below whatever the plugins added (a footer), the `last` finalisers. const sealedOutgoing = lastStage(outgoing.htmlBody, outgoing.textBody); // The label after the plugins had their say, so none can drop it. diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index ae8b25a55..9ce0a7f27 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 { gateLinkClick, linkCheckActive, 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,10 @@ 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, through the link check while one + // listens; other buttons are left alone. + if (ev.type === 'auxclick' && (ev.button !== 1 || !linkCheckActive())) 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 +3570,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 +3579,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); @@ -5798,6 +5803,10 @@ export function EmailViewer({ data-plugin-source="email-body" data-email-id={email?.id} onContextMenu={(e) => selectionMenu.onElementContextMenu(e, email?.id)} + // A plain-text body renders in the page, not the iframe: its + // links go through the link check here. + onClick={(e) => gateLinkClick(e, { emailId: email?.id })} + onAuxClick={(e) => gateLinkClick(e, { emailId: email?.id })} dangerouslySetInnerHTML={plainBodyHtml} style={{ ...(plainTextFont === 'mono' && { fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace' }), 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 }); }} >