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 });
}
});