feat: tell plugins when a message body is on screen or a conversation unfolds, and let them rewrite links

This commit is contained in:
Linus Rath
2026-10-14 03:39:13 +02:00
parent c02aa4f3ed
commit 69dbc1b661
10 changed files with 423 additions and 15 deletions
@@ -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<string, unknown> = {}) => (
<EmailViewer email={email} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" {...extra} />
);
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(
<ThreadConversationView
thread={{ threadId: 't1', emails: [newer, older], latestEmail: newer } as never}
emails={[newer, older]}
onBack={() => {}}
/>,
);
// 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);
});
});
+20 -3
View File
@@ -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);
+47 -10
View File
@@ -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<ReadonlySet<string>>(() => new Set());
const mvExpandedIdsRef = useRef(mvExpandedIds);
mvExpandedIdsRef.current = mvExpandedIds;
const mvExpandedThreadRef = useRef<string | null>(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<string | null>(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 ? (
<div data-mv-open-folded="">
<MvConversationCard email={email} onOpen={() => setMvOpenCollapsed(false)} timeFormat={timeFormat} compact={mvMobileViewer} />
<MvConversationCard email={email} onOpen={() => setMvOpenFolded(false)} timeFormat={timeFormat} compact={mvMobileViewer} />
</div>
) : mvAwaitingConversation ? (
<div className={cn("flex items-start py-3 animate-pulse", mvMobileViewer ? "gap-3 px-4" : "gap-4 px-6")} data-mv-conversation-pending="">
@@ -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}
>
<div className="flex items-start" style={{ gap: mvViewer ? '16px' : 'var(--density-item-gap)' }}>
@@ -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}
>
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
@@ -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)) {
+6
View File
@@ -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
+1 -2
View File
@@ -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; };
+97
View File
@@ -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/');
});
});
@@ -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 });
});
});
+111
View File
@@ -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<any>): Entry[] | null {
const handlers = (bus as unknown as { handlers?: unknown }).handlers;
return Array.isArray(handlers) ? [...(handlers as Entry[])] : null;
}
function withTimeout<T>(value: T | Promise<T>, ms: number): Promise<T> {
if (!(value instanceof Promise)) return Promise.resolve(value);
return new Promise<T>((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<V> = { 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<V>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
bus: HookBus<any>,
value: V,
accept: (result: unknown, current: V) => V | undefined,
plainIntercept: (value: V) => Promise<boolean>,
): Promise<InterceptResult<V>> {
// 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<T extends { href: string }>(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<string | null> {
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;
}
+22
View File
@@ -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<Pick<Email, 'id' | 'threadId'>>,
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 });
}