diff --git a/components/email/__tests__/thread-conversation-plugin-body.test.tsx b/components/email/__tests__/thread-conversation-plugin-body.test.tsx new file mode 100644 index 000000000..52d79fc16 --- /dev/null +++ b/components/email/__tests__/thread-conversation-plugin-body.test.tsx @@ -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( + {}} + />, + ); +}; + +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: '

Decrypted html body

', 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); +}); diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index c60315b12..7170d85fa 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -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>({}); 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(null); const [plainBodyEl, setPlainBodyEl] = useState(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 diff --git a/hooks/use-plugin-rendered-body.ts b/hooks/use-plugin-rendered-body.ts new file mode 100644 index 000000000..34bdce248 --- /dev/null +++ b/hooks/use-plugin-rendered-body.ts @@ -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; +} + +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(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 = {}; + 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; +}