diff --git a/components/email/__tests__/plugin-render-once.test.tsx b/components/email/__tests__/plugin-render-once.test.tsx new file mode 100644 index 000000000..9c70539cd --- /dev/null +++ b/components/email/__tests__/plugin-render-once.test.tsx @@ -0,0 +1,224 @@ +// A crypto plugin's render can be a key operation: with a smart card a dialog, and two +// PIN cancels in a row lock the key. So the render hook runs once per message, not once +// per object: mark-read replaces the object and changes nothing the hook gets to see. +// And a plugin can keep external content off for a body it can't vouch for (EFAIL). +import { act, render, renderHook, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { useSettingsStore } from '@/stores/settings-store'; + +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, + useIsDesktop: () => true, +})); +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 { renderHooks, clearAllHooks } = await import('@/lib/plugin-hooks'); +const { usePluginRenderedBody, pluginRenderKey } = await import('@/hooks/use-plugin-rendered-body'); +const { EmailViewer } = await import('../email-viewer'); +const { ConversationEmailBody } = await import('../thread-conversation-view'); + +const REMOTE = 'https://tracker.example/x.png'; +// The blocker keeps the address in an inert attribute, for "Load images". What matters is that it isn't a live one. +const LOADS_REMOTE = /]*\ssrc="https:\/\/tracker/; +const mailboxes = [{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }] as unknown as Mailbox[]; + +const message = (over: Partial = {}) => ({ + id: 'e1', + blobId: 'b1', + threadId: 't1', + mailboxIds: { inbox: true }, + keywords: {}, + from: [{ name: 'Alice', email: 'alice@example.com' }], + to: [{ name: 'Bob', email: 'bob@example.com' }], + subject: 'Hello', + receivedAt: '2026-01-01T00:00:00Z', + preview: 'Hello', + hasAttachment: false, + size: 100, + textBody: [{ partId: '1', type: 'text/plain' }], + htmlBody: [], + attachments: [], + bodyStructure: { type: 'application/pkcs7-mime' }, + headers: { 'content-type': 'application/pkcs7-mime; smime-type=enveloped-data' }, + bodyValues: { '1': { value: 'Hello Bob', isTruncated: false } }, + ...over, +}) as unknown as Email; + +const renders = () => { + const handler = vi.fn(async () => ({ html: '', text: 'Decrypted', attachments: [], handledBy: 'smime' })); + renderHooks.onRenderEmailBody.register('smime', handler as never); + return handler; +}; + +const openViewer = (email: Email) => ; + +const originalDelay = useSettingsStore.getState().markAsReadDelay; +const originalPolicy = useSettingsStore.getState().externalContentPolicy; + +beforeEach(() => { + useSettingsStore.setState({ markAsReadDelay: -1 }); +}); + +afterEach(() => { + clearAllHooks(); + useSettingsStore.setState({ markAsReadDelay: originalDelay, externalContentPolicy: originalPolicy }); +}); + +describe('pluginRenderKey', () => { + it('ignores what the hook does not get: keywords and mailboxes', () => { + expect(pluginRenderKey(message({ keywords: { $seen: true } }))).toBe(pluginRenderKey(message())); + expect(pluginRenderKey(message({ mailboxIds: { archive: true } }))).toBe(pluginRenderKey(message())); + }); + + it('changes with the message and its blob', () => { + expect(pluginRenderKey(message({ id: 'e2' }))).not.toBe(pluginRenderKey(message())); + expect(pluginRenderKey(message({ blobId: 'b2' }))).not.toBe(pluginRenderKey(message())); + }); + + it('changes when the list row becomes the full message, which is what the hook decides on', () => { + const row = message({ headers: undefined, bodyStructure: undefined, bodyValues: undefined }); + expect(pluginRenderKey(message())).not.toBe(pluginRenderKey(row)); + }); + + it('changes when a truncated body arrives whole', () => { + const whole = message({ bodyValues: { '1': { value: 'Hello Bob, and the rest', isTruncated: false } } as never }); + expect(pluginRenderKey(whole)).not.toBe(pluginRenderKey(message())); + }); +}); + +describe('usePluginRenderedBody', () => { + it('runs the hook once when mark-read replaces the message object', async () => { + const handler = renders(); + const { result, rerender } = renderHook(({ email }) => usePluginRenderedBody(email), { initialProps: { email: message() } }); + await waitFor(() => expect(result.current.text).toBe('Decrypted')); + rerender({ email: message({ keywords: { $seen: true } }) }); + await act(async () => {}); + expect(handler).toHaveBeenCalledTimes(1); + expect(result.current.text).toBe('Decrypted'); + }); + + it('runs it again for an asked-for render, and for another message', async () => { + const handler = renders(); + const { rerender } = renderHook(({ email }) => usePluginRenderedBody(email), { initialProps: { email: message() } }); + await waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); + await act(async () => { window.dispatchEvent(new CustomEvent('plugin:rerender-email')); }); + await waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); + rerender({ email: message({ id: 'e2', blobId: 'b2' }) }); + await waitFor(() => expect(handler).toHaveBeenCalledTimes(3)); + }); + + it('says when the plugin wants external content off', async () => { + renderHooks.onRenderEmailBody.register('smime', (async () => ({ + html: '

Body

', text: '', attachments: [], handledBy: 'smime', blockExternalContent: true, + })) as never); + const { result } = renderHook(() => usePluginRenderedBody(message())); + await waitFor(() => expect(result.current.blockExternal).toBe(true)); + }); + + it('leaves it to the settings otherwise', async () => { + const handler = renders(); + const { result } = renderHook(() => usePluginRenderedBody(message())); + await waitFor(() => expect(handler).toHaveBeenCalled()); + await waitFor(() => expect(result.current.text).toBe('Decrypted')); + expect(result.current.blockExternal).toBe(false); + }); +}); + +describe('EmailViewer', () => { + it('runs the hook once when mark-read replaces the message object', async () => { + const handler = renders(); + const { rerender } = render(openViewer(message())); + await screen.findByText('Decrypted'); + rerender(openViewer(message({ keywords: { $seen: true } }))); + await act(async () => {}); + expect(handler).toHaveBeenCalledTimes(1); + expect(screen.getByText('Decrypted')).toBeTruthy(); + }, 30_000); + + it('runs it again when the list row becomes the full message', async () => { + const handler = renders(); + const { rerender } = render(openViewer(message({ headers: undefined, bodyStructure: undefined }))); + await waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); + rerender(openViewer(message())); + await waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); + }, 30_000); + + it('runs it again for an asked-for render', async () => { + const handler = renders(); + render(openViewer(message())); + await waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); + await act(async () => { window.dispatchEvent(new CustomEvent('plugin:rerender-email')); }); + await waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); + }, 30_000); + + const remoteBody = (blockExternalContent?: boolean) => { + renderHooks.onRenderEmailBody.register('smime', (async () => ({ + html: `

Secret body

`, text: '', attachments: [], handledBy: 'smime', + ...(blockExternalContent ? { blockExternalContent } : {}), + })) as never); + }; + const frameDoc = (container: HTMLElement) => container.querySelector('iframe')?.getAttribute('srcdoc') ?? ''; + + it('keeps external content off when the plugin says so, whatever the settings allow', async () => { + useSettingsStore.setState({ externalContentPolicy: 'allow' }); + remoteBody(true); + const { container } = render(openViewer(message())); + await waitFor(() => expect(frameDoc(container)).toContain('Secret body')); + expect(frameDoc(container)).not.toMatch(LOADS_REMOTE); + }, 30_000); + + it('offers no way to load it then', async () => { + useSettingsStore.setState({ externalContentPolicy: 'ask' }); + remoteBody(true); + const { container } = render(openViewer(message())); + await waitFor(() => expect(frameDoc(container)).toContain('Secret body')); + await act(async () => {}); + expect(screen.queryByTestId('external-content-notice')).toBeNull(); + }, 30_000); + + it('follows the settings for any other plugin body', async () => { + useSettingsStore.setState({ externalContentPolicy: 'allow' }); + remoteBody(); + const { container } = render(openViewer(message())); + await waitFor(() => expect(frameDoc(container)).toContain('Secret body')); + expect(frameDoc(container)).toMatch(LOADS_REMOTE); + }, 30_000); +}); + +describe('ConversationEmailBody', () => { + const body = (allowExternal: boolean, onAllowExternal = () => {}) => ( + + ); + + it('keeps external content off when the plugin says so, even with it allowed', async () => { + renderHooks.onRenderEmailBody.register('smime', (async () => ({ + html: `

Secret body

`, text: '', attachments: [], handledBy: 'smime', blockExternalContent: true, + })) as never); + const { container } = render(body(true)); + await waitFor(() => expect(container.querySelector('iframe')?.getAttribute('srcdoc') ?? '').toContain('Secret body')); + expect(container.querySelector('iframe')?.getAttribute('srcdoc')).not.toMatch(LOADS_REMOTE); + }, 30_000); + + it('has no button to load it', async () => { + renderHooks.onRenderEmailBody.register('smime', (async () => ({ + html: `

Secret body

`, text: '', attachments: [], handledBy: 'smime', blockExternalContent: true, + })) as never); + const { container } = render(body(false)); + await waitFor(() => expect(container.querySelector('iframe')?.getAttribute('srcdoc') ?? '').toContain('Secret body')); + await act(async () => {}); + expect(screen.queryByRole('button', { name: /load_external_content|load/i })).toBeNull(); + }, 30_000); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index e75a260ca..4f431dd60 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -127,6 +127,7 @@ import type { Attachment as PostalMimeAttachment } from 'postal-mime'; import { useSettingsStore } from "@/stores/settings-store"; import { useShallow } from "zustand/react/shallow"; import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout"; +import { pluginRenderKey } from "@/hooks/use-plugin-rendered-body"; import { registerComposeFlush } from "@/lib/compose-flush-registry"; import { useExternalRecipientConfirmStore } from "@/stores/external-recipient-confirm-store"; import { useUIStore } from "@/stores/ui-store"; @@ -1784,6 +1785,9 @@ export function EmailViewer({ const [pluginRenderedHtml, setPluginRenderedHtml] = useState(null); const [pluginRenderedText, setPluginRenderedText] = useState(null); const [pluginRenderedAttachments, setPluginRenderedAttachments] = useState(NO_POSTAL_ATTACHMENTS); + // The plugin can't vouch for its body (mail with no integrity protection): external + // content stays off, whatever the settings or a trusted sender allow. + const [pluginBlocksExternal, setPluginBlocksExternal] = useState(false); // Bumped when a plugin calls `api.ui.rerenderEmail` (e.g. the S/MIME plugin // after the user unlocks a key from the banner) to force the onRenderEmailBody // hook to run again for the open message so the body re-decrypts. @@ -2256,6 +2260,7 @@ export function EmailViewer({ setPluginRenderedHtml(null); setPluginRenderedText(null); setPluginRenderedAttachments(NO_POSTAL_ATTACHMENTS); + setPluginBlocksExternal(false); setTnefHtml(null); setTnefText(null); setTnefAttachments(NO_TNEF_ATTACHMENTS); @@ -2277,6 +2282,7 @@ export function EmailViewer({ setPluginRenderedHtml(null); setPluginRenderedText(null); setPluginRenderedAttachments(NO_POSTAL_ATTACHMENTS); + setPluginBlocksExternal(false); return; } let cancelled = false; @@ -2318,14 +2324,17 @@ export function EmailViewer({ text?: string; attachments?: Array<{ name?: string; type?: string; size?: number; dataUrl?: string; cid?: string }>; handledBy?: string; + blockExternalContent?: boolean; }; if (cancelled) return; if (!result || !result.handledBy) { setPluginRenderedHtml(null); setPluginRenderedText(null); setPluginRenderedAttachments(NO_POSTAL_ATTACHMENTS); + setPluginBlocksExternal(false); return; } + setPluginBlocksExternal(result.blockExternalContent === true); setPluginRenderedHtml(typeof result.html === 'string' && result.html ? result.html : null); setPluginRenderedText(typeof result.text === 'string' && result.text ? result.text : null); // Normalise the plugin's attachment shape into the PostalMime-like shape @@ -2344,11 +2353,15 @@ export function EmailViewer({ setPluginRenderedHtml(null); setPluginRenderedText(null); setPluginRenderedAttachments(NO_POSTAL_ATTACHMENTS); + setPluginBlocksExternal(false); } })(); return () => { cancelled = true; }; - }, [email, pluginRenderNonce]); + // Once per message, not per object: a render can be a key operation, and mark-read + // replaces the object. pluginRenderKey says what should run this again. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pluginRenderKey(email), pluginRenderNonce]); // TNEF (winmail.dat) detection and processing useEffect(() => { @@ -2694,7 +2707,7 @@ export function EmailViewer({ // 'ask' = block until the user allows this message or trusts the sender. const shouldBlockExternal = useMemo(() => { if (!email) return false; - if (remoteHeldBack) return true; + if (remoteHeldBack || pluginBlocksExternal) return true; const senderEmail = email.from?.[0]?.email?.toLowerCase(); const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail)) @@ -2707,7 +2720,7 @@ export function EmailViewer({ // permission changes, so they're deliberately omitted from deps (matches // the srcDoc rebuild contract described on `emailContent`). // eslint-disable-next-line react-hooks/exhaustive-deps - }, [email, externalContentPolicy, allowExternalContent, remoteHeldBack]); + }, [email, externalContentPolicy, allowExternalContent, remoteHeldBack, pluginBlocksExternal]); // Reported as phishing ($phishing): a banner, its links turned off and // no cards (a phishing mail's "booking" is the lure). @@ -5674,7 +5687,7 @@ ${printWatermark ? printWatermark.html : ''} {/* Unified Notification Banner - External Content + Calendar Invitation + Read Receipt */} {(senderVerification || - (!plainView && hasBlockedContent && (remoteBlockedByOrg || (!allowExternalContent && externalContentPolicy !== 'allow'))) || + (!plainView && !pluginBlocksExternal && hasBlockedContent && (remoteBlockedByOrg || (!allowExternalContent && externalContentPolicy !== 'allow'))) || hasCalendarInvitation || (readReceiptResponse === 'ask' && shouldOfferReadReceipt) || structuredCards.length > 0) && ( @@ -5707,7 +5720,8 @@ ${printWatermark ? printWatermark.html : ''} {/* External Content Controls */} {/* shouldBlockExternal: gone once trust applies (e.g. the policy finished loading after the first paint). */} - {!plainView && hasBlockedContent && shouldBlockExternal && (remoteBlockedByOrg || (!allowExternalContent && externalContentPolicy !== 'allow')) && ( + {/* A plugin's own banner says why it keeps external content off, and there's nothing to load. */} + {!plainView && !pluginBlocksExternal && hasBlockedContent && shouldBlockExternal && (remoteBlockedByOrg || (!allowExternalContent && externalContentPolicy !== 'allow')) && (
diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 98e5b407c..eca991274 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -549,7 +549,7 @@ interface ConversationEmailBodyProps { */ export function ConversationEmailBody({ email, - allowExternal, + allowExternal: externalAllowed, onAllowExternal, onTrustSender, onDownloadAttachment, @@ -602,6 +602,8 @@ export function ConversationEmailBody({ const cidBlobUrls = useMemo(() => cidResult?.request === cidRequest ? cidResult.urls : {}, [cidResult, cidRequest]); // A crypto plugin's decrypted body (PGP, S/MIME) replaces the message's own. const pluginBody = usePluginRenderedBody(email); + // The plugin can't vouch for its body: external content stays off, whatever was allowed. + const allowExternal = externalAllowed && !pluginBody.blockExternal; // reader-above-quote: the body the quote was collapsed in, once it is on screen. const [htmlBodyEl, setHtmlBodyEl] = useState(null); const [plainBodyEl, setPlainBodyEl] = useState(null); @@ -930,7 +932,7 @@ export function ConversationEmailBody({
{phishingMarked && } {/* External content warning */} - {hasBlockedContent && !allowExternal && emailContent.isHtml && ( + {hasBlockedContent && !allowExternal && !pluginBody.blockExternal && emailContent.isHtml && (
{remoteBlockedByOrg ? t("third_party.remote_content_blocked") : t("email_viewer.external_content_warning")} diff --git a/hooks/use-plugin-rendered-body.ts b/hooks/use-plugin-rendered-body.ts index 34bdce248..0bc4a7756 100644 --- a/hooks/use-plugin-rendered-body.ts +++ b/hooks/use-plugin-rendered-body.ts @@ -13,9 +13,34 @@ export interface PluginRenderedBody { text: string | null; /** blob: URLs for the decrypted inline parts, keyed by Content-ID without brackets. */ cidUrls: Record; + /** The plugin can't vouch for this body: external content stays off, whatever the settings allow. */ + blockExternal: boolean; } -const EMPTY: PluginRenderedBody = { html: null, text: null, cidUrls: {} }; +const EMPTY: PluginRenderedBody = { html: null, text: null, cidUrls: {}, blockExternal: false }; + +/** + * What onRenderEmailBody gets to see, for its effect's dependencies. A render can be a + * key operation (a smart card's PIN dialog, and two cancels in a row lock the key), so + * it runs once per message, not per object: mark-read replaces the object and changes + * only keywords. The list row and then the full message do differ here: a row has no + * headers or body structure, which is what a plugin recognises its mail by. A plugin + * that wants to render again for another reason has api.ui.rerenderEmail. + */ +export function pluginRenderKey(email: Email | null | undefined): string | null { + if (!email) return null; + const contentType = email.headers?.['content-type'] ?? email.headers?.['Content-Type'] ?? ''; + const bodyLengths = Object.values(email.bodyValues ?? {}).map((part) => part.value?.length ?? 0); + return JSON.stringify([ + email.sourceClientAccountId ?? '', + email.id, + email.blobId ?? '', + contentType, + email.bodyStructure ? 1 : 0, + email.bodyValues ? bodyLengths : null, + email.attachments?.length ?? null, + ]); +} function dataUrlToBytes(dataUrl: string): Uint8Array | null { try { @@ -39,7 +64,8 @@ function dataUrlToBytes(dataUrl: string): Uint8Array | null { * Asks the onRenderEmailBody hook for a replacement body, the way the single * reader does, so a message a PGP or S/MIME plugin decrypts also shows * decrypted inside a conversation (#902). Re-runs when a plugin calls - * api.ui.rerenderEmail, e.g. after the user unlocked a key. + * api.ui.rerenderEmail, e.g. after the user unlocked a key, and not when only the + * object changes (see pluginRenderKey). */ export function usePluginRenderedBody(email: Email): PluginRenderedBody { const [body, setBody] = useState(EMPTY); @@ -79,6 +105,7 @@ export function usePluginRenderedBody(email: Email): PluginRenderedBody { text?: string; attachments?: Array<{ name?: string; type?: string; dataUrl?: string; cid?: string }>; handledBy?: string; + blockExternalContent?: boolean; }; if (cancelled) return; if (!result || !result.handledBy) { @@ -100,6 +127,7 @@ export function usePluginRenderedBody(email: Email): PluginRenderedBody { html: typeof result.html === 'string' && result.html ? result.html : null, text: typeof result.text === 'string' && result.text ? result.text : null, cidUrls, + blockExternal: result.blockExternalContent === true, }); } catch (err) { if (cancelled) return; @@ -112,7 +140,9 @@ export function usePluginRenderedBody(email: Email): PluginRenderedBody { cancelled = true; objectUrls.forEach((url) => URL.revokeObjectURL(url)); }; - }, [email, nonce]); + // The latest message is read inside; see pluginRenderKey for what should run this again. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pluginRenderKey(email), nonce]); return body; } diff --git a/lib/__tests__/plugin-keystore.test.ts b/lib/__tests__/plugin-keystore.test.ts index 3a1426d92..6820a7858 100644 --- a/lib/__tests__/plugin-keystore.test.ts +++ b/lib/__tests__/plugin-keystore.test.ts @@ -196,6 +196,17 @@ describe('api.host.features.keystore', () => { }); }); +describe('api.host.features.blockExternalContent', () => { + it('is there for a plugin that takes over rendering', () => { + expect(snapshotHostFeatures(plugin({ permissions: ['email:render-takeover'] })).blockExternalContent).toBe(true); + }); + + it('is absent for any other, and without a plugin', () => { + expect(snapshotHostFeatures(plugin({ permissions: ['email:read'] }))).not.toHaveProperty('blockExternalContent'); + expect(snapshotHostFeatures()).not.toHaveProperty('blockExternalContent'); + }); +}); + describe('the manifest', () => { const BASE = { id: 'keystore-test', name: 'P', version: '1.0.0', author: 'a', description: 'd', type: 'hook' }; diff --git a/lib/desktop/keystore-grants.ts b/lib/desktop/keystore-grants.ts new file mode 100644 index 000000000..0a7ad76a4 --- /dev/null +++ b/lib/desktop/keystore-grants.ts @@ -0,0 +1,11 @@ +import { getDesktopBridge } from './bridge'; + +/** + * Takes back what the user allowed in this session's certificate dialogs: the next + * account at this computer gets asked. A refusal stays (it lasts until the app quits), + * and so does a stored yes for an instance, which is about the instance and not the + * person. Nothing happens in a browser or on a shell without the call. + */ +export function clearKeystoreGrants(): void { + void Promise.resolve(getDesktopBridge()?.keystore?.clearGrants?.()).catch(() => {}); +} diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index 5c60da8bf..e577f2036 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -180,6 +180,8 @@ export interface DesktopKeystoreBridge { accessState: () => Promise; /** Takes back a stored yes and this session's grants. Only ever narrows. */ revokeAccess: () => Promise; + /** Takes back this session's grants, for the next account. Absent on older shells. */ + clearGrants?: () => Promise; } export interface BulwarkDesktopBridge { diff --git a/lib/plugin-sandbox/host-features.ts b/lib/plugin-sandbox/host-features.ts index 50deeb4eb..bdc8b1840 100644 --- a/lib/plugin-sandbox/host-features.ts +++ b/lib/plugin-sandbox/host-features.ts @@ -15,6 +15,7 @@ import type { HostFeaturesView, InstalledPlugin } from '../plugin-types'; import type { OrganisationPolicy } from '@/lib/admin/organisation-policy'; import { assistantFeatures } from './assistant'; import { keystoreFeature } from './host-api-keystore'; +import { pluginHasPermission } from './permissions'; /** The external-mail-warning plugin, retired in favour of organisation.externalMail. */ export const EXTERNAL_MAIL_WARNING_PLUGIN_ID = 'external-mail-warning'; @@ -34,6 +35,7 @@ export function snapshotHostFeatures(plugin?: InstalledPlugin): HostFeaturesView // The AI assistant's host APIs this webmail offers (lib/plugin-sandbox/assistant). ...assistantFeatures(plugin), ...keystoreFeature(plugin), + ...(plugin && pluginHasPermission(plugin, 'email:render-takeover') ? { blockExternalContent: true } : {}), }; } diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 520c594d8..c72ebd2d7 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -986,6 +986,12 @@ export interface HostFeaturesView extends AssistantHostFeatures { * Absent otherwise. See `api.keystore`. */ keystore?: boolean; + /** + * onRenderEmailBody may return `blockExternalContent: true` to keep external content + * off for that body, whatever the settings and trusted senders allow. Per plugin, for + * those with `email:render-takeover`. Absent on webmails that ignore the field. + */ + blockExternalContent?: boolean; } /** One setting `api.app.searchSettings` found, in the user's language. */ diff --git a/stores/__tests__/auth-store-logout-hooks.test.ts b/stores/__tests__/auth-store-logout-hooks.test.ts index 810e90e93..5deaf2b14 100644 --- a/stores/__tests__/auth-store-logout-hooks.test.ts +++ b/stores/__tests__/auth-store-logout-hooks.test.ts @@ -33,6 +33,47 @@ describe('sign-out with plugins', () => { expect(after).toHaveBeenCalledWith({ accountId: 'all' }); }); + describe('keys in the OS store', () => { + afterEach(() => { delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; }); + const withBridge = (clearGrants?: () => Promise) => { + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, keystore: { clearGrants } }; + }; + + it('takes back this session\'s certificate grants at sign-out', async () => { + const clearGrants = vi.fn().mockResolvedValue(undefined); + withBridge(clearGrants); + await useAuthStore.getState().logout(); + expect(clearGrants).toHaveBeenCalledTimes(1); + }); + + it('does so when all accounts sign out too', async () => { + const clearGrants = vi.fn().mockResolvedValue(undefined); + withBridge(clearGrants); + await useAuthStore.getState().logoutAll(); + expect(clearGrants).toHaveBeenCalled(); + }); + + it('and when switching to another account', () => { + const clearGrants = vi.fn().mockResolvedValue(undefined); + withBridge(clearGrants); + useAuthStore.setState({ isAuthenticated: true, activeAccountId: 'a1' }); + expect(clearGrants).not.toHaveBeenCalled(); + useAuthStore.setState({ activeAccountId: 'a2' }); + expect(clearGrants).toHaveBeenCalledTimes(1); + }); + + it('signs out all the same on a shell without the call, or one that fails', async () => { + withBridge(undefined); + await useAuthStore.getState().logout(); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + + useAuthStore.setState({ isAuthenticated: true }); + withBridge(vi.fn().mockRejectedValue(new Error('gone'))); + await useAuthStore.getState().logout(); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + }); + }); + it('does not wait on a plugin that never answers', async () => { vi.useFakeTimers(); authHooks.onBeforeLogout.register('p', () => new Promise(() => {})); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 9fc0c7ca3..68c119c3c 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -18,6 +18,7 @@ import { generateAccountId, MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; import { replaceWindowLocation, getPathPrefix, getLocaleFromPath, apiFetch } from '@/lib/browser-navigation'; import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { clearKeystoreGrants } from '@/lib/desktop/keystore-grants'; import { DESKTOP_CLIENT_ID_KEY } from '@/lib/desktop/browser-sign-in'; import { isEmbedded, notifyParent } from '@/lib/iframe-bridge'; import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll, forgetAccountRows } from '@/lib/account-state-manager'; @@ -2461,6 +2462,7 @@ export const useAuthStore = create()( await useSettingsStore.getState().flushSync(); useSettingsStore.getState().disableSync(); + clearKeystoreGrants(); void authHooks.onAfterLogout.emit({ accountId: accountId ?? 'all', }); @@ -2635,6 +2637,7 @@ export const useAuthStore = create()( accountStore.removeAccount(account.id); } + clearKeystoreGrants(); void authHooks.onAfterLogout.emit({ accountId: 'all' }); @@ -3688,6 +3691,7 @@ useAuthStore.subscribe((state, prev) => { const wasReady = prev.isAuthenticated && !!prev.client && !!prev.activeAccountId; if (ready && !wasReady) void authHooks.onAuthReady.emit({ accountId: state.activeAccountId }); if (state.activeAccountId && prev.activeAccountId && state.activeAccountId !== prev.activeAccountId) { + clearKeystoreGrants(); void authHooks.onAccountSwitch.emit({ accountId: state.activeAccountId, previousAccountId: prev.activeAccountId }); } });