From c02aa4f3ed4ba1ec5c1a23e88cfadacecdcdfca5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:30:00 +0200 Subject: [PATCH 1/4] feat: show plugin badges next to the subject in message-list rows --- .../__tests__/plugin-row-badges.test.tsx | 159 +++++++++++++++++ components/email/plugin-row-badges.tsx | 161 ++++++++++++++++++ components/email/thread-list-item.tsx | 5 + 3 files changed, 325 insertions(+) create mode 100644 components/email/__tests__/plugin-row-badges.test.tsx create mode 100644 components/email/plugin-row-badges.tsx diff --git a/components/email/__tests__/plugin-row-badges.test.tsx b/components/email/__tests__/plugin-row-badges.test.tsx new file mode 100644 index 000000000..f4406bac2 --- /dev/null +++ b/components/email/__tests__/plugin-row-badges.test.tsx @@ -0,0 +1,159 @@ +import { render, screen, waitFor, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ThreadListItem } from '../thread-list-item'; +import { clearRowBadgeCache, normalizeBadges, MAX_ROW_BADGES } from '../plugin-row-badges'; +import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useUIStore } from '@/stores/ui-store'; +import { groupEmailsByThread } from '@/lib/thread-utils'; +import { renderHooks, clearAllHooks } from '@/lib/plugin-hooks'; +import type { Email } from '@/lib/jmap/types'; + +// onEmailListItemRender: plugins add small badges next to a row's subject. + +vi.mock('@/hooks/use-email-drag', () => ({ + useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }), +})); + +vi.mock('@/stores/auth-store', () => { + const state = { identities: [], username: null }; + return { + useAuthStore: (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state), + }; +}); + +vi.mock('@/stores/account-store', () => { + const state = { getAccountById: () => undefined }; + return { + useAccountStore: Object.assign( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (selector: any) => (selector ? selector(state) : state), + { getState: () => state }, + ), + }; +}); + +const makeEmail = (overrides: Partial = {}): Email => ({ + id: 'email-1', + threadId: 'thread-1', + mailboxIds: { inbox: true }, + keywords: { $seen: true }, + size: 1000, + receivedAt: '2024-01-15T10:00:00Z', + from: [{ name: 'Alice', email: 'alice@example.com' }], + subject: 'Test Subject', + hasAttachment: false, + ...overrides, +}); + +function row(emails: Email[]) { + const [thread] = groupEmailsByThread(emails); + return ( + {}} onEmailSelect={() => {}} /> + ); +} + +beforeEach(() => { + clearAllHooks(); + clearRowBadgeCache(); + useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true }); + useSettingsStore.setState({ + emailKeywords: [...DEFAULT_KEYWORDS], + showPreview: false, + mailLayout: 'split', + interfaceLayout: 'default', + }); + useEmailStore.setState({ selectedEmailKeys: new Set(), selectedMailbox: 'inbox' }); +}); + +describe('plugin badges in message-list rows', () => { + it('shows the badges a plugin returns, with the email as a plain summary', async () => { + const handler = vi.fn(async (badges: unknown[]) => [ + ...badges, + { key: 'enc', label: 'Encrypted', color: '#2563eb', title: 'Signed and encrypted' }, + ]); + renderHooks.onEmailListItemRender.register('test', handler); + + render(row([makeEmail()])); + + const badge = await screen.findByText('Encrypted'); + expect(badge).toHaveAttribute('title', 'Signed and encrypted'); + expect(badge.style.borderColor).not.toBe(''); + expect(handler).toHaveBeenCalledTimes(1); + const [initial, ctx] = handler.mock.calls[0] as unknown as [unknown, { email: Record }]; + expect(initial).toEqual([]); + expect(ctx.email).toMatchObject({ id: 'email-1', subject: 'Test Subject', keywords: ['$seen'] }); + // A projection, not the store object. + expect(ctx.email).not.toHaveProperty('mailboxIds.inbox'); + }); + + it('shows badges on a conversation row and on the phone layout', async () => { + useUIStore.setState({ isMobile: true, isTablet: false, isDesktop: false }); + renderHooks.onEmailListItemRender.register('test', async () => [{ key: 'k', label: 'Phone badge' }]); + + render(row([ + makeEmail({ id: 'a', receivedAt: '2024-01-15T10:00:00Z' }), + makeEmail({ id: 'b', receivedAt: '2024-01-16T10:00:00Z' }), + ])); + + expect(await screen.findByText('Phone badge')).toBeInTheDocument(); + }); + + it('renders the label as text, never as HTML', async () => { + renderHooks.onEmailListItemRender.register('test', async () => [ + { key: 'x', label: '' }, + ]); + + const { container } = render(row([makeEmail()])); + + expect(await screen.findByText('')).toBeInTheDocument(); + expect(container.querySelector('img[src="x"]')).toBeNull(); + }); + + it('asks plugins once per message and keyword state, not on every render', async () => { + const handler = vi.fn(async () => [{ key: 'k', label: 'Badge' }]); + renderHooks.onEmailListItemRender.register('test', handler); + const email = makeEmail(); + + const view = render(row([email])); + await screen.findByText('Badge'); + view.rerender(row([{ ...email }])); + view.unmount(); + render(row([{ ...email }])); + await screen.findByText('Badge'); + expect(handler).toHaveBeenCalledTimes(1); + + // Marking it unread is a new keyword state: ask again. + render(row([{ ...email, keywords: {} }])); + await waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); + }); + + it('does nothing without a handler', async () => { + const transform = vi.spyOn(renderHooks.onEmailListItemRender, 'transform'); + render(row([makeEmail()])); + await act(async () => {}); + expect(transform).not.toHaveBeenCalled(); + expect(screen.queryByTestId('plugin-row-badges')).toBeNull(); + transform.mockRestore(); + }); +}); + +describe('normalizeBadges', () => { + it(`keeps at most ${MAX_ROW_BADGES} well-formed badges and drops unsafe colours`, () => { + const out = normalizeBadges([ + { key: 'a', label: 'A', color: 'url(https://evil.example/x.png)' }, + { key: 'b', label: '' }, + 'nonsense', + { key: 'c', label: 'C', color: 'var(--color-warning)' }, + { key: 'd', label: 'D', color: 'red; background: url(x)' }, + { key: 'e', label: 'E' }, + { key: 'f', label: 'F' }, + ]); + expect(out).toEqual([ + { key: 'a', label: 'A' }, + { key: 'c', label: 'C', color: 'var(--color-warning)' }, + { key: 'd', label: 'D' }, + ]); + expect(normalizeBadges(undefined)).toEqual([]); + }); +}); diff --git a/components/email/plugin-row-badges.tsx b/components/email/plugin-row-badges.tsx new file mode 100644 index 000000000..03e28b3f2 --- /dev/null +++ b/components/email/plugin-row-badges.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { Email } from "@/lib/jmap/types"; +import type { EmailListBadge } from "@/lib/plugin-types"; +import { renderHooks } from "@/lib/plugin-hooks"; +import { emailSummary } from "@/lib/plugin-projection"; +import { cn } from "@/lib/utils"; +import { TAG_GROUP_CLASS, TAG_LOZENGE_CLASS } from "./tag-badge"; + +// Badges plugins add to a message-list row through onEmailListItemRender. +// +// The hook is async and would otherwise run for every row on every render, so +// the answer is kept per message and keyword state: scrolling a row back into +// view, or re-rendering the list, reuses it. A keyword change (read, flagged, +// a tag) asks again, since that is what most badges are about. + +/** At most this many plugin badges per row; the row has little room. */ +export const MAX_ROW_BADGES = 3; +const MAX_LABEL_LENGTH = 32; +const MAX_TITLE_LENGTH = 200; +const CACHE_LIMIT = 2000; + +const cache = new Map(); +const inflight = new Map>(); + +function cacheKey(email: Email, listeners: number): string { + const keywords = Object.keys(email.keywords ?? {}) + .filter((k) => email.keywords?.[k]) + .sort() + .join(","); + // The listener count is part of the key so a plugin being switched on or + // off doesn't leave the old answer behind. + return `${listeners}|${email.id}|${keywords}`; +} + +// A colour the badge may use: hex, rgb()/hsl() with plain numbers, a CSS +// variable or a colour name. Anything else (url(), expressions, several +// declarations) is dropped and the badge keeps the theme colours. +const SAFE_COLOR = /^(#[0-9a-f]{3,8}|(rgb|rgba|hsl|hsla)\([\d\s.,%/+-]+\)|var\(--[a-z0-9-]+\)|[a-z]{3,30})$/i; + +// eslint-disable-next-line no-control-regex -- removing control characters is the point +const CONTROL_CHARS = /[\u0000-\u001f\u007f]/g; + +function cleanText(value: unknown, max: number): string { + return typeof value === "string" ? value.replace(CONTROL_CHARS, "").trim().slice(0, max) : ""; +} + +/** Keep only well-formed badges, as plain text, capped at MAX_ROW_BADGES. */ +export function normalizeBadges(raw: unknown): EmailListBadge[] { + if (!Array.isArray(raw)) return []; + const out: EmailListBadge[] = []; + const seen = new Set(); + for (const item of raw) { + if (out.length >= MAX_ROW_BADGES) break; + if (!item || typeof item !== "object") continue; + const candidate = item as Record; + const label = cleanText(candidate.label, MAX_LABEL_LENGTH); + if (!label) continue; + const key = cleanText(candidate.key, 100) || label; + if (seen.has(key)) continue; + seen.add(key); + const badge: EmailListBadge = { key, label }; + const color = cleanText(candidate.color, 60); + if (color && SAFE_COLOR.test(color)) badge.color = color; + const title = cleanText(candidate.title, MAX_TITLE_LENGTH); + if (title) badge.title = title; + out.push(badge); + } + return out; +} + +function remember(key: string, badges: EmailListBadge[]): void { + if (cache.size >= CACHE_LIMIT) { + const oldest = cache.keys().next().value; + if (oldest !== undefined) cache.delete(oldest); + } + cache.set(key, badges); +} + +function resolveBadges(email: Email, key: string): Promise { + const pending = inflight.get(key); + if (pending) return pending; + const promise = renderHooks.onEmailListItemRender.transform([], { email: emailSummary(email) }) + .then(normalizeBadges, () => []) + .then((badges) => { + remember(key, badges); + return badges; + }) + .finally(() => inflight.delete(key)); + inflight.set(key, promise); + return promise; +} + +/** Forget every cached answer (tests). */ +export function clearRowBadgeCache(): void { + cache.clear(); + inflight.clear(); +} + +/** + * The plugin badges for a row: none at first, then whatever the plugins + * returned. Without any onEmailListItemRender handler this does nothing. + */ +export function usePluginRowBadges(email: Email): EmailListBadge[] { + const listeners = renderHooks.onEmailListItemRender.size; + const key = listeners > 0 ? cacheKey(email, listeners) : null; + const [state, setState] = useState<{ key: string | null; badges: EmailListBadge[] }>(() => ({ + key, + badges: key ? cache.get(key) ?? [] : [], + })); + + useEffect(() => { + if (!key) return; + const cached = cache.get(key); + if (cached) { + setState((prev) => (prev.key === key && prev.badges === cached ? prev : { key, badges: cached })); + return; + } + let cancelled = false; + void resolveBadges(email, key).then((badges) => { + if (!cancelled) setState({ key, badges }); + }); + return () => { cancelled = true; }; + // `key` covers the parts of the email the answer depends on. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key]); + + if (!key) return []; + // While a new answer is on its way, show the cached one for this key, if + // any, rather than the previous key's badges. + return state.key === key ? state.badges : cache.get(key) ?? []; +} + +/** Plugin badges next to a row's subject. Renders nothing when there are none. */ +export function PluginRowBadges({ email, className }: { email: Email; className?: string }) { + const badges = usePluginRowBadges(email); + if (badges.length === 0) return null; + return ( + + {badges.map((badge) => ( + + {badge.label} + + ))} + + ); +} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index f40a91e07..48697c5b2 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -29,6 +29,7 @@ import type { SwipeAction } from "@/stores/settings-store"; import { ThreadEmailItem } from "./thread-email-item"; import { EmailHoverActions } from "./email-hover-actions"; import { SearchSnippetText } from "./search-snippet-text"; +import { PluginRowBadges } from "./plugin-row-badges"; import { useOwnDomainAddress } from "@/hooks/use-own-domain-address"; import { useTranslations } from "next-intl"; @@ -596,6 +597,7 @@ const SingleEmailItem = React.forwardRef( )}> {subjectSnippet ? : (email.subject || '(no subject)')} + {(previewSnippet || inlinePreview) && ( {/* Gmail runs the snippet on from the subject after a dash. */} @@ -738,6 +740,7 @@ const SingleEmailItem = React.forwardRef( )}> {subjectSnippet ? : (email.subject || "(no subject)")} + {showPreview && density !== 'extra-compact' && density !== 'compact' && ( @@ -1182,6 +1185,7 @@ export const ThreadListItem = React.forwardRef {subjectSnippet ? : (latestEmail.subject || '(no subject)')} + {(previewSnippet || inlinePreview) && ( {mvRow && '\u00a0-\u00a0'} @@ -1330,6 +1334,7 @@ export const ThreadListItem = React.forwardRef {subjectSnippet ? : (latestEmail.subject || "(no subject)")} + {showPreview && density !== 'extra-compact' && density !== 'compact' && ( 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 2/4] 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 }); +} From 7b2119456d08f9cc3257fef5348ec30b48e03199 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:50:55 +0200 Subject: [PATCH 3/4] feat: let plugins stop or prefill the composer and mailto links, and tell them when it opens, closes and saves a draft --- .../composer-plugin-lifecycle.test.tsx | 314 ++++++++++++++++++ components/email/email-composer.tsx | 90 ++++- components/email/email-viewer.tsx | 13 +- .../__tests__/compose-plugin-hooks.test.tsx | 166 +++++++++ components/mail/mail-app.tsx | 188 ++++++++--- components/pro/pro-compose-tab-body.tsx | 3 + components/pro/pro-email-tab-body.tsx | 55 ++- lib/__tests__/plugin-compose-gate.test.ts | 78 +++++ lib/plugin-compose-gate.ts | 113 +++++++ lib/plugin-composer-hooks.ts | 68 ++++ lib/protocol-handlers/mailto.ts | 19 +- stores/pro-tab-store.ts | 7 +- 12 files changed, 1035 insertions(+), 79 deletions(-) create mode 100644 components/email/__tests__/composer-plugin-lifecycle.test.tsx create mode 100644 components/mail/__tests__/compose-plugin-hooks.test.tsx create mode 100644 lib/__tests__/plugin-compose-gate.test.ts create mode 100644 lib/plugin-compose-gate.ts create mode 100644 lib/plugin-composer-hooks.ts diff --git a/components/email/__tests__/composer-plugin-lifecycle.test.tsx b/components/email/__tests__/composer-plugin-lifecycle.test.tsx new file mode 100644 index 000000000..2d19c5e75 --- /dev/null +++ b/components/email/__tests__/composer-plugin-lifecycle.test.tsx @@ -0,0 +1,314 @@ +import { render, screen, fireEvent, act, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React, { useState } from 'react'; +import { EmailComposer } from '../email-composer'; +import { useAuthStore } from '@/stores/auth-store'; +import { emailHooks, clearAllHooks } from '@/lib/plugin-hooks'; +import { resetComposerSessions } from '@/lib/plugin-composer-hooks'; + +// mail-compose hooks fired by the composer itself: onComposerOpen and +// onComposerClose once per session (not per remount), onDraftAutoSave after a +// save the server took, and the onBeforeCompose prefill the host hands in. +// The hook module is the real one here. + +// ─── Heavy component mocks (mirrors reply-addressing.test.tsx) ──────────────── + +const editorKeyDown = vi.hoisted(() => vi.fn()); +const toastError = vi.hoisted(() => vi.fn()); + +vi.mock('@/components/email/rich-text-editor', () => ({ + RichTextEditor: () => React.createElement('div', { + 'data-testid': 'rich-text-editor', + onKeyDown: (event: React.KeyboardEvent) => { + if (!event.defaultPrevented) editorKeyDown(); + }, + }), +})); + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null })); +vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null })); +vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null })); +vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null })); +vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null })); +vi.mock('@/hooks/use-focus-trap', () => ({ + useFocusTrap: () => ({ current: null }), +})); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ + useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }), + stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }), +})); + +// ─── Store mocks ────────────────────────────────────────────────────────────── + +vi.mock('@/stores/auth-store', () => { + const state = { + client: null, + identities: [], + primaryIdentity: null, + isAuthenticated: false, + isDemoMode: false, + activeAccountId: null, + connectionLost: false, + getClientForAccount: () => undefined, + getAllConnectedClients: () => new Map(), + syncIdentities: () => {}, + refreshIdentities: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAuthStore: hook }; +}); + +vi.mock('@/stores/identity-store', () => { + const state = { + identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }], + defaultIdentityId: 'id-me', + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useIdentityStore: hook }; +}); + +vi.mock('@/stores/account-store', () => { + const state = { accounts: [], getAccountById: () => undefined }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAccountStore: hook }; +}); + +vi.mock('@/stores/email-store', () => { + const state = { + draftSaveEnabled: false, + sendRawEmail: async () => ({ sent: true }), + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useEmailStore: hook }; +}); + +const updateSetting = vi.fn(); + +vi.mock('@/stores/settings-store', () => { + const state = { + timeFormat: '24h', + plainTextMode: false, + subAddressDelimiter: '+', + autoSelectReplyIdentity: true, + attachmentReminderEnabled: false, + attachmentReminderKeywords: [], + emptySubjectWarningEnabled: true, + sendDelaySeconds: 0, + signaturePosition: 'above_quote', + signatureSeparatorEnabled: false, + requestReadReceiptDefault: false, + addTrustedSender: () => {}, + trustedSendersAddressBook: null, + updateSetting: (...args: unknown[]) => updateSetting(...args), + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useSettingsStore: hook }; +}); + +vi.mock('@/stores/contact-store', () => { + const state = { + contacts: [], + getAutocomplete: async () => [], + addToTrustedSendersBook: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useContactStore: hook }; +}); + +vi.mock('@/stores/template-store', () => { + const state = { templates: [], addTemplate: async () => {} }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useTemplateStore: hook }; +}); + +// ─── Misc dependency mocks ──────────────────────────────────────────────────── + +vi.mock('@/stores/toast-store', () => ({ + toast: { info: () => {}, error: toastError, success: () => {} }, +})); + +vi.mock('@/lib/email-sanitization', () => ({ + sanitizeSignatureHtml: (v: string) => v, + sanitizeEmailHtml: (v: string) => v, + parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'), +})); + +vi.mock('@/lib/email-threading', () => ({ + computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }), +})); +vi.mock('@/lib/signature-utils', () => ({ + appendPlainTextSignature: (body: string) => body, + getPlainTextSignature: () => '', +})); +vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' })); +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); +vi.mock('@/components/email/quoted-html', () => ({ + buildQuotedHtmlBlock: () => '', + serializeEditorContent: () => '', +})); +vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s })); + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +const DRAFT = { + to: 'bob@example.com', + cc: '', + bcc: '', + subject: 'Lunch', + body: '

Hello there

', + showCc: false, + showBcc: false, + selectedIdentityId: 'id-me', + subAddressTag: '', + mode: 'compose' as const, + draftId: null, +}; + +const flushCloseTimer = () => act(() => new Promise((r) => setTimeout(r, 10))); + +const requestCloseRef = { current: null } as React.MutableRefObject<((afterClose?: () => void) => void) | null>; + +function Host({ onSend, sessionKey = 's1' }: { onSend?: () => Promise; sessionKey?: string }) { + const [open, setOpen] = useState(true); + if (!open) return

closed

; + return ( + setOpen(false)} + onSend={async () => { + setOpen(false); + await onSend?.(); + }} + /> + ); +} + +beforeEach(() => { + clearAllHooks(); + resetComposerSessions(); + useAuthStore.setState({ client: null }); +}); + +describe('onComposerOpen / onComposerClose', () => { + it('reports the open with the mode and the original message', () => { + const opened = vi.fn(); + emailHooks.onComposerOpen.register('test', opened); + render( + , + ); + expect(opened).toHaveBeenCalledTimes(1); + expect(opened).toHaveBeenCalledWith({ mode: 'reply', inReplyToId: 'orig-1', originalSubject: 'Plans' }); + }); + + it('treats a remount with the same session as the same composer', async () => { + const opened = vi.fn(); + const closed = vi.fn(); + emailHooks.onComposerOpen.register('test', opened); + emailHooks.onComposerClose.register('test', closed); + + const { rerender } = render(
); + // Moved to another parent: React mounts a new composer. + rerender(
); + await flushCloseTimer(); + + expect(opened).toHaveBeenCalledTimes(1); + expect(closed).not.toHaveBeenCalled(); + }); + + it('reports a close without a send', async () => { + const closed = vi.fn(); + emailHooks.onComposerClose.register('test', closed); + render(); + + // Nothing typed: closes without asking. + act(() => requestCloseRef.current!()); + await screen.findByText('closed'); + await flushCloseTimer(); + + expect(closed).toHaveBeenCalledTimes(1); + expect(closed).toHaveBeenCalledWith({ draftId: null, sent: false }); + }); + + it('reports a close after a send as sent, even though the host closes mid-send', async () => { + const closed = vi.fn(); + emailHooks.onComposerClose.register('test', closed); + render( new Promise((r) => setTimeout(r, 20))} />); + + fireEvent.click(screen.getAllByTestId('composer-send')[0]); + await screen.findByText('closed'); + await act(() => new Promise((r) => setTimeout(r, 50))); + + expect(closed).toHaveBeenCalledTimes(1); + expect(closed).toHaveBeenCalledWith(expect.objectContaining({ sent: true })); + }); +}); + +describe('onDraftAutoSave', () => { + it('fires after the server took the draft, not when the save fails', async () => { + const saved = vi.fn(); + emailHooks.onDraftAutoSave.register('test', saved); + const createDraft = vi.fn().mockRejectedValueOnce(new Error('refused')).mockResolvedValue('draft-7'); + useAuthStore.setState({ + client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never, + }); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + render(); + fireEvent.change(screen.getByTestId('composer-subject'), { target: { value: 'Lunch?' } }); + await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1), { timeout: 4000 }); + await act(async () => {}); + expect(saved).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByTestId('composer-subject'), { target: { value: 'Lunch on Friday?' } }); + await waitFor(() => expect(saved).toHaveBeenCalledTimes(1), { timeout: 4000 }); + expect(saved).toHaveBeenCalledWith({ draftId: 'draft-7', subject: 'Lunch on Friday?' }); + } finally { + consoleError.mockRestore(); + useAuthStore.setState({ client: null }); + } + }, 15_000); +}); + +describe('onBeforeCompose prefill', () => { + it('fills the fields of a new message', () => { + render(); + expect(screen.getByTestId('composer-subject')).toHaveValue('From a plugin'); + expect(screen.getByText('carol@example.com')).toBeInTheDocument(); + }); + + it('goes over what the host handed in', () => { + render(); + expect(screen.getByTestId('composer-subject')).toHaveValue('Changed'); + // Fields the plugin left alone stay. + expect(screen.getByText('bob@example.com')).toBeInTheDocument(); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index cb045cb38..90405ec01 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -99,7 +99,10 @@ import { type Recipient, enrichChipsWithColorsAndIcons, ICON_MAP, + plainTextToComposerBody, } from "@/lib/email-composer-utils"; +import type { ComposePrefill } from "@/lib/plugin-compose-gate"; +import { composerContextMode, composerMounted, composerUnmounted, draftSaved } from "@/lib/plugin-composer-hooks"; import { isValidEmail } from "@/lib/validation"; import { buildMentionCandidates } from "@/lib/recipient-mentions"; import { RichTextEditor } from "@/components/email/rich-text-editor"; @@ -236,6 +239,20 @@ interface EmailComposerProps { className?: string; initialDraftText?: string; initialData?: ComposerDraftData | null; + /** + * Fields a plugin filled in through onBeforeCompose, over what the composer + * would start with. The host passes it for the first mount of a session + * only: a remount restores the user's own text from `initialData`. + */ + prefill?: ComposePrefill | null; + /** + * Identifies the compose session across remounts (a pop-out, a layout + * switch), so plugins hear onComposerOpen / onComposerClose once per + * message rather than once per mount. + */ + sessionKey?: string; + /** Id of the message being replied to or forwarded, for onComposerOpen. */ + sourceEmailId?: string; mode?: 'compose' | 'reply' | 'replyAll' | 'forward'; /** * Email of the mailbox/account the user is viewing when they start a new @@ -348,8 +365,11 @@ export function EmailComposer({ onDiscardDraft, onSaveState, className, - initialDraftText, - initialData, + initialDraftText: initialDraftTextProp, + initialData: initialDataProp, + prefill, + sessionKey, + sourceEmailId, mode = 'compose', composeFromAccountEmail, replyTo @@ -363,8 +383,27 @@ export function EmailComposer({ // or the format a re-opened / stashed draft was written in - and switchable // from the toolbar for just this message. const [plainTextMode, setPlainTextMode] = useState( - () => initialData?.plainTextMode ?? plainTextModeDefault + () => initialDataProp?.plainTextMode ?? plainTextModeDefault ); + // A plugin's onBeforeCompose prefill (plain text body) folded into what the + // composer starts from: over `initialData` when the host hands one in (a + // mailto:, a re-opened draft), otherwise over the fields built below. + const [initialPlainTextMode] = useState(plainTextMode); + const initialData = useMemo(() => { + if (!initialDataProp || !prefill) return initialDataProp; + const next = { ...initialDataProp }; + if (prefill.to) next.to = prefill.to.join(', '); + if (prefill.cc) { + next.cc = prefill.cc.join(', '); + if (prefill.cc.length > 0) next.showCc = true; + } + if (prefill.subject !== undefined) next.subject = prefill.subject; + if (prefill.body !== undefined) { + next.body = initialPlainTextMode ? prefill.body : plainTextToComposerBody(prefill.body); + } + return next; + }, [initialDataProp, prefill, initialPlainTextMode]); + const initialDraftText = !initialDataProp && prefill?.body !== undefined ? prefill.body : initialDraftTextProp; const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter); const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity); const replyIdentityMatch = useSettingsStore((state) => state.replyIdentityMatch); @@ -643,17 +682,22 @@ export function EmailComposer({ // is typing lives in a separate `*Input` string per field. This keeps a // display name containing a comma (e.g. "Doo, John") intact instead of // tearing it apart on a delimiter. - const [to, setTo] = useState(initialData ? parseRecipientList(initialData.to) : getInitialTo()); - const [cc, setCc] = useState(initialData ? parseRecipientList(initialData.cc) : getInitialCc()); + const [to, setTo] = useState(() => initialData + ? parseRecipientList(initialData.to) + : prefill?.to ? parseRecipientList(prefill.to.join(', ')) : getInitialTo()); + const [cc, setCc] = useState(() => initialData + ? parseRecipientList(initialData.cc) + : prefill?.cc ? parseRecipientList(prefill.cc.join(', ')) : getInitialCc()); const [bcc, setBcc] = useState(initialData ? parseRecipientList(initialData.bcc) : []); const [toInput, setToInput] = useState(''); const [ccInput, setCcInput] = useState(''); const [bccInput, setBccInput] = useState(''); - const [subject, setSubject] = useState(initialData?.subject ?? getInitialSubject()); + const [subject, setSubject] = useState(() => initialData?.subject ?? prefill?.subject ?? getInitialSubject()); const [body, setBody] = useState(() => initialData?.body != null ? withSignatureEmbedded(initialData.body) : getInitialBody() ); - const [showCc, setShowCc] = useState(initialData?.showCc ?? getInitialCc().length > 0); + const [showCc, setShowCc] = useState(() => initialData?.showCc + ?? (prefill?.cc ? prefill.cc.length > 0 : getInitialCc().length > 0)); const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false); // Committed recipients plus any not-yet-committed text the user has typed. // Send/validation/draft paths treat a typed-but-uncommitted address as a @@ -1299,6 +1343,31 @@ export function EmailComposer({ // open the next composer with the discarded reply's mode/replyTo. const explicitCloseRef = useRef(false); + // onComposerOpen / onComposerClose, once per session (see + // lib/plugin-composer-hooks.ts). Without a key from the host every mount is + // its own session. + const fallbackSessionKeyRef = useRef(null); + if (!sessionKey && fallbackSessionKeyRef.current === null) { + fallbackSessionKeyRef.current = `composer-${Math.random().toString(36).slice(2)}`; + } + const effectiveSessionKey = sessionKey ?? fallbackSessionKeyRef.current ?? 'composer'; + // Whether this session ended in a send, and the draft it ended with. + const sentRef = useRef(false); + const lastDraftIdRef = useRef(initialData?.draftId ?? null); + useEffect(() => { + composerMounted(effectiveSessionKey, { + mode: composerContextMode(mode), + ...(sourceEmailId && mode !== 'compose' ? { inReplyToId: sourceEmailId } : {}), + ...(replyTo?.subject && mode !== 'compose' ? { originalSubject: replyTo.subject } : {}), + }); + return () => composerUnmounted(effectiveSessionKey, () => ({ + draftId: draftIdRef.current ?? lastDraftIdRef.current, + sent: sentRef.current, + })); + // The session is fixed for the life of this mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [effectiveSessionKey]); + // Auto-save state on unmount (when user navigates away without explicitly closing) useEffect(() => { return () => { @@ -2228,6 +2297,8 @@ export function EmailComposer({ setSaveStatus('saved'); saveFailedRef.current = false; + lastDraftIdRef.current = savedDraftId; + draftSaved(savedDraftId, savedDraft.subject); // Reset status after 2 seconds setTimeout(() => setSaveStatus('idle'), 2000); @@ -2705,6 +2776,7 @@ export function EmailComposer({ } const sendHandledByPlugin = pluginOutcome === 'handled'; if (sendHandledByPlugin) { + sentRef.current = true; if (finalDraftId) { // The draft was created through the identity's owning account // (see saveDraft), so clean it up there too - not on the active @@ -2761,6 +2833,9 @@ export function EmailComposer({ // draft ("Continue draft" flashed after every send). Reset below if // the send fails and the composer stays open. explicitCloseRef.current = true; + // Read when the close is reported: the host tears the composer down + // while onSend is still running. Reset below if the send fails. + sentRef.current = true; await onSend?.({ to: outgoing.to, cc: outgoing.cc, @@ -2819,6 +2894,7 @@ export function EmailComposer({ } catch (err) { // Not sent, so the composer stays open and is an unsent draft again. explicitCloseRef.current = false; + sentRef.current = false; debug.error('Failed to send email:', err); if (err instanceof RecipientsRejectedError) { // Nothing went out; the message stays open here so the addresses diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 4278020e5..aecb0572f 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -112,6 +112,7 @@ import { useMenuNavigation } from "@/hooks/use-menu-navigation"; import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation"; import { RecipientPopover } from "./recipient-popover"; import { MailtoLink } from "@/components/ui/mailto-link"; +import { requestInternalMailto } from "@/lib/protocol-handlers/mailto"; import { imageBlobUrl, inertBlobType, isFilePreviewable, isMimeTypeSafeForInlinePreview, toInertBlob } from "@/lib/file-preview"; import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status"; import { parseTnef, isTnefAttachment } from "@/lib/tnef"; @@ -2949,7 +2950,17 @@ export function EmailViewer({ const targetEl = (ev.target as Element | null)?.closest?.('a[href], area[href]') as HTMLAnchorElement | HTMLAreaElement | null; if (!targetEl) return; const href = targetEl.getAttribute('href') || ''; - if (!href || href.startsWith('#') || href.startsWith('mailto:')) return; + if (!href || href.startsWith('#')) return; + if (/^mailto:/i.test(href)) { + // Write it here, like any other address in the app; the mail page + // asks onMailtoIntercept first. Not taken (no recipient, nobody + // listening): the browser's own handling stays. + if (requestInternalMailto(href)) { + ev.preventDefault(); + ev.stopPropagation(); + } + return; + } ev.preventDefault(); ev.stopPropagation(); // A link at a cid: part now carries the blob: URL we minted for it. diff --git a/components/mail/__tests__/compose-plugin-hooks.test.tsx b/components/mail/__tests__/compose-plugin-hooks.test.tsx new file mode 100644 index 000000000..e384396ca --- /dev/null +++ b/components/mail/__tests__/compose-plugin-hooks.test.tsx @@ -0,0 +1,166 @@ +import React, { Suspense, lazy } from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Mailbox } from '@/lib/jmap/types'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { usePluginStore } from '@/stores/plugin-store'; +import { useThemeStore } from '@/stores/theme-store'; +import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; +import { emailHooks, clearAllHooks } from '@/lib/plugin-hooks'; +import { requestInternalMailto } from '@/lib/protocol-handlers/mailto'; + +// onBeforeCompose and onMailtoIntercept in the mail page: a plugin can stop +// the composer from opening or prefill it, and stop a mailto: link. + +vi.mock('next/dynamic', () => ({ default: (load: () => Promise>>) => { + const Component = lazy(async () => ({ default: await load() })); + return function Dynamic(props: Record) { + return ; + }; +} })); +const composerProps = vi.hoisted(() => ({ last: null as Record | null })); +vi.mock('@/components/email/email-composer', () => ({ + EmailComposer: (props: Record) => { + composerProps.last = props; + return
stub composer
; + }, +})); +vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null })); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); +vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null })); +vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null })); +vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null })); +vi.mock('@/components/email/message-list-tabs', () => ({ MessageListTabs: () => null })); +vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null })); +vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) })); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) })); +vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} })); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] })); +vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} })); +vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false })); +vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} })); +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: true, isTablet: false, isDesktop: false }), + useMediaQuery: () => false, + useIsDesktop: () => false, +})); +vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} })); +vi.mock('@/components/email/email-list', () => ({ EmailList: () =>
message list
})); + +const { MailApp } = await import('../mail-app'); + +const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox' } as Mailbox; +const sendEmail = vi.fn(); + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request'))); + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) }); + useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) }); + const client = { + getAccountId: () => 'acc', getUsername: () => 'me@example.com', + getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null, + }; + useAuthStore.setState({ + isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login', identities: [], + getClientForAccount: () => client as never, + getAllConnectedClients: () => new Map([['login', client]]) as never, + }); + useSettingsStore.setState({ enableUnifiedMailbox: false, mailLayout: 'split', sendDelaySeconds: 0 } as never); + sendEmail.mockReset().mockResolvedValue({}); + useEmailStore.setState({ + emails: [], selectedEmail: null, selectedMailbox: inbox.id, mailboxes: [inbox], + viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null, + isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', selectedKeyword: null, + selectedEmailKeys: new Set(), + fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined), + fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined), + refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined), + sendEmail, + } as never); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + + +const openCompose = () => fireEvent.click(screen.getByRole('button', { name: 'sidebar.compose' })); + +describe('onBeforeCompose', () => { + beforeEach(() => { + clearAllHooks(); + composerProps.last = null; + }); + + it('opens the composer as before when no plugin listens', async () => { + render(); + openCompose(); + await screen.findByText('stub composer', {}, { timeout: 5000 }); + expect(composerProps.last?.prefill).toBeNull(); + }); + + it('keeps the composer closed when a plugin returns false', async () => { + const handler = vi.fn(() => false); + emailHooks.onBeforeCompose.register('test', handler); + render(); + openCompose(); + await waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); + await act(() => new Promise((r) => setTimeout(r, 50))); + expect(screen.queryByText('stub composer')).not.toBeInTheDocument(); + expect(handler).toHaveBeenCalledWith({ mode: 'new', to: [], cc: [], subject: '', body: '' }); + }); + + it('hands the fields a plugin returns to the composer', async () => { + emailHooks.onBeforeCompose.register('test', (opts: Record) => ({ + ...opts, to: ['carol@example.com'], subject: 'Weekly report', + })); + render(); + openCompose(); + await screen.findByText('stub composer', {}, { timeout: 5000 }); + expect(composerProps.last?.prefill).toEqual({ to: ['carol@example.com'], subject: 'Weekly report' }); + }); +}); + +describe('onMailtoIntercept', () => { + beforeEach(() => { + clearAllHooks(); + composerProps.last = null; + }); + + it('asks plugins with the link and opens the composer when they agree', async () => { + const intercept = vi.fn(() => true); + const before = vi.fn(() => undefined); + emailHooks.onMailtoIntercept.register('test', intercept); + emailHooks.onBeforeCompose.register('test', before); + render(); + + let taken = false; + act(() => { taken = requestInternalMailto('mailto:bob@example.com?subject=Hi'); }); + expect(taken).toBe(true); + + await screen.findByText('stub composer', {}, { timeout: 5000 }); + expect(intercept).toHaveBeenCalledWith({ href: 'mailto:bob@example.com?subject=Hi', to: ['bob@example.com'], subject: 'Hi' }); + expect(before).toHaveBeenCalledWith(expect.objectContaining({ mode: 'new', to: ['bob@example.com'], subject: 'Hi' })); + expect(composerProps.last?.initialData).toEqual(expect.objectContaining({ to: 'bob@example.com', subject: 'Hi' })); + }); + + it('keeps the composer closed when a plugin returns false', async () => { + const intercept = vi.fn(() => false); + emailHooks.onMailtoIntercept.register('test', intercept); + render(); + + let taken = false; + act(() => { taken = requestInternalMailto('mailto:bob@example.com'); }); + // Still taken: the click must not fall through to the OS handler. + expect(taken).toBe(true); + await waitFor(() => expect(intercept).toHaveBeenCalledTimes(1)); + await act(() => new Promise((r) => setTimeout(r, 50))); + expect(screen.queryByText('stub composer')).not.toBeInTheDocument(); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 7ebd78726..baa676d44 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -119,6 +119,8 @@ import { plainTextToComposerBody, getQuoteBodies } from "@/lib/email-composer-ut import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks"; import { emailToReadView } from "@/lib/plugin-projection"; import { emitThreadExpand } from "@/lib/plugin-thread-expand"; +import { composeOptionsFor, runBeforeCompose, type ComposePrefill, type ComposerOpenMode } from "@/lib/plugin-compose-gate"; +import { formatMailto } from "@/lib/protocol-handlers/mailto"; import { buildQuoteHeader } from "@/lib/quote-header"; import { buildComposeTabTitle, buildReplySubject } from "@/lib/subject-prefix"; import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment"; @@ -192,6 +194,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const [composerDraftText, setComposerDraftText] = useState(""); const [pendingDraft, setPendingDraft] = useState(null); const [composerSessionId, setComposerSessionId] = useState(0); + // Fields a plugin filled in through onBeforeCompose for the composer about + // to open. Dropped once the composer hands its own state back, so a + // remount restores what the user wrote instead. + const [composerPrefill, setComposerPrefill] = useState(null); // Plugin-resolved quote header for the next reply/forward composer open. // Cleared on close so a subsequent "compose new" doesn't reuse stale state. const [composerQuoteHeader, setComposerQuoteHeader] = useState(null); @@ -898,10 +904,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } }, onCompose: () => { - startFreshComposerSession(); - setComposerMode('compose'); - setShowComposer(true); - if (isMobile) setActiveView('viewer'); + void withComposeGate('compose', {}, () => { + startFreshComposerSession(); + setComposerMode('compose'); + setShowComposer(true); + if (isMobile) setActiveView('viewer'); + }); }, onFocusSearch: () => { if (isScheduledView) return; @@ -1129,6 +1137,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { replyTo, initialDraftText: composerDraftText, initialData: pendingDraft, + prefill: composerPrefill, sourceEmailId: selectedEmail?.id ?? null, title, }); @@ -1137,6 +1146,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { setShowComposer(false); setComposerDraftText(""); setPendingDraft(null); + setComposerPrefill(null); setComposerQuoteHeader(null); // We only react to the rising edge of `showComposer` here; the other // variables read above are captured-but-stale-safe because the next @@ -1187,6 +1197,31 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } }, [initialCheckDone, isAuthenticated, authLoading]); + // onBeforeCompose, in front of every way the composer opens. `open` runs + // once plugins agreed (straight away when none listens); a plugin's + // prefill goes to the composer through `composerPrefill`. + const withComposeGate = useCallback(async ( + mode: ComposerOpenMode, + fields: Omit[1]>, 'ownEmails' | 'replyPrefix' | 'forwardPrefix'>, + open: () => void, + ): Promise => { + if (emailHooks.onBeforeCompose.size === 0) { + setComposerPrefill(null); + open(); + return true; + } + const prefill = await runBeforeCompose(composeOptionsFor(mode, { + ...fields, + ownEmails: identities.map((i) => i.email).filter((e): e is string => Boolean(e)), + replyPrefix: t('email_composer.prefix.reply'), + forwardPrefix: t('email_composer.prefix.forward'), + })); + if (!prefill) return false; + setComposerPrefill(Object.keys(prefill).length > 0 ? prefill : null); + open(); + return true; + }, [identities, t]); + const applyMailtoDraft = useCallback((pending: ParsedMailto, suppressOutgoingStash: boolean) => { const body = useSettingsStore.getState().plainTextMode ? pending.body @@ -1214,7 +1249,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { if (isMobile) setActiveView("viewer"); }, [composerSessionId, isMobile, setActiveView]); - const openMailtoDraft = useCallback((pending: ParsedMailto) => { + const openMailtoDraft = useCallback((pending: ParsedMailto) => withComposeGate('compose', { + to: pending.to, cc: pending.cc, subject: pending.subject, body: pending.body, + }, () => { // A live composer owns text the user has not sent. Replacing it outright // used to drop that text on the floor - the stash suppression below is an // ordering fix, not a decision anyone made. Route the request through the @@ -1229,7 +1266,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { return; } applyMailtoDraft(pending, showComposer); - }, [applyMailtoDraft, showComposer]); + }), [applyMailtoDraft, showComposer, withComposeGate]); const openMailtoForAccount = useCallback(async (pending: ParsedMailto, accountId: string) => { setIsProtocolAccountSwitching(true); @@ -1244,7 +1281,19 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } }, [openMailtoDraft, switchAccount]); - const handleMailtoProtocolRequest = useCallback((pending: ParsedMailto) => { + // onMailtoIntercept: a plugin may stop a mailto: from opening the composer. + const mailtoAllowed = useCallback(async (pending: ParsedMailto & { href?: string }): Promise => { + if (emailHooks.onMailtoIntercept.size === 0) return true; + return emailHooks.onMailtoIntercept.intercept({ + href: pending.href ?? formatMailto(pending), + to: [...pending.to], + ...(pending.subject ? { subject: pending.subject } : {}), + ...(pending.body ? { body: pending.body } : {}), + }); + }, []); + + const handleMailtoProtocolRequest = useCallback(async (pending: ParsedMailto) => { + if (!(await mailtoAllowed(pending))) return; const protocolAccounts = getMailtoProtocolAccounts(); if (protocolAccounts.length > 1) { setPendingMailtoAccountChoice(pending); @@ -1258,14 +1307,14 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } openMailtoDraft(pending); - }, [activeAccountId, getMailtoProtocolAccounts, openMailtoDraft, openMailtoForAccount]); + }, [activeAccountId, getMailtoProtocolAccounts, mailtoAllowed, openMailtoDraft, openMailtoForAccount]); useEffect(() => { if (!isAuthenticated || !client) return; const openPendingMailto = () => { const pending = consumePendingMailto(); - if (pending) handleMailtoProtocolRequest(pending); + if (pending) void handleMailtoProtocolRequest(pending); }; openPendingMailto(); @@ -1281,15 +1330,19 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { if (!isAuthenticated || !client) return; const onInternalMailto = (event: Event) => { - const pending = (event as CustomEvent).detail; + const pending = (event as CustomEvent).detail; if (!pending) return; + // Taken, whatever plugins say: the click must not fall through to the + // OS mail handler. event.preventDefault(); - openMailtoDraft(pending); + void mailtoAllowed(pending).then((allowed) => { + if (allowed) void openMailtoDraft(pending); + }); }; window.addEventListener(INTERNAL_MAILTO_EVENT, onInternalMailto); return () => window.removeEventListener(INTERNAL_MAILTO_EVENT, onInternalMailto); - }, [isAuthenticated, client, openMailtoDraft]); + }, [isAuthenticated, client, mailtoAllowed, openMailtoDraft]); // Fallback fetch for paths that didn't go through login()'s prefetch // (notably checkAuth on page refresh). Settings pages can also prefill @@ -2100,11 +2153,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } else { setComposerQuoteHeader(null); } - startFreshComposerSession(); - setComposerDraftText(draftText || ""); - setComposerMode('reply'); - setShowComposer(true); - if (isMobile) setActiveView('viewer'); + await withComposeGate('reply', { source: selectedEmail, body: draftText || '' }, () => { + startFreshComposerSession(); + setComposerDraftText(draftText || ""); + setComposerMode('reply'); + setShowComposer(true); + if (isMobile) setActiveView('viewer'); + }); }; const handleEditDraft = async (email?: Email) => { @@ -2149,6 +2204,14 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { : identities; const matchedIdentityId = findDraftIdentityId(composerIdentities, draft.from?.[0]); + const draftRecipients = (list: typeof draft.to) => (list ?? []).map(a => a.email).filter(Boolean); + if (!(await withComposeGate('compose', { + to: draftRecipients(draft.to), + cc: draftRecipients(draft.cc), + subject: draft.subject && draft.subject !== t('email_composer.no_subject') ? draft.subject : '', + body: bodyText, + }, () => {}))) return; + // Increment session ID to force the composer to remount with fresh state, // even if it was already open (e.g. right-clicking a draft while composing). setComposerSessionId(id => id + 1); @@ -2296,10 +2359,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } else { setComposerQuoteHeader(null); } - startFreshComposerSession(); - setComposerMode('replyAll'); - setShowComposer(true); - if (isMobile) setActiveView('viewer'); + await withComposeGate('replyAll', { source: selectedEmail }, () => { + startFreshComposerSession(); + setComposerMode('replyAll'); + setShowComposer(true); + if (isMobile) setActiveView('viewer'); + }); }; const handleForward = async () => { @@ -2320,10 +2385,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } else { setComposerQuoteHeader(null); } - startFreshComposerSession(); - setComposerMode('forward'); - setShowComposer(true); - if (isMobile) setActiveView('viewer'); + await withComposeGate('forward', { source: selectedEmail }, () => { + startFreshComposerSession(); + setComposerMode('forward'); + setShowComposer(true); + if (isMobile) setActiveView('viewer'); + }); }; // Forward the original message as a message/rfc822 attachment instead of @@ -2375,6 +2442,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }); if (!ok) return; + if (!(await withComposeGate('forward', { subject: payload.subject }, () => {}))) return; startFreshComposerSession(); setPendingDraft({ to: "", @@ -3887,30 +3955,36 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { email = await emailHooks.onBeforeComposeOpenToReply.transform(email); selectEmail(email); await prepareComposerQuoteHeader(email, 'reply'); - startFreshComposerSession(); - setComposerMode('reply'); - setShowComposer(true); - if (isMobile) setActiveView('viewer'); + await withComposeGate('reply', { source: email }, () => { + startFreshComposerSession(); + setComposerMode('reply'); + setShowComposer(true); + if (isMobile) setActiveView('viewer'); + }); }; const handleConversationReplyAll = async (email: Email) => { email = await emailHooks.onBeforeComposeOpenToReplyAll.transform(email); selectEmail(email); await prepareComposerQuoteHeader(email, 'replyAll'); - startFreshComposerSession(); - setComposerMode('replyAll'); - setShowComposer(true); - if (isMobile) setActiveView('viewer'); + await withComposeGate('replyAll', { source: email }, () => { + startFreshComposerSession(); + setComposerMode('replyAll'); + setShowComposer(true); + if (isMobile) setActiveView('viewer'); + }); }; const handleConversationForward = async (email: Email) => { email = await emailHooks.onBeforeComposeOpenToForward.transform(email); selectEmail(email); await prepareComposerQuoteHeader(email, 'forward'); - startFreshComposerSession(); - setComposerMode('forward'); - setShowComposer(true); - if (isMobile) setActiveView('viewer'); + await withComposeGate('forward', { source: email }, () => { + startFreshComposerSession(); + setComposerMode('forward'); + setShowComposer(true); + if (isMobile) setActiveView('viewer'); + }); }; const ToggleChip = ({ icon, label, value, onClick }: { icon: React.ReactNode; label: string; value: boolean | null; onClick: () => void }) => ( @@ -4005,7 +4079,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } : undefined)} initialDraftText={composerDraftText} initialData={pendingDraft} + prefill={composerPrefill} + sessionKey={`inline-${composerSessionId}`} + sourceEmailId={composerSourceEmail?.id} onSaveState={(data) => { + setComposerPrefill(null); if (suppressComposerStateSaveSessionRef.current === composerSessionId) { suppressComposerStateSaveSessionRef.current = null; return; @@ -4015,6 +4093,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { onSend={async (data) => { await handleEmailSend(data); setPendingDraft(null); + setComposerPrefill(null); }} onScheduledSendCreated={async () => { if (client) { @@ -4030,6 +4109,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { setComposerMode('compose'); setComposerDraftText(""); setPendingDraft(null); + setComposerPrefill(null); setComposerQuoteHeader(null); if (isMobile) { setActiveView('list'); @@ -4204,13 +4284,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { onImportEmail={handleImportEmailFromContextMenu} onRefreshMailboxes={handleRefreshMailboxes} onCompose={() => { - startFreshComposerSession(); - setComposerMode('compose'); - setShowComposer(true); - if (isMobile) { - setSidebarOpen(false); - setActiveView('viewer'); - } + void withComposeGate('compose', {}, () => { + startFreshComposerSession(); + setComposerMode('compose'); + setShowComposer(true); + if (isMobile) { + setSidebarOpen(false); + setActiveView('viewer'); + } + }); }} onSidebarClose={() => setSidebarOpen(false)} mvShell={mvShell} @@ -4721,10 +4803,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { at the top of the navigation rail instead. */} {!mvShell && + ), +})); +vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null })); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); +vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null })); +vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null })); +vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null })); +vi.mock('@/components/email/message-list-tabs', () => ({ MessageListTabs: () => null })); +vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null })); +vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) })); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) })); +vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} })); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] })); +vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} })); +vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false })); +vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} })); +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, + useIsDesktop: () => true, +})); +vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} })); +vi.mock('@/components/email/email-list', () => ({ EmailList: () =>
message list
})); + +const { MailApp } = await import('../mail-app'); + +const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox' } as Mailbox; +const cancelUndoSend = vi.fn(); +const sendEmail = vi.fn(); +const refreshScheduledMetadata = vi.fn(); + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request'))); + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) }); + useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) }); + useToastStore.setState({ toasts: [] }); + const client = { + getAccountId: () => 'acc', getUsername: () => 'me@example.com', + getSieveAccountId: () => null, hasDelayedSend: () => true, getSession: () => null, + }; + useAuthStore.setState({ + isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login', identities: [], + getClientForAccount: () => client as never, + getAllConnectedClients: () => new Map([['login', client]]) as never, + }); + useSettingsStore.setState({ enableUnifiedMailbox: false, mailLayout: 'split', sendDelaySeconds: 30 } as never); + cancelUndoSend.mockReset(); + sendEmail.mockReset(); + clearAllHooks(); + resetAnnouncedSends(); + sendData.delayedUntil = undefined; + sendData.error = null; + refreshScheduledMetadata.mockReset().mockResolvedValue(undefined); + useEmailStore.setState({ + emails: [], selectedEmail: null, selectedMailbox: inbox.id, mailboxes: [inbox], + viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null, + isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', selectedKeyword: null, + selectedEmailKeys: new Set(), + fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined), + fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined), + refreshScheduledMetadata, + cancelUndoSend, + sendEmail, + clearPendingUndoSend: () => useEmailStore.setState({ pendingUndoSend: null }), + pendingUndoSend: null, + } as never); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + + +async function sendFromComposer() { + render(); + fireEvent.click(screen.getByRole('button', { name: 'sidebar.compose' })); + fireEvent.click(await screen.findByText('stub send', {}, { timeout: 5000 })); + await waitFor(() => expect(sendEmail).toHaveBeenCalled()); +} + +const undoToast = () => useToastStore.getState().toasts.find((t) => t.action?.label === 'email_viewer.undo_send'); + +describe('onAfterEmailSend', () => { + it('reports a send the server accepted', async () => { + useSettingsStore.setState({ sendDelaySeconds: 0 } as never); + const sent = vi.fn(); + emailHooks.onAfterEmailSend.register('test', sent); + sendEmail.mockResolvedValue({ scheduled: false, emailId: 'e1', emailSubmissionId: 's1' }); + + await sendFromComposer(); + + await waitFor(() => expect(sent).toHaveBeenCalledTimes(1)); + expect(sent).toHaveBeenCalledWith({ + emailId: 'e1', submissionId: 's1', to: ['bob@example.com'], cc: ['carol@example.com'], bcc: [], + subject: 'Hi', scheduledFor: null, + }); + }); + + it('says nothing when the send fails', async () => { + const sent = vi.fn(); + emailHooks.onAfterEmailSend.register('test', sent); + sendEmail.mockRejectedValue(new Error('refused')); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + await sendFromComposer(); + await waitFor(() => expect(sendData.error).not.toBeNull()); + + expect(sent).not.toHaveBeenCalled(); + }); + + it('reports a scheduled send at once, with its time', async () => { + const sent = vi.fn(); + emailHooks.onAfterEmailSend.register('test', sent); + sendData.delayedUntil = '2030-01-01T08:00:00.000Z'; + sendEmail.mockResolvedValue({ scheduled: true, emailId: 'e2', emailSubmissionId: 's2', sendAt: '2030-01-01T08:00:00Z' }); + + await sendFromComposer(); + + await waitFor(() => expect(sent).toHaveBeenCalledTimes(1)); + expect(sent).toHaveBeenCalledWith(expect.objectContaining({ emailId: 'e2', scheduledFor: '2030-01-01T08:00:00Z' })); + }); + + it('waits out the undo window, and says nothing when the send is undone', async () => { + const sent = vi.fn(); + emailHooks.onAfterEmailSend.register('test', sent); + let sendAt = ''; + sendEmail.mockImplementation(async () => { + sendAt = new Date(Date.now() + 1500).toISOString(); + useEmailStore.setState({ + pendingUndoSend: { submissionId: 's3', emailId: 'e3', identityId: 'id-1', sendAt, isSmime: false }, + } as never); + return { scheduled: true, emailId: 'e3', emailSubmissionId: 's3', sendAt }; + }); + cancelUndoSend.mockResolvedValue(null); + + await sendFromComposer(); + expect(sent).not.toHaveBeenCalled(); + + await waitFor(() => expect(undoToast()).toBeDefined()); + act(() => { undoToast()!.action!.onClick(); }); + await waitFor(() => expect(cancelUndoSend).toHaveBeenCalled()); + + await act(() => new Promise((r) => setTimeout(r, 1800))); + expect(sent).not.toHaveBeenCalled(); + }, 15_000); + + it('reports a held send once the undo window is over', async () => { + const sent = vi.fn(); + emailHooks.onAfterEmailSend.register('test', sent); + sendEmail.mockImplementation(async () => ( + { scheduled: true, emailId: 'e4', emailSubmissionId: 's4', sendAt: new Date(Date.now() + 1000).toISOString() } + )); + + await sendFromComposer(); + expect(sent).not.toHaveBeenCalled(); + + await waitFor(() => expect(sent).toHaveBeenCalledTimes(1), { timeout: 3000 }); + expect(sent).toHaveBeenCalledWith(expect.objectContaining({ emailId: 'e4', submissionId: 's4', scheduledFor: null })); + }, 15_000); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index baa676d44..c89510fa2 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -119,6 +119,7 @@ import { plainTextToComposerBody, getQuoteBodies } from "@/lib/email-composer-ut import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks"; import { emailToReadView } from "@/lib/plugin-projection"; import { emitThreadExpand } from "@/lib/plugin-thread-expand"; +import { announceSent, cancelAnnouncedSend, releaseAnnouncedSend } from "@/lib/plugin-after-send"; import { composeOptionsFor, runBeforeCompose, type ComposePrefill, type ComposerOpenMode } from "@/lib/plugin-compose-gate"; import { formatMailto } from "@/lib/protocol-handlers/mailto"; import { buildQuoteHeader } from "@/lib/quote-header"; @@ -1971,6 +1972,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const result = await sendEmail(sendClient, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, { requestReadReceipt: data.requestReadReceipt, requestDsn: data.requestDsn, requireTls: data.requireTls, localAccountId: data.localAccountId }); submitted = true; + announceSent(result, data); setShowComposer(false); // Sending an edited draft destroys it server-side - and every autosave // before that already replaced it under a fresh id (createDraft is a @@ -2297,6 +2299,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { pending.submissionAccountId, ); clearPendingUndoSend(); + releaseAnnouncedSend(pending.submissionId); if (isScheduledView) await fetchScheduledEmails(client); } catch (error) { console.error('Failed to send now:', error); @@ -2311,6 +2314,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { void (async () => { try { const restored = await cancelUndoSend(undoClient, pending); + // Never left the server: plugins don't hear about it. + cancelAnnouncedSend(pending.submissionId); if (restored && !pending.isSmime) { await handleEditDraft(restored); } diff --git a/components/pro/pro-compose-tab-body.tsx b/components/pro/pro-compose-tab-body.tsx index dc32b3357..84fdd5c90 100644 --- a/components/pro/pro-compose-tab-body.tsx +++ b/components/pro/pro-compose-tab-body.tsx @@ -12,6 +12,7 @@ import { toast } from "@/stores/toast-store"; import { useProTabStore, registerProTabCloseInterceptor, type ProComposeTabData } from "@/stores/pro-tab-store"; import { debug } from "@/lib/debug"; import { threadKeyFor } from "@/lib/thread-utils"; +import { announceSent } from "@/lib/plugin-after-send"; interface ProComposeTabBodyProps { tabId: string; @@ -89,6 +90,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { { localAccountId: sendData.localAccountId }, ); submitted = true; + announceSent(result, sendData); if (result.scheduled) { await handleScheduledSendCreated(); diff --git a/lib/__tests__/plugin-after-send.test.ts b/lib/__tests__/plugin-after-send.test.ts new file mode 100644 index 000000000..5e1f1a7bb --- /dev/null +++ b/lib/__tests__/plugin-after-send.test.ts @@ -0,0 +1,46 @@ +// @vitest-environment node +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { emailHooks, clearAllHooks } from '../plugin-hooks'; +import { announceSent, cancelAnnouncedSend, releaseAnnouncedSend, resetAnnouncedSends } from '../plugin-after-send'; + +const message = { to: ['bob@example.com'], cc: [], bcc: [], subject: 'Hi' }; + +beforeEach(() => { + vi.useFakeTimers(); + clearAllHooks(); + resetAnnouncedSends(); +}); +afterEach(() => vi.useRealTimers()); + +describe('announceSent for a send held back for undo', () => { + const held = () => ({ scheduled: true, emailId: 'e1', emailSubmissionId: 's1', sendAt: new Date(Date.now() + 10_000).toISOString() }); + + it('reports when the hold runs out', () => { + const sent = vi.fn(); + emailHooks.onAfterEmailSend.register('t', sent); + announceSent(held(), message); + vi.advanceTimersByTime(9_000); + expect(sent).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1_000); + expect(sent).toHaveBeenCalledWith({ emailId: 'e1', submissionId: 's1', ...message, scheduledFor: null }); + }); + + it('reports at once on "Send now"', () => { + const sent = vi.fn(); + emailHooks.onAfterEmailSend.register('t', sent); + announceSent(held(), message); + releaseAnnouncedSend('s1'); + expect(sent).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(20_000); + expect(sent).toHaveBeenCalledTimes(1); + }); + + it('never reports an undone send', () => { + const sent = vi.fn(); + emailHooks.onAfterEmailSend.register('t', sent); + announceSent(held(), message); + cancelAnnouncedSend('s1'); + vi.advanceTimersByTime(20_000); + expect(sent).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/plugin-after-send.ts b/lib/plugin-after-send.ts new file mode 100644 index 000000000..d6576f33a --- /dev/null +++ b/lib/plugin-after-send.ts @@ -0,0 +1,89 @@ +// onAfterEmailSend: told once the server accepted a message. +// +// - A plain send is reported straight away, scheduledFor null. +// - A send the user scheduled for later is reported straight away too, with +// scheduledFor: the server accepted it, even though it goes out later. +// - A send held back for undo ("Sending in 10 s") is reported only when the +// hold runs out or the user picks "Send now". Undo drops it: that message +// never leaves. +// A failed send never gets here; the callers only report a resolved send. + +import type { SendEmailResult } from './jmap/types'; +import { emailHooks } from './plugin-hooks'; + +export interface SentMessage { + to: string[]; + cc: string[]; + bcc: string[]; + subject: string; + /** Set when the user scheduled the message; absent for an undo-send hold. */ + delayedUntil?: string; +} + +interface AfterSendPayload { + emailId: string | null; + submissionId: string | null; + to: string[]; + cc: string[]; + bcc: string[]; + subject: string; + scheduledFor: string | null; +} + +const held = new Map; payload: AfterSendPayload }>(); + +function emit(payload: AfterSendPayload): void { + if (emailHooks.onAfterEmailSend.size === 0) return; + void emailHooks.onAfterEmailSend.emit(payload); +} + +/** Report a send the server accepted (see the rules above). */ +export function announceSent(result: SendEmailResult, message: SentMessage): void { + const payload: AfterSendPayload = { + emailId: result.emailId ?? null, + submissionId: result.emailSubmissionId ?? null, + to: [...message.to], + cc: [...message.cc], + bcc: [...message.bcc], + subject: message.subject, + scheduledFor: null, + }; + if (message.delayedUntil) { + emit({ ...payload, scheduledFor: result.sendAt ?? message.delayedUntil }); + return; + } + const submissionId = result.emailSubmissionId; + if (result.scheduled && submissionId && result.sendAt) { + const wait = Math.max(0, Date.parse(result.sendAt) - Date.now()); + const timer = setTimeout(() => { + held.delete(submissionId); + emit(payload); + }, Number.isFinite(wait) ? wait : 0); + held.set(submissionId, { timer, payload }); + return; + } + emit(payload); +} + +/** The user undid a held send: it is not reported. */ +export function cancelAnnouncedSend(submissionId: string): void { + const entry = held.get(submissionId); + if (!entry) return; + clearTimeout(entry.timer); + held.delete(submissionId); +} + +/** The user chose "Send now" for a held send: report it now. */ +export function releaseAnnouncedSend(submissionId: string): void { + const entry = held.get(submissionId); + if (!entry) return; + clearTimeout(entry.timer); + held.delete(submissionId); + emit(entry.payload); +} + +/** Drop every held send (tests). */ +export function resetAnnouncedSends(): void { + for (const { timer } of held.values()) clearTimeout(timer); + held.clear(); +}