import { appApiPath } from "@agent-native/core/client/api-path"; import { useT } from "@agent-native/core/client/i18n"; import type { EmailMessage, MobileActionId } from "@shared/types"; import { IconArchive, IconArrowLeft, IconChevronUp, IconChevronDown, IconExternalLink, IconMailOff, IconX, IconArrowBackUp, IconArrowBackUpDouble, IconArrowForwardUp, IconPaperclip, IconDownload, IconPhoto, IconSearch, IconDots, IconArrowsMaximize, IconArrowsMinimize, IconTrash, } from "@tabler/icons-react"; import { useQueryClient } from "@tanstack/react-query"; import { useTheme } from "next-themes"; import { useState, useCallback, useRef, useEffect, useMemo, forwardRef, Fragment, } from "react"; import { useNavigate, useParams, useSearchParams } from "react-router"; import { toast } from "sonner"; import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { useAccountFilter } from "@/hooks/use-account-filter"; import { useComposeState } from "@/hooks/use-compose-state"; import { useThreadMessages, useArchiveEmail, useTrashEmail, useUntrashEmail, useToggleStar, useMarkRead, useMarkThreadRead, useUnarchiveEmail, useSettings, useUpdateSettings, useEmailTracking, unsuppressThread, } from "@/hooks/use-emails"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useIsMobile } from "@/hooks/use-mobile"; import { setUndoAction } from "@/hooks/use-undo"; import { decodeHtmlEntities, processHtmlImages, } from "@/lib/email-image-policy"; import { isMcpEmbedSurface } from "@/lib/mcp-embed"; import { getResolvedTheme } from "@/lib/theme"; import { ensureThread, warmThreads } from "@/lib/thread-cache"; import type { ThreadSummary } from "@/lib/threads"; import { cn, formatEmailDate, formatFileSize, formatShortcut, } from "@/lib/utils"; import { buildEmailIframeDocument } from "./email-iframe-document"; import { InlineReplyComposer, type InlineReplyHandle, } from "./InlineReplyComposer"; import { MobileActionBar, DEFAULT_MOBILE_ACTIONS } from "./MobileActionBar"; export function EmailThread({ activeThreadId, onArchived, emailIds = [], threads = [], selectedIds, setSelectedIds, onContactSelect, onNavigateThread, isMaximized = false, onToggleMaximize, }: { activeThreadId?: string; onArchived?: (id: string) => void; emailIds?: string[]; /** * Full thread summaries for the current view. Used to resolve thread keys * back to their latestMessage (id, accountEmail) when bulk-archiving via * shift+j/k multi-selection from the detail view. */ threads?: ThreadSummary[]; /** * Multi-selection of thread keys (`threadId || id`). Shared with the list * view so selections survive navigation between list and detail views, and * shift+j/k in detail view can extend the same set. */ selectedIds?: Set; setSelectedIds?: React.Dispatch>>; onContactSelect?: (email: string) => void; onNavigateThread?: (threadId: string | undefined) => void; isMaximized?: boolean; onToggleMaximize?: () => void; }) { const t = useT(); const { view = "inbox", threadId: routeThreadId } = useParams<{ view: string; threadId: string; }>(); const threadId = activeThreadId || routeThreadId; const navigate = useNavigate(); const [searchParams] = useSearchParams(); const labelParam = searchParams.get("label"); const routeSearchSuffix = searchParams.toString() ? `?${searchParams.toString()}` : ""; const compose = useComposeState(); const queryClient = useQueryClient(); // Pull any messages we already have from the list cache (instant, no fetch). // The emails query uses useInfiniteQuery so cached data is InfiniteData<{ emails: EmailMessage[] }>, // not a flat array — flatten pages before searching. const cachedMessages = useMemo(() => { if (!threadId) return []; const allCached: EmailMessage[] = []; const queries = queryClient.getQueriesData({ queryKey: ["emails"], }); for (const [, data] of queries) { let emails: EmailMessage[]; if (Array.isArray(data)) { emails = data as EmailMessage[]; } else if ( data && typeof data === "object" && "pages" in data && Array.isArray((data as any).pages) ) { // InfiniteData — flatten pages emails = (data as any).pages.flatMap( (p: any) => p.emails ?? [], ) as EmailMessage[]; } else { continue; } for (const email of emails) { if ((email.threadId || email.id) === threadId) { allCached.push(email); } } } // Dedupe by id and sort oldest-first const seen = new Set(); return allCached .filter((e) => { if (seen.has(e.id)) return false; seen.add(e.id); return true; }) .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); }, [threadId, queryClient]); // Fetch all messages in the thread (URL param is the real threadId) const { data: threadMessages } = useThreadMessages(threadId); // Use the latestMessage from the threads prop as a last-resort preview (avoids // full skeleton when the user just clicked from the list and we have the data). const previewMessage = useMemo(() => { if (!threadId || !threads.length) return undefined; const thread = threads.find( (t) => (t.latestMessage.threadId || t.latestMessage.id) === threadId, ); return thread?.latestMessage; }, [threadId, threads]); // Use full thread when loaded, fall back to list cache, then to the single // preview message we already have from the list view — never show a full // skeleton when we already have the subject/snippet visible in the list. const allMessages = threadMessages ?? (cachedMessages.length > 0 ? cachedMessages : previewMessage ? [previewMessage] : []); // Hide Superhuman reminder messages — they're noise in the thread view const messages = useMemo( () => allMessages.filter( (m) => !( m.from.email === "reminder@superhuman.com" || (m.from.name === "Reminder" && (m.snippet || m.body || "") .toLowerCase() .includes("reminder from superhuman")) ), ), [allMessages], ); // Use the latest message as the "primary" email for actions/metadata const email = messages.length > 0 ? messages[messages.length - 1] : undefined; // Simple loading check: do we have the full email body yet? const hasFullBody = !!(email?.bodyHtml || email?.body); // Auto-expand latest + unread; user toggles override via this set const [userToggles, setUserToggles] = useState>({}); // Reset user overrides and search when navigating to a different thread useEffect(() => { setUserToggles({}); setSearchOpen(false); setSearchQuery(""); setSearchMatchIdx(0); }, [threadId]); // In-thread search const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [searchMatchIdx, setSearchMatchIdx] = useState(0); const searchInputRef = useRef(null); // Match counts per message for in-thread search const matchCountByMsg = useMemo(() => { const q = searchQuery.trim().toLowerCase(); if (!q) return new Map(); const map = new Map(); for (const msg of messages) { const text = msg.bodyHtml ? msg.bodyHtml.replace(/<[^>]+>/g, " ") : msg.body || ""; const lower = text.toLowerCase(); let count = 0; let i = lower.indexOf(q); while (i !== -1) { count++; i = lower.indexOf(q, i + q.length); } if (count > 0) map.set(msg.id, count); } return map; }, [searchQuery, messages]); const totalMatches = useMemo( () => [...matchCountByMsg.values()].reduce((a, b) => a + b, 0), [matchCountByMsg], ); const safeMatchIdx = totalMatches > 0 ? ((searchMatchIdx % totalMatches) + totalMatches) % totalMatches : 0; const getActiveLocalIdx = useCallback( (msgId: string): number | null => { if (!searchQuery.trim() || totalMatches === 0) return null; let offset = 0; for (const msg of messages) { const count = matchCountByMsg.get(msg.id) ?? 0; if (msg.id === msgId) { const local = safeMatchIdx - offset; return local >= 0 && local < count ? local : null; } offset += count; } return null; }, [searchQuery, totalMatches, safeMatchIdx, messages, matchCountByMsg], ); // Compute which messages are expanded: latest + unread by default, user toggles override const expandedIds = useMemo(() => { const ids = new Set(); if (messages.length === 0) return ids; ids.add(messages[messages.length - 1].id); // always expand latest for (const msg of messages) { if (!msg.isRead) ids.add(msg.id); } // Apply user overrides for (const [id, expanded] of Object.entries(userToggles)) { if (expanded) ids.add(id); else ids.delete(id); } // Auto-expand messages with search matches if (searchQuery.trim()) { for (const msgId of matchCountByMsg.keys()) { ids.add(msgId); } } return ids; }, [messages, userToggles, searchQuery, matchCountByMsg]); // Focused message index for keyboard nav (n/p) — starts on latest const [focusedIndex, setFocusedIndex] = useState(-1); useEffect(() => { setFocusedIndex(messages.length > 0 ? messages.length - 1 : -1); }, [threadId]); // Update if messages grow (full thread loaded) useEffect(() => { if (focusedIndex === -1 && messages.length > 0) { setFocusedIndex(messages.length - 1); } }, [messages.length, focusedIndex]); const focusedRef = useRef(null); const scrollContainerRef = useRef(null); // Scroll so the most recent (last) message is at the top of the viewport. // Pin for ~800ms to handle iframe resizes / async content. const scrolledForRef = useRef(undefined); const lastMessageRef = useRef(null); useEffect(() => { if (!threadId || messages.length === 0) return; const key = `${threadId}:${messages.length}`; if (scrolledForRef.current === key) return; scrolledForRef.current = key; const el = scrollContainerRef.current; const lastMsg = lastMessageRef.current; if (!el) return; const scrollToLatest = () => { if (lastMsg) { // Use manual scrollTop instead of scrollIntoView to avoid // scrolling ancestor overflow:hidden containers (causes header cutoff) el.scrollTop = lastMsg.offsetTop - el.offsetTop - 8; } else { el.scrollTop = el.scrollHeight; } }; scrollToLatest(); let stop = false; let raf: number; const pin = () => { if (!stop) { scrollToLatest(); raf = requestAnimationFrame(pin); } }; raf = requestAnimationFrame(pin); const timer = setTimeout(() => { stop = true; cancelAnimationFrame(raf); }, 800); return () => { stop = true; cancelAnimationFrame(raf); clearTimeout(timer); }; }, [threadId, messages.length]); const archiveEmail = useArchiveEmail(); const unarchiveEmail = useUnarchiveEmail(); const trashEmail = useTrashEmail(); const untrashEmail = useUntrashEmail(); const toggleStar = useToggleStar(); const markRead = useMarkRead(); const markThreadRead = useMarkThreadRead(); // Auto-mark all unread messages in this thread as read when viewed. // Defer the mutation past the commit so its optimistic emails-cache update // doesn't re-render the detail view we just finished mounting. const hasUnread = messages.some((m) => !m.isRead); useEffect(() => { if (threadId && hasUnread) { const id = threadId; const handle = setTimeout(() => markThreadRead.mutate(id), 0); return () => clearTimeout(handle); } // Only trigger when threadId changes or messages load with unread // eslint-disable-next-line react-hooks/exhaustive-deps }, [threadId, hasUnread]); const goBack = useCallback(() => { onNavigateThread?.(undefined); navigate(`/${view}${routeSearchSuffix}`); }, [navigate, view, routeSearchSuffix, onNavigateThread]); // Navigate between threads (j/k) — use ref to avoid stale closure const emailIdsRef = useRef(emailIds); emailIdsRef.current = emailIds; const goToSibling = useCallback( (delta: number) => { const ids = emailIdsRef.current; if (!threadId || ids.length === 0) return; const idx = ids.indexOf(threadId); let nextIdx: number; if (idx === -1) { nextIdx = delta > 0 ? 0 : ids.length - 1; } else { nextIdx = idx + delta; if (nextIdx < 0 || nextIdx >= ids.length) return; } const nextThreadId = ids[nextIdx]; const nextThread = threads.find( (t) => (t.latestMessage.threadId || t.latestMessage.id) === nextThreadId, ); setSelectedIds?.(new Set()); void ensureThread( nextThreadId, nextThread?.latestMessage.accountEmail, ).catch(() => {}); onNavigateThread?.(nextThreadId); navigate(`/${view}/${nextThreadId}${routeSearchSuffix}`); }, [ threadId, view, navigate, routeSearchSuffix, setSelectedIds, onNavigateThread, threads, ], ); // Shift+j/k extends multi-selection across siblings and auto-previews the // newly selected thread. Selection is keyed by thread key (`threadId || id`) // to match the list view so a selection can span both views seamlessly. const extendSelection = useCallback( (delta: number) => { if (!setSelectedIds) return; const ids = emailIdsRef.current; if (!threadId || ids.length === 0) return; const idx = ids.indexOf(threadId); if (idx === -1) return; const nextIdx = idx + delta; if (nextIdx < 0 || nextIdx >= ids.length) return; const nextThreadKey = ids[nextIdx]; setSelectedIds((prev) => { const updated = new Set(prev); if (prev.size === 0) updated.add(threadId); updated.add(nextThreadKey); return updated; }); onNavigateThread?.(nextThreadKey); navigate(`/${view}/${nextThreadKey}${routeSearchSuffix}`, { replace: true, }); }, [ threadId, view, navigate, routeSearchSuffix, setSelectedIds, onNavigateThread, ], ); // Prefetch only the currently open thread's closest siblings. That keeps // j/k navigation smooth without spending a large Gmail quota burst in the // background. useEffect(() => { if (emailIds.length === 0) return; const currentIdx = emailIds.findIndex((id) => id === threadId); const base = currentIdx >= 0 ? currentIdx : 0; const neighbors = emailIds.slice( Math.max(0, base - 1), Math.min(emailIds.length, base + 2), ); warmThreads( neighbors.map((id) => { const thread = threads.find( (t) => (t.latestMessage.threadId || t.latestMessage.id) === id, ); return { id, accountEmail: thread?.latestMessage.accountEmail }; }), ); }, [emailIds, threadId, threads]); const advanceOrGoBack = useCallback(() => { if (!threadId || emailIds.length === 0) { goBack(); return; } const idx = emailIds.indexOf(threadId); if (idx !== -1 && idx + 1 < emailIds.length) { const nextId = emailIds[idx + 1]; onNavigateThread?.(nextId); navigate(`/${view}/${nextId}${routeSearchSuffix}`, { replace: true, }); } else if (idx !== -1 && idx - 1 >= 0) { const prevId = emailIds[idx - 1]; onNavigateThread?.(prevId); navigate(`/${view}/${prevId}${routeSearchSuffix}`, { replace: true, }); } else { goBack(); } }, [ threadId, emailIds, view, navigate, routeSearchSuffix, goBack, onNavigateThread, ]); // Advance to next thread when current email is dismissed (snoozed/spam/muted) useEffect(() => { const handler = (e: Event) => { const { emailId } = (e as CustomEvent<{ emailId: string }>).detail; if (messages.some((m) => m.id === emailId)) { advanceOrGoBack(); } }; window.addEventListener("email:snoozed", handler); return () => window.removeEventListener("email:snoozed", handler); }, [messages, advanceOrGoBack]); // Navigate between messages within the thread (n/p) const focusMessage = useCallback( (delta: number) => { if (messages.length === 0) return; setFocusedIndex((prev) => { const nextIdx = Math.max( 0, Math.min(messages.length - 1, prev + delta), ); setTimeout(() => { const container = scrollContainerRef.current; const target = focusedRef.current; if (container && target) { const targetTop = target.offsetTop - container.offsetTop; const targetBottom = targetTop + target.offsetHeight; const viewTop = container.scrollTop; const viewBottom = viewTop + container.clientHeight; if (targetTop < viewTop) { container.scrollTop = targetTop; } else if (targetBottom > viewBottom) { container.scrollTop = targetBottom - container.clientHeight; } } }, 50); return nextIdx; }); }, [messages.length], ); // Toggle expand/collapse on focused message (Enter) const toggleFocused = useCallback(() => { if (focusedIndex < 0 || focusedIndex >= messages.length) return; const id = messages[focusedIndex].id; const isExpanded = expandedIds.has(id); setUserToggles((prev) => ({ ...prev, [id]: !isExpanded })); }, [focusedIndex, messages, expandedIds]); // Mobile action bar const isMobile = useIsMobile(); // Resolve the set of thread keys the next action should operate on. If the // user has a multi-selection (via shift+j/k in list or detail view), act on // that; otherwise fall back to the currently viewed thread. const getActionThreadKeys = useCallback((): string[] => { if (selectedIds && selectedIds.size > 0) return Array.from(selectedIds); if (threadId) return [threadId]; return []; }, [selectedIds, threadId]); const handleArchive = useCallback(() => { const threadKeys = getActionThreadKeys(); if (threadKeys.length === 0) return; // Resolve each thread key to its latestMessage via the threads prop, with // a fallback to the current email when acting on the focused thread alone. const targets = threadKeys .map((key) => { const t = threads.find( (t) => (t.latestMessage.threadId || t.latestMessage.id) === key, ); if (t) return t.latestMessage; // Fallback: single-thread archive of the currently viewed thread. if (email && (email.threadId || email.id) === key) return email; return undefined; }) .filter((m): m is EmailMessage => !!m); if (targets.length === 0) return; for (const t of targets) onArchived?.(t.id); const undo = () => { for (const key of threadKeys) unsuppressThread(key); for (const t of targets) unarchiveEmail.mutate(t.id); queryClient.invalidateQueries({ queryKey: ["emails"] }); }; setUndoAction(undo); toast( targets.length > 1 ? `Archived ${targets.length} conversations.` : "Archived.", { action: { label: "UNDO", onClick: undo }, position: isMobile ? "top-center" : undefined, }, ); advanceOrGoBack(); for (const t of targets) { archiveEmail.mutate({ id: t.id, accountEmail: t.accountEmail, removeLabel: labelParam || undefined, threadId: t.threadId || t.id, }); } setSelectedIds?.(new Set()); }, [ email, threads, getActionThreadKeys, archiveEmail, unarchiveEmail, advanceOrGoBack, onArchived, queryClient, labelParam, isMobile, setSelectedIds, ]); const handleTrash = useCallback(() => { const threadKeys = getActionThreadKeys(); if (threadKeys.length === 0) return; const targets = threadKeys .map((key) => { const t = threads.find( (t) => (t.latestMessage.threadId || t.latestMessage.id) === key, ); if (t) return t.latestMessage; if (email && (email.threadId || email.id) === key) return email; return undefined; }) .filter((m): m is EmailMessage => !!m); if (targets.length === 0) return; const undo = () => { for (const key of threadKeys) unsuppressThread(key); for (const t of targets) untrashEmail.mutate(t.id); queryClient.invalidateQueries({ queryKey: ["emails"] }); }; setUndoAction(undo); toast( targets.length > 1 ? `Trashed ${targets.length} conversations.` : "Moved to Trash.", { action: { label: "UNDO", onClick: undo } }, ); advanceOrGoBack(); for (const t of targets) trashEmail.mutate(t.id); setSelectedIds?.(new Set()); }, [ email, threads, getActionThreadKeys, trashEmail, untrashEmail, advanceOrGoBack, queryClient, setSelectedIds, ]); const handleStar = useCallback(() => { if (!email) return; toggleStar.mutate({ id: email.id, isStarred: !email.isStarred, accountEmail: email.accountEmail, threadId: email.threadId || email.id, }); }, [email, toggleStar]); const { data: settings } = useSettings(); const updateSettings = useUpdateSettings(); const { allAccounts } = useAccountFilter(); const myEmails = useMemo(() => { const emails = new Set(allAccounts.map((a) => a.email.toLowerCase())); if (settings?.email) emails.add(settings.email.toLowerCase()); return emails; }, [allAccounts, settings?.email]); // Inline reply: find any inline draft belonging to this thread const inlineReplyRef = useRef(null); const inlineDraft = compose.drafts.find( (d) => d.inline && d.replyToThreadId === threadId, ); const buildReplyQuote = (target: EmailMessage) => `\n\n\n\n— On ${new Date(target.date).toLocaleDateString()}, ${target.from.name || target.from.email} wrote:\n\n${target.body .split("\n") .map((l) => `> ${l}`) .join("\n")}`; // Determine which of our accounts the email was sent to (for reply-from) const findReplyAccount = useCallback( (target: EmailMessage): string | undefined => { // First check accountEmail on the message itself if (target.accountEmail) return target.accountEmail; // Otherwise scan to/cc for one of our connected accounts const allAddrs = [ ...target.to.map((r) => r.email.toLowerCase()), ...(target.cc || []).map((r) => r.email.toLowerCase()), ]; return allAddrs.find((e) => myEmails.has(e)); }, [myEmails], ); const handleReply = useCallback( (msg?: EmailMessage) => { // If inline draft exists and no specific message, just focus it const existing = compose.drafts.find( (d) => d.inline && d.replyToThreadId === threadId, ); if (existing && !msg) { inlineReplyRef.current?.focusEditor(); return; } // Discard existing inline draft if switching to a different message if (existing) compose.discard(existing.id); const target = msg ?? email; if (!target) return; // If the message is from me, reply to the first "to" recipient instead const isFromMe = myEmails.has(target.from.email.toLowerCase()); const replyTo = isFromMe ? (target.to[0]?.email ?? target.from.email) : target.from.email; compose.open({ to: replyTo, subject: target.subject.startsWith("Re:") ? target.subject : `Re: ${target.subject}`, body: buildReplyQuote(target), mode: "reply", replyToId: target.id, replyToThreadId: target.threadId, accountEmail: findReplyAccount(target), inline: true, }); }, [email, compose, myEmails, findReplyAccount, threadId], ); const handleReplyAll = useCallback( (msg?: EmailMessage) => { // If inline draft exists and no specific message, just focus it const existing = compose.drafts.find( (d) => d.inline && d.replyToThreadId === threadId, ); if (existing && !msg) { inlineReplyRef.current?.focusEditor(); return; } if (existing) compose.discard(existing.id); const target = msg ?? email; if (!target) return; const isFromMe = myEmails.has(target.from.email.toLowerCase()); // Collect all recipients, excluding all of my accounts const allRecipients = [ ...(isFromMe ? [] : [target.from.email]), ...target.to.map((r) => r.email), ...(target.cc || []).map((r) => r.email), ]; const uniqueTo = [ ...new Set( allRecipients .map((e) => e.toLowerCase()) .filter((e) => !myEmails.has(e)), ), ]; compose.open({ to: uniqueTo.join(", "), subject: target.subject.startsWith("Re:") ? target.subject : `Re: ${target.subject}`, body: buildReplyQuote(target), mode: "reply", replyToId: target.id, replyToThreadId: target.threadId, accountEmail: findReplyAccount(target), inline: true, }); }, [email, compose, myEmails, findReplyAccount, threadId], ); const handleForwardMsg = useCallback( (msg: EmailMessage) => { const existing = compose.drafts.find( (d) => d.inline && d.replyToThreadId === threadId, ); if (existing) compose.discard(existing.id); compose.open({ to: "", subject: msg.subject.startsWith("Fwd:") ? msg.subject : `Fwd: ${msg.subject}`, body: `\n\n\n\n— Forwarded message —\nFrom: ${msg.from.name} <${msg.from.email}>\n\n${msg.body}`, mode: "forward", replyToId: msg.id, replyToThreadId: msg.threadId, accountEmail: findReplyAccount(msg), inline: true, }); }, [compose, findReplyAccount, threadId], ); const handleForward = useCallback(() => { if (!email) return; handleForwardMsg(email); }, [email, handleForwardMsg]); // Keyboard shortcuts useKeyboardShortcuts( [ { key: "Escape", handler: () => { // If a multi-selection is active, first Escape clears it; second // Escape goes back to the list. Matches Gmail / Superhuman feel. if (selectedIds && selectedIds.size > 0) { setSelectedIds?.(new Set()); return; } goBack(); }, }, { key: "j", handler: () => goToSibling(1) }, { key: "k", handler: () => goToSibling(-1) }, { key: "j", shift: true, handler: () => extendSelection(1) }, { key: "k", shift: true, handler: () => extendSelection(-1) }, { key: "ArrowDown", shift: true, handler: () => extendSelection(1) }, { key: "ArrowUp", shift: true, handler: () => extendSelection(-1) }, { key: "n", handler: () => focusMessage(1) }, { key: "p", handler: () => focusMessage(-1) }, { key: "Enter", handler: toggleFocused }, { key: "o", handler: toggleFocused, }, { key: "o", meta: true, handler: () => { if (githubPrUrl) window.open(githubPrUrl, "_blank"); }, }, { key: "e", handler: handleArchive }, { key: "d", handler: handleTrash }, { key: "s", handler: handleStar }, { key: "r", handler: () => { const focused = focusedIndex >= 0 ? messages[focusedIndex] : undefined; handleReply(focused); }, }, { key: "a", handler: () => { const focused = focusedIndex >= 0 ? messages[focusedIndex] : undefined; handleReplyAll(focused); }, }, { key: "f", handler: handleForward }, { key: "f", meta: true, skipInInput: false, handler: () => { if (!searchOpen) { setSearchOpen(true); setTimeout(() => searchInputRef.current?.focus(), 50); } else { searchInputRef.current?.focus(); } }, }, { key: "u", handler: () => { if (!email) return; markRead.mutate({ id: email.id, isRead: !email.isRead, accountEmail: email.accountEmail, }); }, }, { key: "I", shift: true, handler: () => { if (!email) return; markRead.mutate({ id: email.id, isRead: true, accountEmail: email.accountEmail, }); }, }, { key: "U", shift: true, handler: () => { if (!email) return; markRead.mutate({ id: email.id, isRead: false, accountEmail: email.accountEmail, }); }, }, ], !!threadId, ); const mobileActions = settings?.mobileActions ?? DEFAULT_MOBILE_ACTIONS; const handleMobileAction = useCallback( (action: MobileActionId) => { switch (action) { case "archive": handleArchive(); break; case "trash": handleTrash(); break; case "star": handleStar(); break; case "reply": handleReply(); break; case "replyAll": handleReplyAll(); break; case "forward": handleForward(); break; case "markUnread": if (email) markRead.mutate({ id: email.id, isRead: false, accountEmail: email.accountEmail, }); break; case "prev": goToSibling(-1); break; case "next": goToSibling(1); break; } }, [ handleArchive, handleTrash, handleStar, handleReply, handleReplyAll, handleForward, email, markRead, goToSibling, ], ); // Extract GitHub PR URL from any message in the thread const githubPrUrl = useMemo(() => { for (const msg of messages) { const text = msg.bodyHtml ? msg.bodyHtml.replace(/<[^>]+>/g, " ") : msg.body || ""; const match = text.match(/https:\/\/github\.com\/[^\s"'<>]+\/pull\/\d+/); if (match) return match[0].replace(/[.,;)]+$/, ""); // strip trailing punctuation } return null; }, [messages]); // Extract unsubscribe info from thread messages (use the most recent with the header) const unsubscribeInfo = useMemo(() => { for (let i = messages.length - 1; i >= 0; i--) { const unsub = messages[i].unsubscribe; if (unsub && (unsub.url || unsub.mailto)) { return { ...unsub, messageId: messages[i].id, accountEmail: messages[i].accountEmail, }; } } // Fallback: scan HTML body for unsubscribe links for (let i = messages.length - 1; i >= 0; i--) { const html = messages[i].bodyHtml; if (!html) continue; const match = html.match( /]*href=["']([^"']+)["'][^>]*>[^<]*unsubscribe[^<]*<\/a>/i, ); if (match) return { url: match[1], bodyFallback: true } as const; } return null; }, [messages]); const [unsubscribing, setUnsubscribing] = useState(false); const handleUnsubscribe = useCallback(async () => { if (!unsubscribeInfo) return; // If we only found a link in the body (no header), just open it if (!("messageId" in unsubscribeInfo)) { if (unsubscribeInfo.url) window.open(unsubscribeInfo.url, "_blank"); return; } setUnsubscribing(true); try { const res = await fetch( appApiPath(`/api/emails/${unsubscribeInfo.messageId}/unsubscribe`), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ accountEmail: unsubscribeInfo.accountEmail, }), }, ); const data = await res.json(); if (data.ok) { toast.success(t("mail.toasts.unsubscribeSent")); // Also open the URL so user can confirm if needed if (data.url || unsubscribeInfo.url) { window.open(data.url || unsubscribeInfo.url, "_blank"); } } else { // Fallback: open the unsubscribe URL directly if (unsubscribeInfo.url) { window.open(unsubscribeInfo.url, "_blank"); } else { toast.error(t("mail.toasts.couldNotUnsubscribe")); } } } catch { // Fallback: open URL directly if (unsubscribeInfo.url) { window.open(unsubscribeInfo.url, "_blank"); } } finally { setUnsubscribing(false); } }, [unsubscribeInfo]); if (!threadId) return null; if (!email) { if (previewMessage) { return ( ); } return ; } // Filter to user labels for display const systemLabels = new Set([ "inbox", "sent", "drafts", "archive", "trash", "starred", "all", "important", ]); const displayLabels = [...new Set(email.labelIds)].filter( (l) => !systemLabels.has(l), ); // Strip "Re: " / "Fwd: " prefixes for thread subject const threadSubject = email.subject.replace(/^(Re|Fwd|Fw):\s*/i, ""); return (
{/* Thread header */}
Back (Esc)

{threadSubject}

{displayLabels.map((labelId) => ( {labelId} ))} {/* Action bar */}
Archive (E) {view !== "trash" && ( Move to Trash (D) )} {onToggleMaximize && ( {isMaximized ? "Minimize" : "Maximize"} )}
{(githubPrUrl || unsubscribeInfo) && (
{githubPrUrl && ( {t("mail.thread.viewPullRequest")} {t("mail.thread.viewPullRequest")} {formatShortcut("cmd")} O )} {unsubscribeInfo && ( {t("mail.thread.unsubscribeTooltip")} )}
)}
{/* In-thread search bar */} {searchOpen && ( { setSearchQuery(q); setSearchMatchIdx(0); }} onNext={() => setSearchMatchIdx((p) => p + 1)} onPrev={() => setSearchMatchIdx((p) => p - 1)} onClose={() => { setSearchOpen(false); setSearchQuery(""); setSearchMatchIdx(0); }} matchIdx={safeMatchIdx} totalMatches={totalMatches} inputRef={searchInputRef} /> )} {/* Thread messages */}
{!hasFullBody && messages.length > 0 && ( )} {messages.map((msg, idx) => { const isExpanded = expandedIds.has(msg.id); const isFocused = idx === focusedIndex; const isLast = idx === messages.length - 1; const showComposerAfter = inlineDraft?.replyToId === msg.id; return ( {isExpanded ? ( { if (isFocused) ( focusedRef as React.MutableRefObject ).current = el; if (isLast) lastMessageRef.current = el; }} email={msg} isFocused={isFocused} isFromMe={myEmails.has(msg.from.email.toLowerCase())} onCollapse={() => { setUserToggles((prev) => ({ ...prev, [msg.id]: false })); }} onReply={() => handleReply(msg)} onReplyAll={() => handleReplyAll(msg)} onForward={() => handleForwardMsg(msg)} onFocus={() => setFocusedIndex(idx)} onContactSelect={onContactSelect} searchTerm={searchQuery.trim() || undefined} activeLocalIdx={getActiveLocalIdx(msg.id)} /> ) : ( { if (isFocused) ( focusedRef as React.MutableRefObject ).current = el; if (isLast) lastMessageRef.current = el; }} email={msg} isFocused={isFocused} onClick={() => { setFocusedIndex(idx); setUserToggles((prev) => ({ ...prev, [msg.id]: true })); }} /> )} {showComposerAfter && (
{ const drafts = compose.drafts ?? []; const draft = drafts.find((d: any) => d.id === id); const hasContent = !!( draft?.to?.trim() || draft?.cc?.trim() || draft?.bcc?.trim() || draft?.subject?.trim() || draft?.body?.trim() ); const snapshot = draft ? { ...draft } : null; compose.close(id); if (hasContent && snapshot) { toast("Draft saved.", { action: { label: "REOPEN", onClick: () => { const { id: _id, ...reopenData } = snapshot; compose.open({ ...reopenData, inline: true }); }, }, cancel: { label: "DELETE DRAFT", onClick: () => { if (snapshot.savedDraftId) { fetch( appApiPath( `/api/emails/${snapshot.savedDraftId}`, ), { method: "DELETE", }, ); } }, }, }); } }} onPopOut={(id) => compose.update(id, { inline: false })} onFlush={compose.flush} onReopen={(state) => compose.open({ ...state, inline: true }) } />
)}
); })} {/* Inline reply composer fallback (replyToId not matched) */} {inlineDraft && !messages.some((m) => m.id === inlineDraft.replyToId) && (
{ const drafts = compose.drafts ?? []; const draft = drafts.find((d: any) => d.id === id); const hasContent = !!( draft?.to?.trim() || draft?.cc?.trim() || draft?.bcc?.trim() || draft?.subject?.trim() || draft?.body?.trim() ); const snapshot = draft ? { ...draft } : null; compose.close(id); if (hasContent && snapshot) { toast("Draft saved.", { action: { label: "REOPEN", onClick: () => { const { id: _id, ...reopenData } = snapshot; compose.open({ ...reopenData, inline: true }); }, }, cancel: { label: "DELETE DRAFT", onClick: () => { if (snapshot.savedDraftId) { fetch( appApiPath( `/api/emails/${snapshot.savedDraftId}`, ), { method: "DELETE", }, ); } }, }, }); } }} onPopOut={(id) => compose.update(id, { inline: false })} onFlush={compose.flush} onReopen={(state) => compose.open({ ...state, inline: true })} />
)} {/* Reply prompt when no draft open */} {!inlineDraft && (
handleReply()} > Reply
)}
{/* Mobile bottom action bar */} {isMobile && ( updateSettings.mutate({ mobileActions: actions }) } /> )}
); } function ThreadLoadingState({ onBack, preview, }: { onBack: () => void; preview?: { subject: string; from: { name: string; email: string }; date: string; snippet: string; to: { name: string; email: string }[]; }; }) { const threadSubject = preview?.subject?.replace(/^(Re|Fwd|Fw):\s*/i, ""); return (
Back (Esc)
{preview ? (

{threadSubject}

) : (
)}
{preview ? (
{preview.from.name || preview.from.email} {formatEmailDate(preview.date)}
To: {preview.to.map((r) => r.name || r.email).join(", ")}

{preview.snippet}

) : ( <> )}
); } function ThreadMessageSkeleton({ compact = false }: { compact?: boolean }) { if (compact) { return (
); } return (
); } function BodySkeleton() { const bar = "animate-pulse rounded-md bg-muted-foreground/10 h-3"; return (
); } // ─── Collapsed message row (Superhuman style) ──────────────────────────────── const CollapsedMessageRow = forwardRef< HTMLDivElement, { email: EmailMessage; isFocused?: boolean; onClick: () => void; } >(function CollapsedMessageRow({ email, isFocused, onClick }, ref) { const senderFirst = (email.from.name || email.from.email).split(" ")[0]; return (
{senderFirst} {email.snippet} {formatEmailDate(email.date)}
); }); // ─── Expanded message card (Superhuman style) ──────────────────────────────── const ExpandedMessageCard = forwardRef< HTMLDivElement, { email: EmailMessage; isFocused?: boolean; isFromMe?: boolean; onCollapse: () => void; onReply: () => void; onReplyAll: () => void; onForward: () => void; onFocus?: () => void; onContactSelect?: (email: string) => void; searchTerm?: string; activeLocalIdx?: number | null; } >(function ExpandedMessageCard( { email, isFocused, isFromMe, onCollapse, onReply, onReplyAll, onForward, onFocus, onContactSelect, searchTerm, activeLocalIdx, }, ref, ) { const t = useT(); const [showDetails, setShowDetails] = useState(false); const senderName = email.from.name || email.from.email; const recipients = [ ...email.to.map((r) => r.name || r.email), ...(email.cc || []).map((r) => r.name || r.email), ].join(", "); const formatContact = (c: { name: string; email: string }) => c.name && c.name !== c.email ? `${c.name} <${c.email}>` : c.email; const renderContactLink = ( c: { name: string; email: string }, i: number, arr: { name: string; email: string }[], ) => ( {i < arr.length - 1 && ", "} ); return (
{/* Header */} {showDetails ? (
From
To {email.to.map(renderContactLink)}
{email.cc && email.cc.length > 0 && (
Cc {email.cc.map(renderContactLink)}
)}
{new Date(email.date).toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric", })}{" "} at{" "} {new Date(email.date).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", timeZoneName: "short", })}
) : (
{/* Reply / Reply All / Forward buttons */}
Reply {t("mail.mobileActions.replyAll")} Forward
{formatEmailDate(email.date)}
)} {/* Body */}
{email.bodyHtml ? ( ) : email.body ? ( ) : email.snippet ? (

{email.snippet}

) : ( )}
{/* Attachments */} {email.attachments && email.attachments.length > 0 && (
{/* Image thumbnails */} {email.attachments.some((a) => a.mimeType.startsWith("image/")) && (
{email.attachments .filter((a) => a.mimeType.startsWith("image/")) .map((att) => { const url = att.url ? appApiPath(att.url) : appApiPath( `/api/attachments?messageId=${email.id}&id=${encodeURIComponent(att.id)}&mimeType=${encodeURIComponent(att.mimeType)}`, ); return ( {att.filename} {att.filename} ); })}
)} {/* Non-image files + download all */}
{email.attachments .filter((a) => !a.mimeType.startsWith("image/")) .map((att) => ( {att.filename} {formatFileSize(att.size)} ))} {email.attachments.length > 1 && ( )}
)} {isFromMe && }
); }); // ─── Tracking footer (opens / clicks on sent messages) ─────────────────────── function formatRelativeTime(ts: number): string { const diff = Date.now() - ts; const s = Math.floor(diff / 1000); if (s < 60) return "just now"; const m = Math.floor(s / 60); if (m < 60) return `${m}m ago`; const h = Math.floor(m / 60); if (h < 24) return `${h}h ago`; const d = Math.floor(h / 24); if (d < 7) return `${d}d ago`; const w = Math.floor(d / 7); if (w < 5) return `${w}w ago`; return new Date(ts).toLocaleDateString(); } function TrackingFooter({ messageId }: { messageId: string }) { const { data } = useEmailTracking(messageId); if (!data) return null; const { opens, lastOpenedAt, totalClicks } = data; if (opens === 0 && totalClicks === 0) return null; const parts: string[] = []; if (opens > 0) { parts.push(`Opened ${opens} ${opens === 1 ? "time" : "times"}`); if (lastOpenedAt) parts.push(`last ${formatRelativeTime(lastOpenedAt)}`); } if (totalClicks > 0) { parts.push(`${totalClicks} link ${totalClicks === 1 ? "click" : "clicks"}`); } return (
{parts.join(" · ")}
); } // ─── Plain text body with quoted text trimming ─────────────────────────────── /** Detect where an email signature begins in plain text (standard "-- " separator) */ function findSignatureStart(lines: string[], beforeLine?: number): number { const limit = beforeLine != null ? beforeLine : lines.length; for (let i = 0; i < limit; i++) { if (/^--\s*$/.test(lines[i].trim())) return i; } return -1; } /** Detect where quoted/forwarded content begins in a plain text email */ function findQuoteStart(lines: string[]): number { for (let i = 0; i < lines.length; i++) { // "On ... wrote:" pattern (with optional em-dash/dash prefix) if (/^[—–-]*\s*On .+ wrote:$/i.test(lines[i].trim())) return i; // "--- Original Message ---" / "--- Forwarded message ---" if (/^-{2,}\s*(Original|Forwarded)\s/i.test(lines[i].trim())) return i; // Outlook/Word reply headers are often plain text blocks: // From: ... / Sent: ... / To: ... / Subject: ... if (/^From:\s+/i.test(lines[i].trim())) { const headerWindow = lines .slice(i, i + 8) .map((line) => line.trim()) .join(" "); if ( /\bSent:\s+/i.test(headerWindow) && /\bSubject:\s+/i.test(headerWindow) ) { return i > 0 && lines[i - 1].trim() === "" ? i - 1 : i; } } // Block of consecutive ">" quoted lines (at least 2) if ( lines[i].trimStart().startsWith(">") && i + 1 < lines.length && lines[i + 1].trimStart().startsWith(">") ) { // Walk back to include any blank line or "On ... wrote:" right before let start = i; if (start > 0 && lines[start - 1].trim() === "") start--; if (start > 0 && /^On .+ wrote:$/i.test(lines[start - 1].trim())) start--; return start; } } return -1; } function PlainTextBody({ body, searchTerm, activeLocalIdx, }: { body: string; searchTerm?: string; activeLocalIdx?: number | null; }) { const t = useT(); const [showQuoted, setShowQuoted] = useState(false); const [showSig, setShowSig] = useState(false); const containerRef = useRef(null); const lines = body.split("\n"); const quoteStart = findQuoteStart(lines); const hasQuoted = quoteStart >= 0; const sigStart = findSignatureStart( lines, hasQuoted ? quoteStart : undefined, ); const hasSig = sigStart >= 0; // When searching, show all content (including quoted/sig) so matches aren't hidden const forceShowAll = !!searchTerm; // Determine visible lines: body → [sig toggle] → [quote toggle] let visibleLines: string[]; if (forceShowAll) { visibleLines = lines; } else if (hasSig && !showSig) { visibleLines = lines.slice(0, sigStart); } else if (hasQuoted && !showQuoted) { visibleLines = lines.slice(0, quoteStart); } else { visibleLines = lines; } // Scroll active match into view useEffect(() => { if (activeLocalIdx == null || !containerRef.current) return; const mark = containerRef.current.querySelectorAll("mark[data-search]")[ activeLocalIdx ] as HTMLElement | undefined; mark?.scrollIntoView({ behavior: "smooth", block: "nearest" }); }, [activeLocalIdx]); // Render text with search highlights const renderHighlighted = (text: string, globalMatchOffset: number) => { if (!searchTerm) return text || "\u00a0"; const q = searchTerm.toLowerCase(); const lower = text.toLowerCase(); const nodes: React.ReactNode[] = []; let matchCount = globalMatchOffset; let idx = 0; let pos = lower.indexOf(q); while (pos !== -1) { if (pos > idx) nodes.push(text.slice(idx, pos)); const isActive = matchCount === activeLocalIdx; nodes.push( {text.slice(pos, pos + searchTerm.length)} , ); matchCount++; idx = pos + searchTerm.length; pos = lower.indexOf(q, idx); } if (idx < text.length) nodes.push(text.slice(idx)); return nodes.length > 0 ? nodes : text || "\u00a0"; }; // Count matches in lines above the current one so we can track global match index per line const countMatchesInText = (text: string) => { if (!searchTerm) return 0; const q = searchTerm.toLowerCase(); const lower = text.toLowerCase(); let count = 0; let i = lower.indexOf(q); while (i !== -1) { count++; i = lower.indexOf(q, i + q.length); } return count; }; let cumulativeMatches = 0; return (
{visibleLines.map((line, i) => { const offset = cumulativeMatches; cumulativeMatches += countMatchesInText(line); return (

{renderHighlighted(line, offset)}

); })} {hasSig && !showSig && !forceShowAll && ( )} {hasQuoted && !showQuoted && !forceShowAll && (showSig || !hasSig) && ( )}
); } // ─── HTML email body (iframe) ──────────────────────────────────────────────── // Let normalized dark-mode emails inherit the message card's themed surface. // The iframe and its document are transparent, so theme token changes stay in sync. const IFRAME_BG_DARK = "transparent"; const IFRAME_BG_LIGHT = "#ffffff"; function buildEmailIframeCss( useDarkIframeCss: boolean, iframeBackground: string, ): string { return useDarkIframeCss ? ` html, body { margin: 0; padding: 0; background: ${iframeBackground} !important; color: #e4e4e7 !important; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; overflow: hidden; color-scheme: dark; } /* Force dark backgrounds on all container elements */ div, table, tr, td, th, span, p, blockquote, pre, ul, ol, li, h1, h2, h3, h4, h5, h6, header, footer, section, article, form, fieldset, center, font, main, aside, nav { background-color: transparent !important; background-image: none !important; } /* Default text color for elements that don't have readable inline colors */ body, div, p, span, td, th, li, h1, h2, h3, h4, h5, h6, font, strong, em, b, i, u, small, label, dt, dd, pre, code, blockquote { color: inherit; } a { color: #818cf8 !important; } img { max-width: 100%; height: auto; } hr { border-color: rgba(255,255,255,0.1) !important; } .quoted-hidden { display: none; } .sig-collapsed { display: none; } .quote-toggle, .sig-toggle { display: inline-block; cursor: pointer; color: rgba(161,161,170,0.5); font-size: 13px; letter-spacing: 0.15em; padding: 2px 0; border: none; background: none; margin-top: 4px; } .quote-toggle:hover, .sig-toggle:hover { color: rgba(161,161,170,0.8); } ` : ` html, body { margin: 0; padding: 0; background: ${iframeBackground}; color: #1a1a1a; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; overflow: hidden; } img { max-width: 100%; height: auto; } .quoted-hidden { display: none; } .sig-collapsed { display: none; } .quote-toggle, .sig-toggle { display: inline-block; cursor: pointer; color: rgba(0,0,0,0.4); font-size: 13px; letter-spacing: 0.15em; padding: 2px 0; border: none; background: none; margin-top: 4px; } .quote-toggle:hover, .sig-toggle:hover { color: rgba(0,0,0,0.7); } `; } // ─── Color utilities for dark-mode email processing ───────────────────────── const NAMED_COLORS: Record = { // Dark colors black: [0, 0, 0], navy: [0, 0, 128], darkblue: [0, 0, 139], darkgreen: [0, 100, 0], maroon: [128, 0, 0], darkred: [139, 0, 0], brown: [165, 42, 42], purple: [128, 0, 128], indigo: [75, 0, 130], midnightblue: [25, 25, 112], darkslategray: [47, 79, 79], darkslategrey: [47, 79, 79], dimgray: [105, 105, 105], dimgrey: [105, 105, 105], gray: [128, 128, 128], grey: [128, 128, 128], // Light/white colors (needed for background detection) white: [255, 255, 255], snow: [255, 250, 250], ivory: [255, 255, 240], floralwhite: [255, 250, 240], ghostwhite: [248, 248, 255], whitesmoke: [245, 245, 245], seashell: [255, 245, 238], linen: [250, 240, 230], beige: [245, 245, 220], oldlace: [253, 245, 230], antiquewhite: [250, 235, 215], aliceblue: [240, 248, 255], mintcream: [245, 255, 250], lavender: [230, 230, 250], // Mid-range colors red: [255, 0, 0], green: [0, 128, 0], blue: [0, 0, 255], yellow: [255, 255, 0], cyan: [0, 255, 255], magenta: [255, 0, 255], orange: [255, 165, 0], teal: [0, 128, 128], silver: [192, 192, 192], lightgray: [211, 211, 211], lightgrey: [211, 211, 211], }; function parseColorToRgb( color: string, ): { r: number; g: number; b: number } | null { const c = color.trim().toLowerCase(); if (NAMED_COLORS[c]) { const [r, g, b] = NAMED_COLORS[c]; return { r, g, b }; } // Hex: #RGB or #RRGGBB const hexMatch = c.match(/^#([0-9a-f]{3,8})$/); if (hexMatch) { const hex = hexMatch[1]; if (hex.length === 3) { return { r: parseInt(hex[0] + hex[0], 16), g: parseInt(hex[1] + hex[1], 16), b: parseInt(hex[2] + hex[2], 16), }; } if (hex.length >= 6) { return { r: parseInt(hex.slice(0, 2), 16), g: parseInt(hex.slice(2, 4), 16), b: parseInt(hex.slice(4, 6), 16), }; } } // rgb(r, g, b) or rgba(r, g, b, a) const rgbMatch = c.match( /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/, ); if (rgbMatch) { const alpha = rgbMatch[4] !== undefined ? parseFloat(rgbMatch[4]) : 1; if (alpha < 0.5) return null; return { r: parseInt(rgbMatch[1]), g: parseInt(rgbMatch[2]), b: parseInt(rgbMatch[3]), }; } return null; } function relativeLuminance(r: number, g: number, b: number): number { const [rs, gs, bs] = [r, g, b].map((ch) => { const s = ch / 255; return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); }); return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs; } /** Convert RGB to HSL (h: 0-360, s: 0-1, l: 0-1) */ function rgbToHsl( r: number, g: number, b: number, ): { h: number; s: number; l: number } { r /= 255; g /= 255; b /= 255; const max = Math.max(r, g, b); const min = Math.min(r, g, b); const l = (max + min) / 2; if (max === min) return { h: 0, s: 0, l }; const d = max - min; const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); let h = 0; if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6; else if (max === g) h = ((b - r) / d + 2) / 6; else h = ((r - g) / d + 4) / 6; return { h: h * 360, s, l }; } /** Convert HSL back to RGB */ function hslToRgb( h: number, s: number, l: number, ): { r: number; g: number; b: number } { h /= 360; if (s === 0) { const v = Math.round(l * 255); return { r: v, g: v, b: v }; } const hue2rgb = (p: number, q: number, t: number) => { if (t < 0) t += 1; if (t > 1) t -= 1; if (t < 1 / 6) return p + (q - p) * 6 * t; if (t < 1 / 2) return q; if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; return p; }; const q = l < 0.5 ? l * (1 + s) : l + s - l * s; const p = 2 * l - q; return { r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255), g: Math.round(hue2rgb(p, q, h) * 255), b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255), }; } /** * Transform a dark color to its light equivalent for dark mode. * Preserves hue and saturation, lightens the color. * e.g. dark blue (#00008B) → light blue (#8B8BFF), black → #e4e4e7 * Returns null if the color doesn't need transformation (already light enough). */ function lightenColorForDarkMode(colorStr: string): string | null { const rgb = parseColorToRgb(colorStr); if (!rgb) return null; const lum = relativeLuminance(rgb.r, rgb.g, rgb.b); // Don't transform colors that are already readable on dark bg if (lum >= 0.15) return null; const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); // For near-black/gray (no saturation), use our standard light text color if (hsl.s < 0.1) return "#e4e4e7"; // Lighten: mirror the lightness around 0.5 and boost // A dark color at L=0.2 becomes L=0.7, L=0.1 becomes L=0.75 const newL = Math.min(0.85, Math.max(0.6, 1 - hsl.l)); const newRgb = hslToRgb(hsl.h, Math.min(hsl.s, 0.85), newL); return `rgb(${newRgb.r}, ${newRgb.g}, ${newRgb.b})`; } /** * Check if the email has intentional colored (non-white) backgrounds * that indicate a designed layout. White/near-white backgrounds are NOT * considered "designed" — they're just the default and we override them to dark. * Returns true only for colored backgrounds (e.g. blue banners, gray sections). */ function emailHasDesignedBackground(html: string): boolean { const lower = html.toLowerCase(); if ( !lower.includes("style") && !lower.includes("bgcolor") && !lower.includes("background") ) { return false; } const parser = new DOMParser(); const doc = parser.parseFromString(html, "text/html"); // Check for non-white background colors on body/html const checkBg = (colorStr: string): boolean => { const rgb = parseColorToRgb(colorStr.trim()); if (!rgb) return false; const lum = relativeLuminance(rgb.r, rgb.g, rgb.b); // White/near-white (lum > 0.85) → not "designed", just default // Very dark (lum < 0.05) → already dark, no issue // Everything else (colored backgrounds) → designed layout return lum <= 0.85 && lum >= 0.05; }; const body = doc.body; const bodyStyle = body?.getAttribute("style") || ""; const bgMatch = bodyStyle.match(/background(?:-color)?\s*:\s*([^;!]+)/i); if (bgMatch && checkBg(bgMatch[1])) return true; const bodyBg = body?.getAttribute("bgcolor"); if (bodyBg && checkBg(bodyBg)) return true; const htmlBg = doc.documentElement?.getAttribute("bgcolor"); if (htmlBg && checkBg(htmlBg)) return true; // Check