fix: decrypt PGP and S/MIME messages inside conversations #902

This commit is contained in:
Linus Rath
2026-10-17 19:17:56 +02:00
parent b646ff0dc1
commit ce60099523
3 changed files with 257 additions and 2 deletions
@@ -0,0 +1,110 @@
// #902: the phone conversation view (also used by Mountain View's expanded
// messages) rendered the raw message body and never asked the
// onRenderEmailBody hook, so a PGP message the plugin decrypts in the single
// reader stayed an armored block inside a thread.
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act, render, screen, waitFor } from '@testing-library/react';
const t = Object.assign((k: string) => k, { raw: (k: string) => k, rich: (k: string) => k });
vi.mock('next-intl', () => ({
useTranslations: () => t,
useLocale: () => 'en',
}));
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => ({ enabled: false }), canWopiOpen: () => false }));
vi.mock('@/hooks/use-own-domain-address', () => ({ useOwnDomainAddress: () => () => false }));
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 { ThreadConversationView } = await import('@/components/email/thread-conversation-view');
const ARMORED = '-----BEGIN PGP MESSAGE-----\nhQEMA1234\n-----END PGP MESSAGE-----';
const pgpEmail = (id: string, receivedAt: string) => ({
id, blobId: `blob-${id}`, threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: false },
from: [{ name: 'Alice', email: 'alice@example.com' }], to: [{ email: 'me@example.org' }],
subject: 'Secret', receivedAt, size: 1, preview: '-----BEGIN PGP MESSAGE-----', hasAttachment: false,
textBody: [{ partId: '1', type: 'text/plain' }], htmlBody: [{ partId: '1', type: 'text/plain' }], attachments: [],
bodyValues: { '1': { value: ARMORED } },
});
const renderThread = () => {
const newer = pgpEmail('new', '2026-10-02T10:00:00Z');
const older = pgpEmail('old', '2026-10-01T10:00:00Z');
return render(
<ThreadConversationView
thread={{ threadId: 't1', emails: [newer, older], latestEmail: newer } as never}
emails={[newer, older] as never}
onBack={() => {}}
/>,
);
};
afterEach(() => clearAllHooks());
describe('conversation view runs the onRenderEmailBody hook', () => {
it('shows the plain text a plugin decrypted for each unfolded message', async () => {
const handler = vi.fn(async (_body: unknown, ctx: { id: string }) => ({
html: '', text: `Decrypted ${ctx.id}`, attachments: [], handledBy: 'pgp',
}));
renderHooks.onRenderEmailBody.register('pgp', handler as never);
renderThread();
expect(await screen.findByText('Decrypted new')).toBeTruthy();
expect(await screen.findByText('Decrypted old')).toBeTruthy();
expect(screen.queryByText(/BEGIN PGP MESSAGE/)).toBeNull();
expect(handler).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ id: 'new', blobId: 'blob-new' }),
);
}, 30_000);
it('puts decrypted HTML into the sandboxed body frame, sanitized', async () => {
renderHooks.onRenderEmailBody.register('pgp', (async () => ({
html: '<p>Decrypted html body</p><script>alert(1)</script>', text: '', attachments: [], handledBy: 'pgp',
})) as never);
const { container } = renderThread();
await waitFor(() => {
const srcdocs = [...container.querySelectorAll('iframe')].map((f) => f.getAttribute('srcdoc') ?? '');
expect(srcdocs).toHaveLength(2);
for (const doc of srcdocs) {
expect(doc).toContain('Decrypted html body');
expect(doc).not.toContain('alert(1)');
}
});
}, 30_000);
it('keeps the original body when no plugin handles the message', async () => {
renderHooks.onRenderEmailBody.register('pgp', (async (body: unknown) => body) as never);
renderThread();
await act(async () => {});
expect(screen.getAllByText(/BEGIN PGP MESSAGE/).length).toBe(2);
}, 30_000);
it('decrypts again when a plugin asks for a re-render', async () => {
let unlocked = false;
renderHooks.onRenderEmailBody.register('pgp', (async () => (unlocked
? { html: '', text: 'Unlocked body', attachments: [], handledBy: 'pgp' }
: { html: '', text: '', attachments: [] })) as never);
renderThread();
await act(async () => {});
expect(screen.queryByText('Unlocked body')).toBeNull();
unlocked = true;
act(() => { window.dispatchEvent(new Event('plugin:rerender-email')); });
expect((await screen.findAllByText('Unlocked body')).length).toBe(2);
}, 30_000);
});
+29 -2
View File
@@ -3,7 +3,7 @@
import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react";
import DOMPurify from "dompurify";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { EMAIL_SANITIZE_CONFIG, blockExternalResourcesOnNode, collapseBlockedImageContainers, emailIframeCsp, plainTextToSafeHtml, restrictDataUriResourcesOnNode, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { EMAIL_SANITIZE_CONFIG, blockExternalResourcesOnNode, collapseBlockedImageContainers, emailIframeCsp, plainTextToSafeHtml, restrictDataUriResourcesOnNode, sanitizeEmailBodyForIframe, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
import { findVerificationCode, verificationCodeBodyText } from "@/lib/verification-code";
import { VerificationCodeChip } from "./verification-code-chip";
@@ -46,6 +46,7 @@ import { emitThreadExpand } from "@/lib/plugin-thread-expand";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { PluginActions } from "@/components/plugins/plugin-actions";
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
import { usePluginRenderedBody } from "@/hooks/use-plugin-rendered-body";
import { emailToReadView } from "@/lib/plugin-projection";
import { AttachmentPluginMenu, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces";
@@ -515,6 +516,8 @@ export function ConversationEmailBody({
const [hasBlockedContent, setHasBlockedContent] = useState(false);
const [cidBlobUrls, setCidBlobUrls] = useState<Record<string, string>>({});
const { client } = useAuthStore(useShallow((state) => ({ client: state.client })));
// A crypto plugin's decrypted body (PGP, S/MIME) replaces the message's own.
const pluginBody = usePluginRenderedBody(email);
// 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);
@@ -575,6 +578,30 @@ export function ConversationEmailBody({
const emailContent = useMemo(() => {
if (!email) return { html: "", isHtml: false };
// Plugin-rendered bodies get the reader's sanitizer and remote-content
// blocking, like any other mail body (#797).
if (pluginBody.html) {
const withCids = pluginBody.html.replace(
/\bcid:([^"'\s)]+)/gi,
(_match, cidRef) => pluginBody.cidUrls[cidRef] || cidBlobUrls[cidRef] || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
);
const { html, blockedExternalContent } = sanitizeEmailBodyForIframe(withCids, !allowExternal);
if (blockedExternalContent) {
if (!hasBlockedContent) setHasBlockedContent(true);
return { html: collapseBlockedImageContainers(html), isHtml: true };
}
return { html, isHtml: true };
}
if (pluginBody.text) {
return {
html: collapsePlainTextQuotes(plainTextToSafeHtml(pluginBody.text, 'text-primary hover:underline'), {
show: t('email_viewer.show_quoted_text'),
hide: t('email_viewer.hide_quoted_text'),
}),
isHtml: false,
};
}
if (email.bodyValues) {
let htmlContent = getRenderableHtmlBody(email);
@@ -651,7 +678,7 @@ export function ConversationEmailBody({
// `hasBlockedContent` is only read to skip a redundant setState; as a
// dependency it would re-run the sanitizer once the banner shows.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [email, allowExternal, cidBlobUrls, t]);
}, [email, allowExternal, cidBlobUrls, pluginBody, t]);
// Parts the body embeds via cid: stay out of the attachment row while the
// user hides inline images - the desktop viewer's rule, shared through
+118
View File
@@ -0,0 +1,118 @@
"use client";
import { useEffect, useState } from "react";
import type { Email } from "@/lib/jmap/types";
import { renderHooks } from "@/lib/plugin-hooks";
import { toInertBlob } from "@/lib/file-preview";
import { debug } from "@/lib/debug";
export interface PluginRenderedBody {
/** HTML a crypto plugin (PGP, S/MIME) rendered for the message, unsanitized. */
html: string | null;
/** Plain text the plugin rendered, when it gave no HTML. */
text: string | null;
/** blob: URLs for the decrypted inline parts, keyed by Content-ID without brackets. */
cidUrls: Record<string, string>;
}
const EMPTY: PluginRenderedBody = { html: null, text: null, cidUrls: {} };
function dataUrlToBytes(dataUrl: string): Uint8Array | null {
try {
const comma = dataUrl.indexOf(',');
if (comma < 0) return null;
const meta = dataUrl.slice(0, comma);
const data = dataUrl.slice(comma + 1);
if (meta.includes(';base64')) {
const bin = atob(data);
const u8 = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
return u8;
}
return new TextEncoder().encode(decodeURIComponent(data));
} catch {
return 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.
*/
export function usePluginRenderedBody(email: Email): PluginRenderedBody {
const [body, setBody] = useState<PluginRenderedBody>(EMPTY);
const [nonce, setNonce] = useState(0);
useEffect(() => {
const bump = () => setNonce((n) => n + 1);
window.addEventListener('plugin:rerender-email', bump);
return () => window.removeEventListener('plugin:rerender-email', bump);
}, []);
useEffect(() => {
if (renderHooks.onRenderEmailBody.size === 0) {
setBody(EMPTY);
return;
}
let cancelled = false;
const objectUrls: string[] = [];
(async () => {
try {
const rawContentType = email.headers?.['content-type'] || email.headers?.['Content-Type'];
const contentType = Array.isArray(rawContentType) ? rawContentType[0] : rawContentType;
const result = await renderHooks.onRenderEmailBody.transform(
{ html: '', text: '', attachments: [] as unknown[] },
{
id: email.id,
contentType,
bodyStructure: email.bodyStructure,
bodyValues: email.bodyValues,
attachments: email.attachments,
blobId: email.blobId,
from: email.from,
},
) as {
html?: string;
text?: string;
attachments?: Array<{ name?: string; type?: string; dataUrl?: string; cid?: string }>;
handledBy?: string;
};
if (cancelled) return;
if (!result || !result.handledBy) {
setBody(EMPTY);
return;
}
const cidUrls: Record<string, string> = {};
for (const att of Array.isArray(result.attachments) ? result.attachments : []) {
if (!att.cid || !att.dataUrl) continue;
const bytes = dataUrlToBytes(att.dataUrl);
if (!bytes) continue;
// Same re-typing as fetched cid parts before they get a blob: URL
// in our origin (GHSA-xvjh-v9c6-qcvc).
const url = URL.createObjectURL(toInertBlob(new Blob([bytes as BlobPart], { type: att.type || 'application/octet-stream' })));
objectUrls.push(url);
cidUrls[att.cid.replace(/^<|>$/g, '')] = url;
}
setBody({
html: typeof result.html === 'string' && result.html ? result.html : null,
text: typeof result.text === 'string' && result.text ? result.text : null,
cidUrls,
});
} catch (err) {
if (cancelled) return;
debug.error('onRenderEmailBody hook failed:', err);
setBody(EMPTY);
}
})();
return () => {
cancelled = true;
objectUrls.forEach((url) => URL.revokeObjectURL(url));
};
}, [email, nonce]);
return body;
}