mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: tell plugins when a message body is on screen or a conversation unfolds, and let them rewrite links
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; };
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user