From 69dbc1b6615be43e349466c07c8679880b1d64e9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:39:13 +0200 Subject: [PATCH] feat: tell plugins when a message body is on screen or a conversation unfolds, and let them rewrite links --- .../__tests__/viewer-read-hooks.test.tsx | 94 +++++++++++++++ components/email/email-list.tsx | 23 +++- components/email/email-viewer.tsx | 57 +++++++-- components/email/thread-conversation-view.tsx | 3 + components/mail/mail-app.tsx | 6 + components/ui/avatar.tsx | 3 +- lib/__tests__/plugin-hook-result.test.ts | 97 +++++++++++++++ lib/__tests__/plugin-thread-expand.test.ts | 22 ++++ lib/plugin-hook-result.ts | 111 ++++++++++++++++++ lib/plugin-thread-expand.ts | 22 ++++ 10 files changed, 423 insertions(+), 15 deletions(-) create mode 100644 components/email/__tests__/viewer-read-hooks.test.tsx create mode 100644 lib/__tests__/plugin-hook-result.test.ts create mode 100644 lib/__tests__/plugin-thread-expand.test.ts create mode 100644 lib/plugin-hook-result.ts create mode 100644 lib/plugin-thread-expand.ts diff --git a/components/email/__tests__/viewer-read-hooks.test.tsx b/components/email/__tests__/viewer-read-hooks.test.tsx new file mode 100644 index 000000000..6437c4f4e --- /dev/null +++ b/components/email/__tests__/viewer-read-hooks.test.tsx @@ -0,0 +1,94 @@ +import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { emailHooks, clearAllHooks } from '@/lib/plugin-hooks'; + +// mail-read hooks fired by the reading pane: onEmailContentRender once the +// body is on screen and onThreadExpand for the conversation cards. (The link +// hook runs inside the body iframe, which jsdom doesn't load; its decision is +// covered in lib/__tests__/plugin-hook-result.test.ts.) + +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, +})); +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 { EmailViewer } = await import('../email-viewer'); +const { ThreadConversationView } = await import('../thread-conversation-view'); + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +const textEmail = (id = 'e1') => ({ + id, blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + 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: [{ partId: '1', type: 'text/plain' }], attachments: [], + bodyValues: { '1': { value: 'Hello there' } }, +}) as unknown as Email; + +const viewer = (email: Email, extra: Record = {}) => ( + +); + +beforeEach(() => clearAllHooks()); + +describe('onEmailContentRender', () => { + it('fires once the body is on screen, once per message', async () => { + const handler = vi.fn(); + emailHooks.onEmailContentRender.register('test', handler); + + const { rerender } = render(viewer(textEmail(), { isLoading: true })); + await act(async () => {}); + expect(handler).not.toHaveBeenCalled(); + + rerender(viewer(textEmail(), { isLoading: false })); + await waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); + expect(handler).toHaveBeenCalledWith({ emailId: 'e1', hasHtml: false, hasExternalContent: false }); + + rerender(viewer(textEmail(), { isLoading: false, onReply: () => {} })); + await act(async () => {}); + expect(handler).toHaveBeenCalledTimes(1); + + rerender(viewer(textEmail('e2'), { isLoading: false })); + await waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); + expect(handler).toHaveBeenLastCalledWith({ emailId: 'e2', hasHtml: false, hasExternalContent: false }); + }); +}); + +describe('onThreadExpand in the conversation view', () => { + it('reports a card being unfolded and folded', () => { + const handler = vi.fn(); + emailHooks.onThreadExpand.register('test', handler); + const older = { ...textEmail('old'), keywords: { $seen: true }, receivedAt: '2025-12-01T00:00:00Z' } as Email; + const newer = textEmail('new'); + + render( + {}} + />, + ); + // The newest card starts unfolded, the older read one folded. + const olderHeader = screen.getAllByText('Alice').at(-1)!; + fireEvent.click(olderHeader); + expect(handler).toHaveBeenLastCalledWith({ threadId: 't1', emailIds: ['old'], expanded: true }); + fireEvent.click(screen.getAllByText('Alice').at(-1)!); + expect(handler).toHaveBeenLastCalledWith({ threadId: 't1', emailIds: ['old'], expanded: false }); + expect(handler).toHaveBeenCalledTimes(2); + }); +}); diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 5e33fd79b..25bb80857 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -31,6 +31,7 @@ import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-displa import { SearchChips } from "@/components/search/search-chips"; import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils"; import { getOwnAddresses } from "@/lib/filters/quick-rule-target"; +import { emitThreadExpand } from "@/lib/plugin-thread-expand"; import { normalizeAddress } from "@/lib/filters/quick-rules"; interface EmailListProps { @@ -497,18 +498,34 @@ export function EmailList({ } }, [client, hasMoreEmails, isLoadingMore, isLoading, isScheduledView, loadMoreEmails, onLoadMoreScheduled]); + // The messages of a thread as the list knows them, for onThreadExpand. + const threadMembers = useCallback((threadKey: string): Email[] => ( + useEmailStore.getState().threadEmailsCache.get(threadKey) + ?? threadGroups.find((g) => g.threadKey === threadKey)?.emails + ?? [] + ), [threadGroups]); + const handleToggleThreadExpansion = useCallback(async (threadKey: string) => { const isExpanded = expandedThreadIds.has(threadKey); if (!isExpanded && client) { toggleThreadExpansion(threadKey); await fetchThreadEmails(client, threadKey); + emitThreadExpand(threadKey, threadMembers(threadKey), true); // Mark all unread emails in this thread as read void markThreadAsRead(client, threadKey); } else { toggleThreadExpansion(threadKey); + emitThreadExpand(threadKey, threadMembers(threadKey), !isExpanded); } - }, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails, markThreadAsRead]); + }, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails, markThreadAsRead, threadMembers]); + + const collapseAllThreadsAndNotify = useCallback(() => { + const wereExpanded = [...useEmailStore.getState().expandedThreadIds]; + const members = wereExpanded.map((key) => [key, threadMembers(key)] as const); + collapseAllThreads(); + for (const [key, emails] of members) emitThreadExpand(key, emails, false); + }, [collapseAllThreads, threadMembers]); // Range-based load more: trigger when last visible item is near the end. // Debounce to prevent rapid cascade when thread grouping reduces item @@ -756,14 +773,14 @@ export function EmailList({ isLoading={isLoadingThread === thread.threadKey} expandedEmails={threadEmailsCache.get(thread.threadKey)} onToggleExpand={() => handleToggleThreadExpansion(thread.threadKey)} - onCollapseAllThreads={collapseAllThreads} + onCollapseAllThreads={collapseAllThreadsAndNotify} onEmailSelect={(email) => { // Collapse expanded threads when selecting an email outside the expanded thread const currentExpanded = useEmailStore.getState().expandedThreadIds; if (currentExpanded.size > 0) { // Check if the selected email belongs to any expanded thread via its threadId if (!email.threadId || !currentExpanded.has(threadKeyFor(email))) { - collapseAllThreads(); + collapseAllThreadsAndNotify(); } } onEmailSelect?.(email); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 8fc8e9118..4278020e5 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -4,7 +4,7 @@ import { Fragment, useState, useEffect, useLayoutEffect, useMemo, useRef, useCal import { Email, ContactCard, Mailbox } from "@/lib/jmap/types"; import { emailExportFilename, attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename"; import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; -import { applyNewTabToAnchor, emailIframeCsp, escapeHtml, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; +import { applyNewTabToAnchor, emailIframeCsp, escapeHtml, isExternalResourceUrl, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; import { getRenderableHtmlBody } from "@/lib/email-body-selection"; import { collectReferencedCids, isEmbeddedInBody } from "@/lib/attachment-visibility"; import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse"; @@ -26,7 +26,7 @@ import { useKeywordFormat } from "@/hooks/use-keyword-format"; import { getEmailTagIds } from "@/lib/thread-utils"; import { getSecurityStatus, extractListHeaders, getSenderVerification } from "@/lib/email-headers"; import { emailToReadView } from "@/lib/plugin-projection"; -import type { EmailReadView } from "@/lib/plugin-types"; +import type { EmailReadView, ExternalLinkContext } from "@/lib/plugin-types"; import { generateEmailSource } from "@/lib/email-source"; import { Reply, @@ -124,7 +124,9 @@ import { PluginSlot } from "@/components/plugins/plugin-slot"; import { RulesPanel, useRulesAvailability } from "./rules-menu"; import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers"; import { ResizeHandle } from "@/components/layout/resize-handle"; -import { emailHooks, uiHooks, renderHooks } from "@/lib/plugin-hooks"; +import { emailHooks, renderHooks } from "@/lib/plugin-hooks"; +import { emitThreadExpand } from "@/lib/plugin-thread-expand"; +import { resolveExternalLink } from "@/lib/plugin-hook-result"; import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types"; import { useAttachmentDrag, isDragOutSupported, type AttachmentDragSource } from "@/hooks/use-attachment-drag"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; @@ -1123,6 +1125,8 @@ export function EmailViewer({ // conversation opens with its unread messages unfolded, as Gmail does, so // reading the thread reads all of them. const [mvExpandedIds, setMvExpandedIds] = useState>(() => new Set()); + const mvExpandedIdsRef = useRef(mvExpandedIds); + mvExpandedIdsRef.current = mvExpandedIds; const mvExpandedThreadRef = useRef(null); useEffect(() => { if (!mvConversation || mvConversation.length === 0 || !email) { @@ -1135,6 +1139,7 @@ export function EmailViewer({ setMvExpandedIds(new Set(mvConversation.filter((e) => e.id !== email.id && !e.keywords?.$seen).map((e) => e.id))); }, [mvConversation, email]); const toggleMvExpanded = useCallback((target: Email) => { + emitThreadExpand(target.threadId, [target], !mvExpandedIdsRef.current.has(target.id)); setMvExpandedIds((prev) => { const next = new Set(prev); if (next.has(target.id)) next.delete(target.id); @@ -1146,6 +1151,12 @@ export function EmailViewer({ const [mvOpenCollapsed, setMvOpenCollapsed] = useState(false); useEffect(() => setMvOpenCollapsed(false), [email?.id]); const mvOpenFolded = mvOpenIndex >= 0 && mvOpenCollapsed; + // Folding or unfolding the open message of a conversation, told to plugins + // like any other conversation message (onThreadExpand). + const setMvOpenFolded = (folded: boolean) => { + if (email) emitThreadExpand(email.threadId, [email], !folded); + setMvOpenCollapsed(folded); + }; // The conversation is not here yet (the one on hand is another thread's, or // none). Hold the message back instead of drawing it alone and then pushing // it down under the earlier messages. @@ -2789,6 +2800,30 @@ export function EmailViewer({ staleDocRef.current = iframeRef.current?.contentDocument ?? null; }, [emailIframeSrcDoc]); + // onEmailContentRender: once per opened message, after its body is on + // screen (the iframe has loaded an HTML body; a text body renders at once). + const contentRenderedForRef = useRef(null); + useEffect(() => { + if (!email || isBodyLoading) return; + if (effectiveEmailContent.isHtml && !iframeReady) return; + if (contentRenderedForRef.current === email.id) return; + contentRenderedForRef.current = email.id; + if (emailHooks.onEmailContentRender.size === 0) return; + let hasExternalContent = hasBlockedContent; + if (!hasExternalContent && effectiveEmailContent.isHtml) { + // Loaded rather than blocked (allowed by the user or the policy): look + // at what the rendered body actually references. + const doc = iframeRef.current?.contentDocument; + hasExternalContent = !!doc && Array.from(doc.querySelectorAll('[src], [srcset], [background], [poster]')).some((el) => + ['src', 'srcset', 'background', 'poster'].some((attr) => isExternalResourceUrl(el.getAttribute(attr)))); + } + void emailHooks.onEmailContentRender.emit({ + emailId: email.id, + hasHtml: effectiveEmailContent.isHtml, + hasExternalContent, + }); + }, [email, isBodyLoading, effectiveEmailContent.isHtml, iframeReady, hasBlockedContent]); + // Open a blob: link the rendered body points at a cid: part. Mirrors the // attachment-chip gate: inert previewable types open in a tab, everything // else (and any blob: URL we did not mint) is downloaded under its part name. @@ -2929,14 +2964,16 @@ export function EmailViewer({ // data:, unresolved cid: and relative paths would land inside our // own origin. if (!isOpenableLinkHref(href)) return; - const ctx = { + const ctx: ExternalLinkContext = { href, target: targetEl.getAttribute('target') ?? undefined, emailId: email?.id, }; - const ok = await uiHooks.onBeforeExternalLink.intercept(ctx); - if (!ok || !isOpenableLinkHref(ctx.href)) return; - window.open(ctx.href, '_blank', 'noopener,noreferrer'); + // A plugin returns false to stop the link or { href } to send it + // somewhere else (a web address only). + const target = await resolveExternalLink(ctx); + if (!target) return; + window.open(target, '_blank', 'noopener,noreferrer'); }; doc.addEventListener('click', onLinkClick, true); @@ -4430,7 +4467,7 @@ export function EmailViewer({ {/* Mountain View: the open message folded like the rest of the conversation. */} {mvOpenFolded && email ? (
- setMvOpenCollapsed(false)} timeFormat={timeFormat} compact={mvMobileViewer} /> + setMvOpenFolded(false)} timeFormat={timeFormat} compact={mvMobileViewer} />
) : mvAwaitingConversation ? (
@@ -4452,7 +4489,7 @@ export function EmailViewer({ // message, as Gmail does; its buttons and links keep their own job. if (isInteractiveClick(e.target, e.currentTarget)) return; if (window.getSelection()?.toString()) return; - setMvOpenCollapsed(true); + setMvOpenFolded(true); } : undefined} >
@@ -4770,7 +4807,7 @@ export function EmailViewer({ onClick={mvOpenIndex >= 0 ? (e) => { if (isInteractiveClick(e.target, e.currentTarget)) return; if (window.getSelection()?.toString()) return; - setMvOpenCollapsed(true); + setMvOpenFolded(true); } : undefined} >
diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 4a4f70d43..72d3db894 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -41,6 +41,7 @@ import { useAuthStore } from "@/stores/auth-store"; import { isFilePreviewable, toInertBlob } from "@/lib/file-preview"; import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status"; import { useOwnDomainAddress } from "@/hooks/use-own-domain-address"; +import { emitThreadExpand } from "@/lib/plugin-thread-expand"; interface ThreadConversationViewProps { thread: ThreadGroup; @@ -193,6 +194,8 @@ export function ThreadConversationView({ }, [emails]); const toggleExpanded = (emailId: string) => { + const target = emails.find((e) => e.id === emailId); + if (target) emitThreadExpand(target.threadId, [target], !expandedIds.has(emailId)); setExpandedIds(prev => { const next = new Set(prev); if (next.has(emailId)) { diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 385bbbdaa..7ebd78726 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -118,6 +118,7 @@ import { INTERNAL_MAILTO_EVENT, type ParsedMailto } from "@/lib/protocol-handler import { plainTextToComposerBody, getQuoteBodies } from "@/lib/email-composer-utils"; import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks"; import { emailToReadView } from "@/lib/plugin-projection"; +import { emitThreadExpand } from "@/lib/plugin-thread-expand"; import { buildQuoteHeader } from "@/lib/quote-header"; import { buildComposeTabTitle, buildReplySubject } from "@/lib/subject-prefix"; import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment"; @@ -971,10 +972,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const store = useEmailStore.getState(); const wasExpanded = store.expandedThreadIds.has(threadKey); store.toggleThreadExpansion(threadKey); + const members = () => useEmailStore.getState().threadEmailsCache.get(threadKey) + ?? activeEmails.filter((e) => e.threadId && threadKeyFor(e) === threadKey); if (!wasExpanded) { void store.fetchThreadEmails(client, threadKey).then(() => { + emitThreadExpand(threadKey, members(), true); void useEmailStore.getState().markThreadAsRead(client, threadKey); }); + } else { + emitThreadExpand(threadKey, members(), false); } }, // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx index ce1ad23df..2478c78a6 100644 --- a/components/ui/avatar.tsx +++ b/components/ui/avatar.tsx @@ -165,8 +165,7 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d setPluginAvatarFailed(false); if (!email || avatarHooks.onAvatarResolve.size === 0) return; let cancelled = false; - avatarHooks.onAvatarResolve - .transform(null as string | null, { email, name }) + avatarHooks.onAvatarResolve.transform(null as string | null, { email, name }) .then((url) => { if (!cancelled) setPluginAvatarUrl(url); }) .catch(() => { if (!cancelled) setPluginAvatarFailed(true); }); return () => { cancelled = true; }; diff --git a/lib/__tests__/plugin-hook-result.test.ts b/lib/__tests__/plugin-hook-result.test.ts new file mode 100644 index 000000000..81c1d38dd --- /dev/null +++ b/lib/__tests__/plugin-hook-result.test.ts @@ -0,0 +1,97 @@ +// @vitest-environment node +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { clearAllHooks, uiHooks, pluginErrorTracker } from '../plugin-hooks'; +import { interceptWithResult, acceptLinkRewrite, resolveExternalLink } from '../plugin-hook-result'; + +const plainIntercept = (v: unknown) => uiHooks.onBeforeExternalLink.intercept(v); + +beforeEach(() => { + clearAllHooks(); + pluginErrorTracker.resetAll(); +}); + +describe('interceptWithResult', () => { + it('lets the action through unchanged without handlers', async () => { + const ctx = { href: 'https://a.example/' }; + expect(await interceptWithResult(uiHooks.onBeforeExternalLink, ctx, acceptLinkRewrite, plainIntercept)) + .toEqual({ allowed: true, value: ctx }); + }); + + it('stops on false and does not ask later handlers', async () => { + const later = vi.fn(); + uiHooks.onBeforeExternalLink.register('a', () => false); + uiHooks.onBeforeExternalLink.register('b', later); + const out = await interceptWithResult(uiHooks.onBeforeExternalLink, { href: 'https://a.example/' }, acceptLinkRewrite, plainIntercept); + expect(out).toEqual({ allowed: false }); + expect(later).not.toHaveBeenCalled(); + }); + + it('takes a returned { href } and hands it to the next handler', async () => { + const second = vi.fn(() => undefined); + uiHooks.onBeforeExternalLink.register('a', () => ({ href: 'https://proxy.example/?u=1' })); + uiHooks.onBeforeExternalLink.register('b', second); + const out = await interceptWithResult(uiHooks.onBeforeExternalLink, { href: 'https://a.example/', emailId: 'e1' }, acceptLinkRewrite, plainIntercept); + expect(out).toEqual({ allowed: true, value: { href: 'https://proxy.example/?u=1', emailId: 'e1' } }); + expect(second).toHaveBeenCalledWith({ href: 'https://proxy.example/?u=1', emailId: 'e1' }); + }); + + it('calls every handler once', async () => { + const handler = vi.fn(() => ({ href: 'https://b.example/' })); + uiHooks.onBeforeExternalLink.register('a', handler); + await interceptWithResult(uiHooks.onBeforeExternalLink, { href: 'https://a.example/' }, acceptLinkRewrite, plainIntercept); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('skips a handler that throws (fails open)', async () => { + uiHooks.onBeforeExternalLink.register('a', () => { throw new Error('boom'); }); + const out = await interceptWithResult(uiHooks.onBeforeExternalLink, { href: 'https://a.example/' }, acceptLinkRewrite, plainIntercept); + expect(out).toEqual({ allowed: true, value: { href: 'https://a.example/' } }); + }); +}); + +describe('acceptLinkRewrite', () => { + const cur = { href: 'https://a.example/' }; + it.each([ + 'javascript:alert(1)', + 'data:text/html,hi', + '/settings', + 'mailto:x@example.com', + 'https://', + ])('ignores %s', (href) => { + expect(acceptLinkRewrite({ href }, cur)).toBeUndefined(); + }); + + it('ignores anything but an object with a string href', () => { + expect(acceptLinkRewrite('https://b.example/', cur)).toBeUndefined(); + expect(acceptLinkRewrite({ href: 42 }, cur)).toBeUndefined(); + }); + + it('takes an http(s) address', () => { + expect(acceptLinkRewrite({ href: ' HTTP://b.example/x ' }, cur)).toEqual({ href: 'HTTP://b.example/x' }); + }); +}); + +describe('resolveExternalLink (a link clicked in a message)', () => { + const ctx = { href: 'https://a.example/', emailId: 'e1' }; + + it('opens the original address without plugins', async () => { + expect(await resolveExternalLink(ctx)).toBe('https://a.example/'); + }); + + it('opens the address a plugin returns', async () => { + const handler = vi.fn(() => ({ href: 'https://proxy.example/?u=a' })); + uiHooks.onBeforeExternalLink.register('p', handler); + expect(await resolveExternalLink(ctx)).toBe('https://proxy.example/?u=a'); + expect(handler).toHaveBeenCalledWith(ctx); + }); + + it('opens nothing when a plugin returns false', async () => { + uiHooks.onBeforeExternalLink.register('p', () => false); + expect(await resolveExternalLink(ctx)).toBeNull(); + }); + + it('keeps the original when the rewrite is not a web address', async () => { + uiHooks.onBeforeExternalLink.register('p', () => ({ href: 'javascript:alert(1)' })); + expect(await resolveExternalLink(ctx)).toBe('https://a.example/'); + }); +}); diff --git a/lib/__tests__/plugin-thread-expand.test.ts b/lib/__tests__/plugin-thread-expand.test.ts new file mode 100644 index 000000000..109df770e --- /dev/null +++ b/lib/__tests__/plugin-thread-expand.test.ts @@ -0,0 +1,22 @@ +// @vitest-environment node +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { emailHooks, clearAllHooks } from '../plugin-hooks'; +import { emitThreadExpand } from '../plugin-thread-expand'; + +beforeEach(() => clearAllHooks()); + +describe('emitThreadExpand', () => { + it('reports the thread id and its messages', () => { + const handler = vi.fn(); + emailHooks.onThreadExpand.register('test', handler); + emitThreadExpand('acct/x:T1', [{ id: 'a', threadId: 'T1' }, { id: 'b', threadId: 'T1' }], true); + expect(handler).toHaveBeenCalledWith({ threadId: 'T1', emailIds: ['a', 'b'], expanded: true }); + }); + + it('falls back to the id inside an account-scoped thread key', () => { + const handler = vi.fn(); + emailHooks.onThreadExpand.register('test', handler); + emitThreadExpand('acct/x:T1', [], false); + expect(handler).toHaveBeenCalledWith({ threadId: 'T1', emailIds: [], expanded: false }); + }); +}); diff --git a/lib/plugin-hook-result.ts b/lib/plugin-hook-result.ts new file mode 100644 index 000000000..1c532885c --- /dev/null +++ b/lib/plugin-hook-result.ts @@ -0,0 +1,111 @@ +// "Before" hooks whose handlers may either stop an action (return false) or +// change what it does (return a value), such as onBeforeExternalLink +// (`{ href }`) and onBeforeCompose (a ComposeOptions to prefill). +// +// HookBus.intercept() only reports whether the action may go ahead and drops +// every other return value; HookBus.transform() keeps return values but +// ignores `false`. Running both would call every plugin twice (and show any +// confirmation dialog twice), so this walks the handlers once and honours +// both answers. A payload changed in place has no effect: plugin handlers run +// in a sandboxed iframe and receive a copy. + +import { pluginErrorTracker, uiHooks, type HookBus } from './plugin-hooks'; +import type { ExternalLinkContext } from './plugin-types'; +import { isOpenableLinkHref } from './email-sanitization'; + +// Same budget as HookBus.intercept: these hooks may wait on a dialog. +const INTERCEPT_TIMEOUT_MS = 60_000; + +interface Entry { + pluginId: string; + handler: (...args: unknown[]) => unknown; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function handlersOf(bus: HookBus): Entry[] | null { + const handlers = (bus as unknown as { handlers?: unknown }).handlers; + return Array.isArray(handlers) ? [...(handlers as Entry[])] : null; +} + +function withTimeout(value: T | Promise, ms: number): Promise { + if (!(value instanceof Promise)) return Promise.resolve(value); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Hook timed out after ${ms}ms`)), ms); + value.then( + (v) => { clearTimeout(timer); resolve(v); }, + (e) => { clearTimeout(timer); reject(e); }, + ); + }); +} + +export type InterceptResult = { allowed: false } | { allowed: true; value: V }; + +/** + * Ask every handler of an intercept bus, in order. `false` stops the action; + * any other return value goes through `accept`, which turns it into the next + * value or returns undefined to ignore it. Like `intercept`, a handler that + * throws or times out is skipped (fail open). + * + * `plainIntercept` is the bus's own `intercept`, used when its handlers can't + * be walked (a test double): the veto still works, return values are lost. + */ +export async function interceptWithResult( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + bus: HookBus, + value: V, + accept: (result: unknown, current: V) => V | undefined, + plainIntercept: (value: V) => Promise, +): Promise> { + // No listeners (or a test double without a size): nothing to ask. + if (!(bus && bus.size > 0)) return { allowed: true, value }; + const handlers = handlersOf(bus); + if (!handlers) { + return (await plainIntercept(value)) ? { allowed: true, value } : { allowed: false }; + } + let current = value; + for (const { pluginId, handler } of handlers) { + if (pluginErrorTracker.isDisabled(pluginId)) continue; + let result: unknown; + try { + result = await withTimeout(handler(current), INTERCEPT_TIMEOUT_MS); + } catch (err) { + pluginErrorTracker.record(pluginId, err); + continue; + } + if (result === false) return { allowed: false }; + if (result === undefined || result === null || result === true) continue; + const next = accept(result, current); + if (next !== undefined) current = next; + } + return { allowed: true, value: current }; +} + +/** + * `accept` for onBeforeExternalLink: a handler may return `{ href }` to send + * the click elsewhere. Only a web address is taken, so a plugin can't turn a + * link into javascript:, data: or a path inside the app. + */ +export function acceptLinkRewrite(result: unknown, current: T): T | undefined { + if (!result || typeof result !== 'object') return undefined; + const href = (result as { href?: unknown }).href; + if (typeof href !== 'string') return undefined; + const trimmed = href.trim(); + if (!/^https?:/i.test(trimmed)) return undefined; + try { + new URL(trimmed); + } catch { + return undefined; + } + return { ...current, href: trimmed }; +} + +/** + * Ask onBeforeExternalLink about a click on a link in a message. Resolves to + * the address to open: the original, the one a plugin rewrote it to, or null + * when a plugin stopped it. + */ +export async function resolveExternalLink(ctx: ExternalLinkContext): Promise { + const outcome = await interceptWithResult(uiHooks.onBeforeExternalLink, ctx, acceptLinkRewrite, (c) => uiHooks.onBeforeExternalLink.intercept(c)); + if (!outcome.allowed || !isOpenableLinkHref(outcome.value.href)) return null; + return outcome.value.href; +} diff --git a/lib/plugin-thread-expand.ts b/lib/plugin-thread-expand.ts new file mode 100644 index 000000000..cad1b9b5d --- /dev/null +++ b/lib/plugin-thread-expand.ts @@ -0,0 +1,22 @@ +// onThreadExpand: a conversation was expanded or collapsed, in the message +// list or in the reading pane. Shared by every place that folds a thread so +// they all report the same payload. + +import type { Email } from './jmap/types'; +import { emailHooks } from './plugin-hooks'; +import { threadIdFromKey } from './thread-utils'; + +/** + * Tell plugins a conversation was expanded or collapsed. `emails` are the + * messages that were unfolded or folded: the whole thread for a list row, one + * message for a card in the reading pane. + */ +export function emitThreadExpand( + threadKey: string, + emails: ReadonlyArray>, + expanded: boolean, +): void { + if (emailHooks.onThreadExpand.size === 0) return; + const threadId = emails[0]?.threadId || threadIdFromKey(threadKey); + void emailHooks.onThreadExpand.emit({ threadId, emailIds: emails.map((e) => e.id), expanded }); +}