mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
feat: merge decrypting once per message and keeping external content off unverified mail
This commit is contained in:
@@ -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 = /<img[^>]*\ssrc="https:\/\/tracker/;
|
||||
const mailboxes = [{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }] as unknown as Mailbox[];
|
||||
|
||||
const message = (over: Partial<Email> = {}) => ({
|
||||
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) => <EmailViewer email={email} mailboxes={mailboxes} />;
|
||||
|
||||
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: '<p>Body</p>', 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: `<p>Secret body</p><img src="${REMOTE}">`, 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 = () => {}) => (
|
||||
<ConversationEmailBody email={message()} allowExternal={allowExternal} onAllowExternal={onAllowExternal} />
|
||||
);
|
||||
|
||||
it('keeps external content off when the plugin says so, even with it allowed', async () => {
|
||||
renderHooks.onRenderEmailBody.register('smime', (async () => ({
|
||||
html: `<p>Secret body</p><img src="${REMOTE}">`, 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: `<p>Secret body</p><img src="${REMOTE}">`, 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);
|
||||
});
|
||||
@@ -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<string | null>(null);
|
||||
const [pluginRenderedText, setPluginRenderedText] = useState<string | null>(null);
|
||||
const [pluginRenderedAttachments, setPluginRenderedAttachments] = useState<PostalMimeAttachment[]>(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')) && (
|
||||
<div className="flex items-start gap-3 py-1" data-testid="external-content-notice">
|
||||
<div className="w-10 h-10 rounded-full bg-info/15 text-info flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||
<Image className="w-5 h-5" />
|
||||
|
||||
@@ -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<HTMLElement | null>(null);
|
||||
const [plainBodyEl, setPlainBodyEl] = useState<HTMLDivElement | null>(null);
|
||||
@@ -930,7 +932,7 @@ export function ConversationEmailBody({
|
||||
<div className={className}>
|
||||
{phishingMarked && <PhishingMarkBanner className="px-4" />}
|
||||
{/* External content warning */}
|
||||
{hasBlockedContent && !allowExternal && emailContent.isHtml && (
|
||||
{hasBlockedContent && !allowExternal && !pluginBody.blockExternal && emailContent.isHtml && (
|
||||
<div className="px-4 py-2 bg-muted/50 flex items-center justify-between text-sm" data-testid="external-content-notice">
|
||||
<span className="text-muted-foreground">
|
||||
{remoteBlockedByOrg ? t("third_party.remote_content_blocked") : t("email_viewer.external_content_warning")}
|
||||
|
||||
@@ -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<string, string>;
|
||||
/** 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<PluginRenderedBody>(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;
|
||||
}
|
||||
|
||||
@@ -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' };
|
||||
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
@@ -180,6 +180,8 @@ export interface DesktopKeystoreBridge {
|
||||
accessState: () => Promise<DesktopKeystoreAccessState>;
|
||||
/** Takes back a stored yes and this session's grants. Only ever narrows. */
|
||||
revokeAccess: () => Promise<void>;
|
||||
/** Takes back this session's grants, for the next account. Absent on older shells. */
|
||||
clearGrants?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface BulwarkDesktopBridge {
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<void>) => {
|
||||
(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(() => {}));
|
||||
|
||||
@@ -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<AuthState>()(
|
||||
await useSettingsStore.getState().flushSync();
|
||||
useSettingsStore.getState().disableSync();
|
||||
|
||||
clearKeystoreGrants();
|
||||
void authHooks.onAfterLogout.emit({
|
||||
accountId: accountId ?? 'all',
|
||||
});
|
||||
@@ -2635,6 +2637,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user