@bobfrankston/rmfmail 1.1.115 → 1.1.117
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/app.bundle.js.map +1 -1
- package/client/app.js +1 -1
- package/client/app.ts +1 -1
- package/client/components/message-viewer.js +1 -1
- package/client/components/message-viewer.js.map +1 -1
- package/client/components/message-viewer.ts +1 -1
- package/package.json +1 -2
- package/packages/mailx-service/index.d.ts +1 -0
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +34 -14
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +34 -15
- /package/packages/mailx-imap/{node_modules.npmglobalize-stash-77936 → node_modules.npmglobalize-stash-48732}/.package-lock.json +0 -0
package/client/app.bundle.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["lib/api-client.ts", "components/context-menu.ts", "lib/message-state.ts", "../packages/mailx-types/contact-rules.ts", "../packages/mailx-types/contacts-config.ts", "../packages/mailx-types/groups.ts", "../packages/mailx-types/index.ts", "components/message-viewer.ts", "components/folder-picker.ts", "components/message-list.ts", "components/outbox-view.ts", "components/calendar-sidebar.ts", "components/address-book.ts", "components/alarms.ts", "help/search-help.ts", "components/folder-tree.ts", "components/tabs.ts", "app.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * API client \u2014 all operations go through the IPC bridge (mailxapi).\n * mailxapi is injected by the launcher (msger on desktop, MAUI on Android).\n */\n\ndeclare const mailxapi: any;\n\nfunction getIpc(): any {\n if (typeof mailxapi !== \"undefined\" && mailxapi?.isApp) return mailxapi;\n if ((window as any).opener?.mailxapi?.isApp) return (window as any).opener.mailxapi;\n // Compose iframe \u2014 check parent\n if ((window as any).parent?.mailxapi?.isApp) return (window as any).parent.mailxapi;\n return null;\n}\n\n/** Build a proxy bridge that forwards every method call to the parent window\n * via postMessage. Used when the compose iframe's own attempt to reach\n * msger's IPC silently drops messages \u2014 empirically, `sendMessage` and\n * `saveDraft` both hit this (user-visible: \"Sending\u2026\" spinner forever;\n * \"Draft save failed: mailxapi timeout\"). The main window's bridge is\n * provably fine, so the iframe routes through it. */\nfunction buildRelayBridge(): any {\n const pending = new Map<string, { resolve: (v: any) => void; reject: (e: any) => void; timer: any }>();\n window.addEventListener(\"message\", (ev: MessageEvent) => {\n if (!ev.data || ev.data.type !== \"mailx-ipc-result\" || !ev.data.id) return;\n const entry = pending.get(ev.data.id);\n if (!entry) return;\n pending.delete(ev.data.id);\n clearTimeout(entry.timer);\n if (ev.data.ok) entry.resolve(ev.data.result);\n else entry.reject(new Error(ev.data.error || \"parent-relay ipc error\"));\n });\n const call = (method: string, args: any[]): Promise<any> => {\n const id = `ipc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;\n return new Promise((resolve, reject) => {\n const timer = setTimeout(() => {\n pending.delete(id);\n reject(new Error(`parent-relay timeout: ${method}`));\n }, 120000);\n pending.set(id, { resolve, reject, timer });\n try {\n (window.parent as any).postMessage({ type: \"mailx-ipc\", id, method, args }, \"*\");\n } catch (e) {\n clearTimeout(timer);\n pending.delete(id);\n reject(e);\n }\n });\n };\n // Proxy: any property access returns a function that forwards to parent.\n // `isApp` / `platform` / other non-function reads return sensible defaults\n // so existing getIpc-style checks still work.\n return new Proxy({}, {\n get(_t, prop: string) {\n if (prop === \"isApp\") return true;\n if (prop === \"platform\") return (window.parent as any)?.mailxapi?.platform || \"webview2\";\n if (prop === \"onEvent\") {\n // Event subscription can't be relayed simply \u2014 iframes that need\n // events are rare. Fall back to direct parent bridge for onEvent\n // since the subscription path doesn't hit the broken send path.\n return (handler: any) => (window.parent as any)?.mailxapi?.onEvent?.(handler);\n }\n return (...args: any[]) => call(prop, args);\n }\n });\n}\n\nlet cachedRelayBridge: any = null;\nfunction ipc(): any {\n // Direct bridge is fine for the top window (main mailx app). The iframe\n // (compose) can't trust its own bridge resolution because msger-routed\n // sendMessage / saveDraft IPCs disappear without trace. So when we're in\n // a child frame with a parent bridge, go through the parent.\n const inIframe = window.parent && window.parent !== window;\n if (inIframe && (window.parent as any)?.mailxapi?.isApp) {\n if (!cachedRelayBridge) cachedRelayBridge = buildRelayBridge();\n return cachedRelayBridge;\n }\n const bridge = getIpc();\n if (!bridge) throw new Error(\"IPC bridge not available\");\n return bridge;\n}\n\n// \u2500\u2500 Abort controller for message-list requests \u2500\u2500\n\nlet messageListAbort: AbortController | null = null;\n\nexport function abortMessageListRequests(): void {\n if (messageListAbort) {\n messageListAbort.abort();\n messageListAbort = null;\n }\n}\n\n// \u2500\u2500 API Methods \u2500\u2500\n\nexport function getAccounts() {\n return ipc().getAccounts();\n}\n\nexport function getFolders(accountId: string) {\n return ipc().getFolders(accountId);\n}\n\nexport function getMessages(accountId: string, folderId: number, page = 1, pageSize = 50, flaggedOnly = false, sort?: string, sortDir?: string) {\n abortMessageListRequests();\n return ipc().getMessages(accountId, folderId, page, pageSize, sort, sortDir, undefined, flaggedOnly);\n}\n\nexport function getUnifiedInbox(page = 1, pageSize = 50) {\n abortMessageListRequests();\n return ipc().getUnifiedInbox(page, pageSize);\n}\n\nexport function searchMessages(query: string, page = 1, pageSize = 50, scope = \"all\", accountId = \"\", folderId = 0, includeTrashSpam = false) {\n return ipc().searchMessages(query, page, pageSize, scope, accountId, folderId, includeTrashSpam);\n}\n\n/** Abort any in-flight server (IMAP) search. Fire-and-forget \u2014 the daemon\n * bumps a generation counter its per-folder loop checks between batches. */\nexport function cancelServerSearch() {\n return ipc().cancelServerSearch?.();\n}\n\nexport function getMessage(accountId: string, uid: number, allowRemote = false, folderId?: number) {\n return ipc().getMessage(accountId, uid, allowRemote, folderId);\n}\n\nexport function updateFlags(accountId: string, uid: number, flags: string[]) {\n return ipc().updateFlags(accountId, uid, flags);\n}\n\nexport function triggerSync() {\n return ipc().syncAll();\n}\n\nexport function syncAccount(accountId: string) {\n return ipc().syncAccount(accountId);\n}\n\nexport function reauthenticate(accountId: string) {\n return ipc().reauthenticate(accountId);\n}\n\nexport function reauthGoogleScopes(): Promise<{ cleared: number }> {\n return (ipc() as any).reauthGoogleScopes();\n}\n\nexport function getSyncPending() {\n return ipc().getSyncPending();\n}\n\nexport function getDiagnostics(): Promise<any> {\n return ipc().getDiagnostics?.() ?? Promise.resolve([]);\n}\n\n/** Account that supplies `feature` data (calendar / tasks / contacts).\n * Resolution: per-feature primary flag \u2192 catch-all `primary` \u2192 first account.\n * Pass e.g. \"calendar\" to honor `primaryCalendar:true` overrides; omit for\n * back-compat single-flag behavior. */\nexport function getPrimaryAccount(feature?: string): Promise<any> {\n return ipc().getPrimaryAccount?.(feature) ?? Promise.resolve(null);\n}\n\n// Calendar / Tasks: two-way cache. Reads return local-cached rows; writes\n// commit locally and queue a push to Google. Service layer handles drain.\nexport function getCalendarEvents(fromMs: number, toMs: number): Promise<any[]> {\n return ipc().getCalendarEvents?.(fromMs, toMs) ?? Promise.resolve([]);\n}\n/** List the user's selected Google calendars (id, name, color, primary) so\n * the sidebar can render a checkbox + icon per calendar. */\nexport function getCalendars(): Promise<Array<{ id: string; name: string; color: string; primary: boolean }>> {\n return ipc().getCalendars?.() ?? Promise.resolve([]);\n}\nexport function createCalendarEvent(ev: {\n title: string; startMs: number; endMs: number; allDay?: boolean;\n location?: string; notes?: string;\n}): Promise<{ uuid: string }> {\n return ipc().createCalendarEvent?.(ev);\n}\nexport function updateCalendarEvent(uuid: string, patch: any): Promise<{ ok: boolean }> {\n return ipc().updateCalendarEvent?.(uuid, patch);\n}\nexport function deleteCalendarEvent(uuid: string): Promise<{ ok: boolean }> {\n return ipc().deleteCalendarEvent?.(uuid);\n}\nexport function getTasks(includeCompleted = false): Promise<any[]> {\n return ipc().getTasks?.(includeCompleted) ?? Promise.resolve([]);\n}\nexport function createTask(t: { title: string; notes?: string; dueMs?: number }): Promise<{ uuid: string }> {\n return ipc().createTask?.(t);\n}\nexport function updateTask(uuid: string, patch: any): Promise<{ ok: boolean }> {\n return ipc().updateTask?.(uuid, patch);\n}\nexport function deleteTask(uuid: string): Promise<{ ok: boolean }> {\n return ipc().deleteTask?.(uuid);\n}\nexport function drainStoreSync(): Promise<{ ok: boolean }> {\n return ipc().drainStoreSync?.();\n}\n\n/** Report the currently-viewed message as spam \u2192 appends a row to\n * `~/.mailx/spam.csv`. Placeholder: no folder move, no flag change, no\n * auto-delete. Training data for a smarter pass later. */\nexport function recordSpamReport(accountId: string, uid: number, folderId: number): Promise<{ ok: boolean }> {\n return ipc().recordSpamReport?.(accountId, uid, folderId);\n}\n\nexport function getOutboxStatus() {\n return (ipc() as any).getOutboxStatus();\n}\n\nexport function listQueuedOutgoing() {\n return (ipc() as any).listQueuedOutgoing();\n}\n\nexport function cancelQueuedOutgoing(p: string) {\n return (ipc() as any).cancelQueuedOutgoing(p);\n}\n\nexport function searchContacts(query: string) {\n return ipc().searchContacts(query);\n}\n\nexport function hasCcHistoryTo(email: string): Promise<{ hasCc: boolean }> {\n return (ipc() as any).hasCcHistoryTo(email);\n}\n\nexport function hasBccHistoryTo(email: string): Promise<{ hasBcc: boolean }> {\n return (ipc() as any).hasBccHistoryTo(email);\n}\n\nexport function listContacts(query: string, page = 1, pageSize = 100) {\n return (ipc() as any).listContacts(query, page, pageSize);\n}\n\nexport function upsertContact(name: string, email: string) {\n return (ipc() as any).upsertContact(name, email);\n}\n\nexport function deleteContact(email: string) {\n return (ipc() as any).deleteContact(email);\n}\n\nexport function addPreferredContact(entry: { name: string; email: string; source?: string; organization?: string }) {\n return (ipc() as any).addPreferredContact(entry.name, entry.email, entry.source, entry.organization);\n}\n\nexport function getPriorityLists(): Promise<{ senders: string[]; domains: string[] }> {\n return (ipc() as any).getPriorityLists();\n}\n\nexport function setPrioritySender(email: string, value: boolean, name?: string): Promise<{ ok: boolean }> {\n return (ipc() as any).setPrioritySender(email, value, name);\n}\n\nexport function setPriorityDomain(domain: string, value: boolean): Promise<{ ok: boolean }> {\n return (ipc() as any).setPriorityDomain(domain, value);\n}\n\nexport function addToDenylist(email: string) {\n return (ipc() as any).addToDenylist(email);\n}\n\nexport function openLocalPath(which: \"config\" | \"log\") {\n return (ipc() as any).openLocalPath(which);\n}\n/** Open an absolute file path (under ~/.rmfmail) in the OS default\n * *text* editor \u2014 Notepad on Windows, TextEdit on Mac, $EDITOR or\n * xdg-open(.txt) on Linux. Distinct from the file's default app,\n * which for .eml is usually Outlook / a mail client. */\nexport function openInTextEditor(path: string): Promise<{ ok: boolean; opener: string; reason?: string }> {\n return (ipc() as any).openInTextEditor?.(path) ?? Promise.resolve({ ok: false, opener: \"none\", reason: \"no host\" });\n}\n\nexport function allowRemoteContent(type: string, value: string) {\n return ipc().allowRemoteContent(type, value);\n}\nexport function getUserDict(): Promise<string[]> {\n return (ipc() as any).getUserDict?.() ?? Promise.resolve([]);\n}\nexport function addUserDictWord(word: string): Promise<string[]> {\n return (ipc() as any).addUserDictWord?.(word) ?? Promise.resolve([]);\n}\nexport function addUserDictWords(words: string[]): Promise<string[]> {\n return (ipc() as any).addUserDictWords?.(words) ?? Promise.resolve([]);\n}\nexport function removeUserDictWord(word: string): Promise<string[]> {\n return (ipc() as any).removeUserDictWord?.(word) ?? Promise.resolve([]);\n}\nexport function flagSenderOrDomain(type: \"sender\" | \"domain\", value: string): Promise<{ flagged: boolean }> {\n return (ipc() as any).flagSenderOrDomain?.(type, value) ?? Promise.resolve({ flagged: false });\n}\n\nexport function deleteMessage(accountId: string, uid: number) {\n return ipc().deleteMessage?.(accountId, uid);\n}\n\nexport function deleteMessages(accountId: string, uids: number[]) {\n if (uids.length === 1) return deleteMessage(accountId, uids[0]);\n return ipc().deleteMessages?.(accountId, uids);\n}\n\nexport function moveMessages(accountId: string, uids: number[], targetFolderId: number, targetAccountId?: string) {\n if (uids.length === 1) return moveMessage(accountId, uids[0], targetFolderId, targetAccountId);\n return ipc().moveMessages?.(accountId, uids, targetFolderId, targetAccountId);\n}\n\nexport function markAsSpamMessages(accountId: string, uids: number[]): Promise<{ targetFolderId: number; moved: number }> {\n return ipc().markAsSpamMessages?.(accountId, uids);\n}\n\nexport function undeleteMessage(accountId: string, uid: number, folderId: number) {\n return ipc().undeleteMessage?.(accountId, uid, folderId);\n}\n\nexport function moveMessage(accountId: string, uid: number, targetFolderId: number, targetAccountId?: string) {\n return ipc().moveMessage?.(accountId, uid, targetFolderId, targetAccountId);\n}\n\nexport function restartServer() {\n return ipc().restart?.();\n}\n\nexport function markFolderRead(accountId: string, folderId: number) {\n return ipc().markFolderRead?.(accountId, folderId);\n}\n\nexport function createFolder(accountId: string, parentPath: string, name: string) {\n return ipc().createFolder?.(accountId, parentPath, name);\n}\n\nexport function renameFolder(accountId: string, folderId: number, newName: string) {\n return ipc().renameFolder?.(accountId, folderId, newName);\n}\n\nexport function deleteFolder(accountId: string, folderId: number) {\n return ipc().deleteFolder?.(accountId, folderId);\n}\n\nexport function moveFolderToTrash(accountId: string, folderId: number) {\n return ipc().moveFolderToTrash?.(accountId, folderId);\n}\n\nexport function emptyFolder(accountId: string, folderId: number) {\n return ipc().emptyFolder?.(accountId, folderId);\n}\n\n/** Ship a named event to the Node log as `[client] <tag> <data>`. Fire and\n * forget \u2014 never awaits, never throws, never blocks the caller. Tries two\n * paths so a broken primary channel can't swallow the trace:\n * 1. Direct bridge call (self / opener / parent mailxapi).\n * 2. parent.postMessage fallback \u2014 the main window listens and relays.\n * The fallback matters because the whole point of tracing is to diagnose\n * a broken iframe bridge; a single-path tracer that goes through that same\n * bridge is useless in exactly the case we need it. */\nexport function logClientEvent(tag: string, data?: any): void {\n let delivered = false;\n try {\n const bridge = typeof (globalThis as any).mailxapi !== \"undefined\" && (globalThis as any).mailxapi?.isApp ? (globalThis as any).mailxapi\n : (window as any).opener?.mailxapi?.isApp ? (window as any).opener.mailxapi\n : (window as any).parent?.mailxapi?.isApp ? (window as any).parent.mailxapi\n : null;\n if (bridge?.logClientEvent) {\n bridge.logClientEvent(tag, data);\n delivered = true;\n }\n } catch { /* never throw from tracing */ }\n try {\n if (window.parent && window.parent !== window) {\n (window.parent as any).postMessage({ type: \"mailx-trace\", tag, data, bridged: delivered }, \"*\");\n }\n } catch { /* */ }\n}\n\nexport function sendMessage(body: any) {\n return ipc().sendMessage?.(body);\n}\n\nexport function saveDraft(body: any) {\n return ipc().saveDraft?.(body);\n}\n\n// \u2500\u2500 Events \u2500\u2500\n\ntype EventHandler = (event: any) => void;\nconst eventHandlers: EventHandler[] = [];\n\nexport function onEvent(handler: EventHandler): () => void {\n eventHandlers.push(handler);\n return () => {\n const i = eventHandlers.indexOf(handler);\n if (i >= 0) eventHandlers.splice(i, 1);\n };\n}\n\n// \u2500\u2500 Store-bus subscriptions (mirrors packages/mailx-store/bus.ts) \u2500\u2500\n//\n// The service forwards every storeBus event over IPC as `{ _event: \"store\",\n// topic, kind, ... }`. Mirror the topic/wildcard semantics here so consumers\n// subscribe by topic instead of filtering inside a single onEvent callback.\n// Same shape as the server-side bus \u2014 that's the load-bearing property.\n\ntype StoreEvent = { topic: string; kind: string; [k: string]: any };\ntype StoreHandler = (event: StoreEvent) => void;\nconst storeSubs = new Map<string, Set<StoreHandler>>();\n\nexport function subscribeStore(topic: string, handler: StoreHandler): () => void {\n let set = storeSubs.get(topic);\n if (!set) { set = new Set(); storeSubs.set(topic, set); }\n set.add(handler);\n return () => {\n const s = storeSubs.get(topic);\n if (s) { s.delete(handler); if (s.size === 0) storeSubs.delete(topic); }\n };\n}\n\nfunction deliverStore(event: StoreEvent): void {\n const exact = storeSubs.get(event.topic);\n if (exact) for (const h of exact) { try { h(event); } catch (e) { console.error(\"[store-bus]\", e); } }\n const wild = storeSubs.get(\"*\");\n if (wild) for (const h of wild) { try { h(event); } catch (e) { console.error(\"[store-bus]\", e); } }\n}\n\nexport function connectEvents(): void {\n ipc().onEvent((event: any) => {\n if (event && event._event === \"store\") deliverStore(event as StoreEvent);\n for (const h of eventHandlers) h(event);\n });\n}\n\n// \u2500\u2500 Autocomplete \u2500\u2500\n\nexport function autocomplete(body: { subject: string; to: string; bodyText: string; cursorOffset: number }, signal?: AbortSignal) {\n return ipc().autocomplete?.(body);\n}\n\nexport function getAutocompleteSettings() {\n return ipc().getAutocompleteSettings?.();\n}\n\nexport function saveAutocompleteSettings(settings: any) {\n return ipc().saveAutocompleteSettings?.(settings);\n}\n\nexport function getVersion() {\n return ipc().getVersion();\n}\n\nexport function getSettings() {\n return ipc().getSettings();\n}\n\nexport function saveSettings(settings: any) {\n return ipc().saveSettingsData?.(settings);\n}\n\nexport function repairAccounts() {\n return ipc().repairAccounts?.();\n}\n\nexport function deleteDraft(accountId: string, draftUid: number, draftId?: string) {\n return ipc().deleteDraft?.(accountId, draftUid, draftId);\n}\n\nexport function addContact(name: string, email: string) {\n return ipc().addContact?.(name, email);\n}\n\nexport function getThreadMessages(accountId: string, threadId: string) {\n return ipc().getThreadMessages?.(accountId, threadId);\n}\n\nexport function readJsoncFile(name: string): Promise<{ content: string | null }> {\n return ipc().readJsoncFile?.(name);\n}\nexport function writeJsoncFile(name: string, content: string): Promise<{ ok: boolean }> {\n return ipc().writeJsoncFile?.(name, content);\n}\nexport function formatJsonc(content: string): Promise<{ content: string }> {\n return (ipc() as any).formatJsonc?.(content);\n}\nexport function readConfigHelp(name: string): Promise<{ content: string }> {\n return ipc().readConfigHelp?.(name) ?? Promise.resolve({ content: \"\" });\n}\nexport function unsubscribeOneClick(url: string): Promise<{ ok: boolean; status: number; statusText: string }> {\n return ipc().unsubscribeOneClick?.(url);\n}\nexport function openInWord(editId: string, html: string): Promise<{ ok: boolean; path: string; opener: string }> {\n return (ipc() as any).openInWord?.(editId, html) ?? Promise.resolve({ ok: false, path: \"\", opener: \"none\" });\n}\nexport function closeWordEdit(editId: string): Promise<void> {\n return (ipc() as any).closeWordEdit?.(editId) ?? Promise.resolve();\n}\n\n/** Show an OS-level always-on-top reminder popup via msger. Returns the\n * label of the button the user clicked. Empty string when the host\n * isn't available (browser fallback) \u2014 caller should fall back to an\n * in-WebView popup in that case. */\nexport function showReminderPopup(opts: {\n title: string;\n html: string;\n buttons: string[];\n size?: { width: number; height: number };\n pos?: { x: number; y: number };\n}): Promise<{ button: string; form?: any; reason?: string }> {\n return (ipc() as any).showReminderPopup?.(opts) ?? Promise.resolve({ button: \"\", reason: \"no host\" });\n}\n\n/** Read + delete a pending mailto: drop file, if any (P115). Used at app\n * startup so a `mailx --mailto <url>` that just spawned us doesn't lose\n * its compose payload to the daemon-fires-before-app-registers race\n * window. Subsequent live clicks arrive via the `openMailto` event. */\nexport function consumePendingMailto(): Promise<{\n to: string[]; cc: string[]; bcc: string[];\n subject: string; body: string; inReplyTo: string;\n} | null> {\n return ipc().consumePendingMailto?.() ?? Promise.resolve(null);\n}\n\n/** Run an AI text transform (translate / proofread / summarize). Returns\n * empty `text` with a `reason` when the feature is disabled or the provider\n * errors \u2014 caller should surface `reason` in a status bar, not throw. */\nexport function aiTransform(req: {\n action: \"translate\" | \"proofread\" | \"summarize\" | \"extractEvent\";\n text: string;\n targetLang?: string;\n nowISO?: string;\n}): Promise<{ text: string; reason?: string; event?: any }> {\n return ipc().aiTransform?.(req) ?? Promise.resolve({ text: \"\", reason: \"AI not available in this host\" });\n}\n\nexport function setupAccount(name: string, email: string, password: string) {\n return ipc().setupAccount?.(name, email, password);\n}\n\nexport async function getAttachment(accountId: string, uid: number, attachmentId: number, folderId?: number): Promise<{ content: string; contentType: string; filename: string }> {\n return ipc().getAttachment(accountId, uid, attachmentId, folderId);\n}\n\n/** Desktop: have the Node service save the attachment and open it with the\n * OS default app. Returns undefined when the host has no such method (real\n * browser / --server mode) so the caller can fall back to a blob download. */\nexport async function openAttachment(accountId: string, uid: number, attachmentId: number, folderId?: number): Promise<{ ok: boolean; path: string } | undefined> {\n const fn = (ipc() as any).openAttachment;\n return fn ? fn(accountId, uid, attachmentId, folderId) : undefined;\n}\n\nexport async function getDeviceAccounts(): Promise<{ email: string; name: string }[]> {\n return ipc().getDeviceAccounts?.() ?? [];\n}\n\n// Legacy exports for backward compatibility\nexport const connectWebSocket = connectEvents;\nexport const onWsEvent = onEvent;\n", "/**\n * Simple context menu component.\n * Shows a menu at a given position with clickable items.\n */\n\nlet activeMenu: HTMLElement | null = null;\nlet dismissListener: ((e: Event) => void) | null = null;\nlet escapeListener: ((e: KeyboardEvent) => void) | null = null;\n\nexport interface MenuItem {\n label: string;\n action: () => void;\n disabled?: boolean;\n separator?: boolean;\n /** Native browser tooltip shown on hover (title attribute). Use it\n * to explain non-obvious side effects of an action \u2014 e.g., \"skips\n * Trash, no undo\" so the user can tell two near-identical entries\n * apart without trial-and-error. */\n tooltip?: string;\n}\n\n/** Close any open context menu and remove dismiss listeners */\nexport function closeContextMenu(): void {\n if (activeMenu) {\n activeMenu.remove();\n activeMenu = null;\n }\n if (dismissListener) {\n document.removeEventListener(\"pointerdown\", dismissListener, true);\n dismissListener = null;\n }\n if (escapeListener) {\n document.removeEventListener(\"keydown\", escapeListener, true);\n escapeListener = null;\n }\n}\n\n/** Show a context menu at the given position */\nexport function showContextMenu(x: number, y: number, items: MenuItem[]): void {\n closeContextMenu();\n\n const menu = document.createElement(\"div\");\n menu.className = \"ctx-menu\";\n\n for (const item of items) {\n if (item.separator) {\n const sep = document.createElement(\"div\");\n sep.className = \"ctx-sep\";\n menu.appendChild(sep);\n continue;\n }\n const el = document.createElement(\"div\");\n el.className = \"ctx-item\" + (item.disabled ? \" ctx-disabled\" : \"\");\n el.textContent = item.label;\n if (item.tooltip) el.title = item.tooltip;\n if (!item.disabled) {\n el.addEventListener(\"click\", () => {\n closeContextMenu();\n item.action();\n });\n }\n menu.appendChild(el);\n }\n\n menu.style.left = `${x}px`;\n menu.style.top = `${y}px`;\n document.body.appendChild(menu);\n\n // Adjust if menu goes off-screen\n const rect = menu.getBoundingClientRect();\n if (rect.right > window.innerWidth) menu.style.left = `${x - rect.width}px`;\n if (rect.bottom > window.innerHeight) menu.style.top = `${y - rect.height}px`;\n\n activeMenu = menu;\n\n // Dismiss on click/tap outside the menu. Uses pointerdown in capture phase\n // so it fires before any child handler and catches both left- and right-clicks.\n // Deferred by one frame so the opening pointerdown doesn't immediately close it.\n requestAnimationFrame(() => {\n dismissListener = (e: Event) => {\n if (activeMenu && !activeMenu.contains(e.target as Node)) {\n closeContextMenu();\n }\n };\n document.addEventListener(\"pointerdown\", dismissListener, true);\n\n escapeListener = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") {\n e.preventDefault();\n e.stopPropagation();\n closeContextMenu();\n }\n };\n document.addEventListener(\"keydown\", escapeListener, true);\n });\n}\n\n// Scroll anywhere closes the menu (capture phase so nested scrollers trigger it)\ndocument.addEventListener(\"scroll\", closeContextMenu, true);\n// A new right-click that opens a different menu goes through showContextMenu\u2192closeContextMenu\ndocument.addEventListener(\"contextmenu\", () => { /* handled by showContextMenu */ });\n// Iframe pointerdown forwarded from preview/compose iframes \u2014 needed because\n// iframe events don't bubble to the parent document, so the dismissListener\n// (which hooks document.pointerdown) doesn't see clicks INSIDE iframes. The\n// message-viewer's inline iframe script posts {type:\"iframePointerDown\"} on\n// every non-right-click pointerdown.\nwindow.addEventListener(\"message\", (e: MessageEvent) => {\n if (e.data && (e.data as any).type === \"iframePointerDown\") closeContextMenu();\n});\n", "/**\n * Shared message store \u2014 the messages currently rendered in the list.\n *\n * Selection state used to live here too, with a broadcast-subscribe model\n * that let the viewer subscribe to \"selected changed\" events independently\n * of the list. That made drift between the highlighted row and the preview\n * pane possible: two subscribers, two render paths, two opportunities for\n * a missed update or stale read. Selection is now owned by message-list\n * (the only thing that has DOM rows in the first place); the viewer is a\n * passive `show(msg)` / `clear()` API the list calls directly. With one\n * code path, drift is structurally impossible.\n *\n * What remains here: the array of currently-loaded messages, plus the\n * removeMessages helper that filters the array and lets the caller decide\n * which row to focus next (if any) \u2014 that policy is the list's, not the\n * store's.\n */\n\nexport interface ListMessage {\n accountId: string;\n uid: number;\n folderId: number;\n subject: string;\n from: { name: string; address: string };\n to: { name: string; address: string }[];\n cc: { name: string; address: string }[];\n date: number;\n flags: string[];\n size: number;\n preview: string;\n hasAttachments: boolean;\n [key: string]: any;\n}\n\nlet messages: ListMessage[] = [];\n\n/** Subscribers fire when the messages array is replaced or filtered.\n * Selection state is NOT broadcast \u2014 that lives in message-list and\n * reaches the viewer through a single direct call, never through this\n * fan-out. Subscribers here are observers only (status bar, thread\n * filter, etc.); they don't drive the preview pane. */\ntype MessagesListener = () => void;\nconst listeners: MessagesListener[] = [];\n\nexport function subscribe(fn: MessagesListener): () => void {\n listeners.push(fn);\n return () => {\n const i = listeners.indexOf(fn);\n if (i >= 0) listeners.splice(i, 1);\n };\n}\nfunction notify(): void {\n for (const fn of listeners) {\n try { fn(); } catch { /* don't let one subscriber break others */ }\n }\n}\n\n/** Replace the entire message list (folder load, search, unified inbox). */\nexport function setMessages(msgs: ListMessage[]): void {\n messages = msgs;\n notify();\n}\n\nexport function getMessages(): ListMessage[] {\n return messages;\n}\n\n/** Result of a removeMessages call: the list-controller uses this to\n * decide what to focus after a delete/move. */\nexport interface RemovalOutcome {\n /** True if any of the removed messages was previously focused \u2014 caller\n * must transition the focus (to nextSurvivor or clear the viewer). */\n focusedWasRemoved: boolean;\n /** Identity of the message that should become the new focus, or null\n * when the list is now empty / no survivor exists. The list looks up\n * its row by this identity and calls its own focus-row method. */\n nextSurvivor: { accountId: string; uid: number; folderId: number } | null;\n}\n\n/**\n * Remove messages from the list (after move or delete).\n *\n * Returns a RemovalOutcome the caller uses to drive focus handoff. We\n * pre-capture the next-survivor *by identity* (not by index) before the\n * filter mutates the array, so subsequent index shifts don't matter \u2014 if\n * every deleted row had a survivor below it, the chosen survivor is the\n * same regardless of how many got deleted. Falls back to walking\n * backward when the deletion is at the bottom.\n */\nexport function removeMessages(\n uids: { accountId: string; uid: number }[],\n currentlyFocused: { accountId: string; uid: number } | null,\n): RemovalOutcome {\n const removeSet = new Set(uids.map(u => `${u.accountId}:${u.uid}`));\n const focusedKey = currentlyFocused ? `${currentlyFocused.accountId}:${currentlyFocused.uid}` : null;\n const focusedWasRemoved = focusedKey !== null && removeSet.has(focusedKey);\n\n let nextSurvivor: ListMessage | null = null;\n if (focusedWasRemoved) {\n let lastRemovedIdx = -1;\n for (let i = 0; i < messages.length; i++) {\n if (removeSet.has(`${messages[i].accountId}:${messages[i].uid}`)) lastRemovedIdx = i;\n }\n for (let i = lastRemovedIdx + 1; i < messages.length; i++) {\n if (!removeSet.has(`${messages[i].accountId}:${messages[i].uid}`)) {\n nextSurvivor = messages[i];\n break;\n }\n }\n if (!nextSurvivor) {\n for (let i = lastRemovedIdx - 1; i >= 0; i--) {\n if (!removeSet.has(`${messages[i].accountId}:${messages[i].uid}`)) {\n nextSurvivor = messages[i];\n break;\n }\n }\n }\n }\n\n // dupeCount fix-up: if we removed one half of a unified-inbox duplicate\n // pair, the surviving row's \u21C6 marker should drop now rather than wait\n // for the next server fetch. Decrement once per remaining row whose\n // messageId matches a removed one.\n const removedIds = new Set<string>();\n for (const m of messages) {\n if (removeSet.has(`${m.accountId}:${m.uid}`) && m.messageId) {\n removedIds.add(m.messageId);\n }\n }\n messages = messages.filter(m => !removeSet.has(`${m.accountId}:${m.uid}`));\n if (removedIds.size > 0) {\n for (const m of messages) {\n if (m.messageId && removedIds.has(m.messageId) && typeof (m as any).dupeCount === \"number\") {\n (m as any).dupeCount = Math.max(0, (m as any).dupeCount - 1);\n }\n }\n }\n notify();\n\n return {\n focusedWasRemoved,\n nextSurvivor: nextSurvivor\n ? { accountId: nextSurvivor.accountId, uid: nextSurvivor.uid, folderId: nextSurvivor.folderId }\n : null,\n };\n}\n\n/** Update flags on a message in the list. List re-renders the row's\n * flag/unread classes inline; no broadcast. */\nexport function updateMessageFlags(accountId: string, uid: number, flags: string[]): void {\n const msg = messages.find(m => m.uid === uid && m.accountId === accountId);\n if (msg) msg.flags = flags;\n}\n\nimport { subscribeStore } from \"./api-client.js\";\n\n/** Subscribe to Store-bus flag changes so any source \u2014 UI click, server-side\n * sync, another window \u2014 updates this in-memory list. The IPC click\u2192render\n * path was already optimistic (caller patches `msg.flags` before awaiting),\n * so this listener is the redundancy that catches the cases where the local\n * patch and the canonical Store row diverge (server pushed a different flag\n * set during the round trip, sync rebound the row, etc.). */\nsubscribeStore(\"*\", (event) => {\n if (event.kind !== \"flagsChanged\") return;\n const accountId = event.accountId as string | undefined;\n const uid = event.uid as number | undefined;\n const flags = event.flags as string[] | undefined;\n if (!accountId || typeof uid !== \"number\" || !Array.isArray(flags)) return;\n const msg = messages.find(m => m.uid === uid && m.accountId === accountId);\n if (!msg) return;\n const before = JSON.stringify(msg.flags);\n msg.flags = flags;\n if (before !== JSON.stringify(flags)) notify();\n});\n", "// AUTO-GENERATED from contact-rules.jsonc \u2014 do not edit.\n// Edit the .jsonc and run `node build-rules.js` (or `npm run build`).\n\nexport const CONTACT_RULES = {\n \"rulesVersion\": \"v3-domain-oneoff\",\n \"junk\": {\n \"localExact\": \"^(no-?reply|do-?not-?reply|noreply|mailer-daemon|postmaster|abuse|automated|bounce(s|d)?|list-?(server|admin|owner|manager)?|notification|notifications?|admin@.*automated|root|daemon|nobody|undisclosed)$\",\n \"localSuffix\": \"(-bounces|\\\\+bounces|-noreply|-no-reply|-notifications?|-mailer)$\",\n \"localPrefix\": \"^(no-?reply|noreply|do-?not-?reply|donotreply|notifications?|alerts?|bounces?|mailer)[-_+]\",\n \"localOneoff\": \"^[0-9a-f]{4}\\\\.[0-9a-f]{4}(\\\\.[0-9a-z]{6})?$\",\n \"domain\": \"^(txt\\\\.voice\\\\.google\\\\.com|reply\\\\.facebook\\\\.com|reply\\\\.linkedin\\\\.com)$\"\n }\n} as const;\n", "/**\n * Shared `contacts.jsonc` mutations \u2014 denylist + preferred.\n *\n * Pure logic with cloud I/O injected, so the desktop service\n * (`mailx-service`, Node) and the Android service (`mailx-store-web`, in the\n * WebView) run ONE implementation instead of each keeping its own copy.\n * `web-service.ts` used to stub `addToDenylist` / `addPreferredContact` as\n * `notImpl()`, which is why the phone's \u2298 / \u2605 buttons did nothing.\n *\n * Nothing here is platform-specific: it's a `cloudRead` \u2192 mutate \u2192 `cloudWrite`\n * round-trip. Each caller passes its own platform's cloud functions.\n */\n\nexport type CloudReadFn = (filename: string) => Promise<string | null>;\n// Return type is intentionally loose \u2014 desktop `cloudWrite` resolves void,\n// the Android one resolves a boolean; the shared code ignores the result.\nexport type CloudWriteFn = (filename: string, content: string) => Promise<unknown>;\n\nexport interface PreferredContactEntry {\n name: string;\n email: string;\n source?: string;\n organization?: string;\n}\n\n/** Loose-parse a JSONC blob. The file mailx writes is plain JSON\n * (`JSON.stringify`), but a hand-edit may add `//` comments or trailing\n * commas \u2014 strip those and retry rather than losing the whole file. */\nfunction parseContactsConfig(raw: string | null): Record<string, unknown> {\n if (!raw) return {};\n try { return JSON.parse(raw) || {}; } catch { /* fall through to loose */ }\n try {\n const stripped = raw\n .replace(/^\\s*\\/\\/.*$/gm, \"\") // line comments\n .replace(/,(\\s*[}\\]])/g, \"$1\"); // trailing commas\n return JSON.parse(stripped) || {};\n } catch { return {}; }\n}\n\n/** Append an email to `contacts.jsonc#denylist[]` \u2014 idempotent and\n * case-insensitive. Writes only when the entry is genuinely new. */\nexport async function addContactsDenylistEntry(\n email: string, cloudRead: CloudReadFn, cloudWrite: CloudWriteFn,\n): Promise<void> {\n const lower = (email || \"\").trim().toLowerCase();\n if (!lower) return;\n const cfg = parseContactsConfig(await cloudRead(\"contacts.jsonc\")) as any;\n if (!Array.isArray(cfg.denylist)) cfg.denylist = [];\n if (cfg.denylist.some((e: any) => (e || \"\").toLowerCase() === lower)) return;\n cfg.denylist.push(email);\n await cloudWrite(\"contacts.jsonc\", JSON.stringify(cfg, null, 2));\n}\n\n/** Append a preferred contact to `contacts.jsonc#preferred[]` \u2014 dedup by\n * source|email|name. Writes only when the entry is genuinely new. */\nexport async function addContactsPreferredEntry(\n entry: PreferredContactEntry, cloudRead: CloudReadFn, cloudWrite: CloudWriteFn,\n): Promise<void> {\n if (!entry?.email) return;\n const cfg = parseContactsConfig(await cloudRead(\"contacts.jsonc\")) as any;\n if (!Array.isArray(cfg.preferred)) cfg.preferred = [];\n const key = (s: string, e: string, n: string): string =>\n `${(s || \"preferred\").toLowerCase()}|${(e || \"\").toLowerCase()}|${(n || \"\").toLowerCase()}`;\n const dupKey = key(entry.source || \"\", entry.email, entry.name || \"\");\n if (cfg.preferred.some((e: any) => key(e?.source || \"\", e?.email || \"\", e?.name || \"\") === dupKey)) return;\n const row: Record<string, string> = { name: entry.name || \"\", email: entry.email };\n if (entry.source) row.source = entry.source;\n if (entry.organization) row.organization = entry.organization;\n cfg.preferred.push(row);\n await cloudWrite(\"contacts.jsonc\", JSON.stringify(cfg, null, 2));\n}\n", "/**\n * Group expansion \u2014 turns a recipient string with group names into a flat\n * deduplicated address list. Shared between desktop (mailx-service) and\n * Android (mailx-store-web) so send paths produce the same result.\n *\n * Group source: contacts.jsonc \u2192 groups: Record<string, string[]>. Each\n * value is an array of either email addresses (\"a@b\" or \"Name <a@b>\") or\n * names of other groups (recursive aliasing). Cycles are detected; depth\n * is capped at 10 nested levels.\n */\n\n/** Raw recipient string from a To/Cc/Bcc field; may contain group names. */\nexport type RecipientToken = string;\n\n/** Map of group name \u2192 member list (each entry: address or another group name). */\nexport type GroupMap = Record<string, string[]>;\n\n/** Result of expanding a recipient string. */\nexport interface ExpansionResult {\n /** Flat unique address list (preserving order of first occurrence). */\n addresses: string[];\n /** Group names that couldn't be resolved (typo / cycle) \u2014 surface in UI. */\n unresolved: string[];\n}\n\n/** Split a comma-or-semicolon recipient string into tokens. Respects\n * angle-bracket address blocks (\"Name, Suffix <a@b>\") so a comma inside\n * the display name doesn't split the entry. */\nexport function splitRecipients(raw: string): RecipientToken[] {\n const out: string[] = [];\n let buf = \"\";\n let depth = 0;\n for (let i = 0; i < raw.length; i++) {\n const c = raw[i];\n if (c === \"<\") depth++;\n else if (c === \">\") depth = Math.max(0, depth - 1);\n if ((c === \",\" || c === \";\") && depth === 0) {\n const t = buf.trim();\n if (t) out.push(t);\n buf = \"\";\n } else {\n buf += c;\n }\n }\n const t = buf.trim();\n if (t) out.push(t);\n return out;\n}\n\n/** Quick test: does the token look like an email address (with or without\n * a display name)? Anything containing \"@\" with non-whitespace on both\n * sides counts. */\nexport function isAddressToken(token: string): boolean {\n return /^[^@\\s][^@]*@[^@\\s]+(\\s|$)|<[^>]+@[^>]+>/.test(token) || /^[^\\s<>@]+@[^\\s<>@]+$/.test(token);\n}\n\n/** Extract the bare email address from a token. Returns the original token\n * lowercased if it has no angle-bracket form. */\nexport function extractAddress(token: string): string {\n const m = token.match(/<([^>]+)>/);\n if (m) return m[1].trim().toLowerCase();\n return token.trim().toLowerCase();\n}\n\n/** Expand a recipient string by resolving group names against `groups`.\n * Group names take precedence: if a token matches a group name AND looks\n * like an address, it's treated as a group. (In practice no one names a\n * group \"x@y.com\", so this is rarely a real conflict.) */\nexport function expandRecipients(raw: string, groups: GroupMap): ExpansionResult {\n const tokens = splitRecipients(raw);\n const seen = new Set<string>();\n const addresses: string[] = [];\n const unresolved: string[] = [];\n\n const visit = (token: string, depth: number, visited: Set<string>): void => {\n if (depth > 10) {\n unresolved.push(`${token} (depth limit)`);\n return;\n }\n // Group name match \u2014 case-insensitive\n const groupKey = Object.keys(groups).find(k => k.toLowerCase() === token.trim().toLowerCase());\n if (groupKey) {\n if (visited.has(groupKey.toLowerCase())) {\n unresolved.push(`${groupKey} (cycle)`);\n return;\n }\n const next = new Set(visited);\n next.add(groupKey.toLowerCase());\n for (const member of groups[groupKey] || []) {\n visit(member, depth + 1, next);\n }\n return;\n }\n // Address \u2014 keep the original (with display name if present), but\n // dedupe by lowercased bare address.\n if (isAddressToken(token)) {\n const key = extractAddress(token);\n if (!seen.has(key)) {\n seen.add(key);\n addresses.push(token.trim());\n }\n return;\n }\n // Neither group nor address \u2014 flag.\n unresolved.push(token);\n };\n\n for (const t of tokens) {\n visit(t, 0, new Set());\n }\n return { addresses, unresolved };\n}\n", "/**\n * @bobfrankston/mailx-types\n * Shared type definitions for the mailx email client.\n * This is the contract between client and server.\n */\n\n// Generated rule data \u2014 both desktop store and Android store import via\n// this barrel so a single source-of-truth (contact-rules.jsonc) drives\n// junk-contact filtering on every platform.\nexport { CONTACT_RULES } from \"./contact-rules.js\";\nexport type { MailxApi } from \"./mailx-api.js\";\n// Shared contacts.jsonc mutations \u2014 one implementation for desktop + Android.\nexport {\n addContactsDenylistEntry, addContactsPreferredEntry,\n type PreferredContactEntry, type CloudReadFn, type CloudWriteFn,\n} from \"./contacts-config.js\";\n\n// Group-name expansion for recipient fields. Lets users type a group name\n// (e.g. \"family\") in To/Cc/Bcc and have it expand to the address list at\n// send time. Both desktop and Android send paths consume this expander\n// against contacts.jsonc \u2192 groups.\nexport {\n expandRecipients, splitRecipients, isAddressToken, extractAddress,\n} from \"./groups.js\";\nexport type { GroupMap, RecipientToken, ExpansionResult } from \"./groups.js\";\n\n// \u2500\u2500 Account Configuration \u2500\u2500\n\n/** Supported authentication methods */\nexport type AuthMethod = \"password\" | \"oauth2\";\n\n/** Mail account configuration */\nexport interface AccountConfig {\n id: string; /** Unique account identifier (e.g., \"iecc\", \"gmail-bob\") */\n name: string; /** Sender name for From header (e.g., \"Bob Frankston\") */\n label?: string; /** UI label for account list (e.g., \"Gmail\"). Falls back to name if not set */\n email: string; /** Email address */\n imap: {\n host: string;\n port: number;\n tls: boolean;\n auth: AuthMethod;\n user: string;\n password?: string; /** For password auth */\n oauthClientId?: string; /** For OAuth2 */\n };\n smtp: {\n host: string;\n port: number;\n tls: boolean;\n auth: AuthMethod;\n user: string;\n password?: string;\n };\n enabled: boolean;\n primary?: boolean; /** Catch-all \"this is my main account\" \u2014 default source for Calendar / Tasks / Contacts when no per-feature override set. */\n primaryCalendar?: boolean; /** Per-feature override: use this account's Google Calendar. Falls back to `primary` if unset. */\n primaryTasks?: boolean; /** Per-feature override: use this account's Google Tasks. Falls back to `primary` if unset. */\n primaryContacts?: boolean; /** Per-feature override: use this account's Google Contacts. Falls back to `primary` if unset. */\n defaultSend?: boolean; /** Use this account's SMTP when From doesn't match any account */\n syncContacts?: boolean; /** Sync contacts even when account is disabled (contacts-only Gmail) */\n relayDomains?: string[]; /** Domains to skip in Delivered-To chain (e.g., [\"relay.aaz.lt\"]) */\n identityDomains?: string[]; /** Domains where Delivered-To address should become the reply From (e.g., [\"bob.ma\", \"bobf.frankston.com\"]) */\n spam?: string; /** IMAP folder path for \"Mark as spam\" button (e.g., \"_spam\"). Button hidden when not set. */\n signature?: string; /** Legacy: HTML signature appended to all outgoing messages (new + reply + forward). Plain text or HTML allowed. Superseded by `sig`. */\n sig?: AccountSignature; /** Per-account signature object. Initially appended only to NEW messages; later options will cover replies/forwards. */\n}\n\n/** Signature configuration in accounts.jsonc. Initial shape carries `text`\n * only; `html: true` reserved for future support of raw HTML signatures. */\nexport interface AccountSignature {\n text: string; /** Plain-text signature body. Newlines preserved. Appended to NEW messages with the standard \"-- \" RFC 3676 separator. */\n html?: boolean; /** Future flag: when true, `text` is treated as raw HTML rather than escaped plain text. Currently ignored. */\n}\n\n// \u2500\u2500 Folder Types \u2500\u2500\n\n/** Standard IMAP special-use folder types */\nexport type SpecialUse = \"inbox\" | \"sent\" | \"drafts\" | \"trash\" | \"junk\" | \"archive\" | \"all\";\n\n/** A mailbox folder */\nexport interface Folder {\n id: number;\n accountId: string;\n path: string; /** IMAP path e.g., \"INBOX\", \"INBOX/Projects\" */\n name: string; /** Display name */\n specialUse: SpecialUse; /** Standard folder type, or null for custom */\n delimiter: string; /** IMAP hierarchy delimiter */\n totalCount: number;\n unreadCount: number;\n children: Folder[]; /** Nested subfolders */\n}\n\n// \u2500\u2500 Message flag state \u2500\u2500\n//\n// External API surface for the IMAP system flags. The literal strings\n// (`\"\\\\Seen\"`, `\"\\\\Flagged\"`, etc.) live ONLY inside this module \u2014 every\n// caller speaks in terms of named predicates (`seenOf(msg)`) and verbs\n// (`setSeen(msg, true)`, `toggleSeen(msg)`). That keeps a typo like\n// `\"\\Seen\"` (single backslash) from silently bypassing flag checks\n// elsewhere, and removes the \"two-API leak\" of having both `FLAG.SEEN`\n// constants AND verb helpers exposed simultaneously.\n//\n// These work against any object with a mutable `flags: string[]`\n// property \u2014 `MessageEnvelope`, `Message`, and the row's `msg` reference\n// all qualify. The verbs return `void` and mutate `msg.flags` in place\n// (replacing the array with a fresh one so listeners observing\n// reference identity, e.g. message-state, still re-render). Pure\n// predicate calls (`seenOf`, `flaggedOf`) don't mutate.\n\nconst _SEEN = \"\\\\Seen\";\nconst _FLAGGED = \"\\\\Flagged\";\nconst _ANSWERED = \"\\\\Answered\";\nconst _DRAFT = \"\\\\Draft\";\nconst _DELETED = \"\\\\Deleted\";\n\ninterface FlagBearing { flags: string[]; }\n\nfunction _has(msg: FlagBearing | { flags?: readonly string[] }, flag: string): boolean {\n return !!msg.flags && msg.flags.includes(flag);\n}\nfunction _set(msg: FlagBearing, flag: string, state: boolean): void {\n const present = (msg.flags || []).includes(flag);\n if (state === present) return;\n const next = state\n ? [...(msg.flags || []), flag]\n : (msg.flags || []).filter(f => f !== flag);\n msg.flags = next;\n}\n\nexport const seenOf = (m: { flags?: readonly string[] }): boolean => _has(m, _SEEN);\nexport const flaggedOf = (m: { flags?: readonly string[] }): boolean => _has(m, _FLAGGED);\nexport const answeredOf = (m: { flags?: readonly string[] }): boolean => _has(m, _ANSWERED);\nexport const draftOf = (m: { flags?: readonly string[] }): boolean => _has(m, _DRAFT);\nexport const deletedOf = (m: { flags?: readonly string[] }): boolean => _has(m, _DELETED);\n\nexport const setSeen = (m: FlagBearing, state: boolean): void => _set(m, _SEEN, state);\nexport const setFlagged = (m: FlagBearing, state: boolean): void => _set(m, _FLAGGED, state);\nexport const setAnswered = (m: FlagBearing, state: boolean): void => _set(m, _ANSWERED, state);\nexport const setDraft = (m: FlagBearing, state: boolean): void => _set(m, _DRAFT, state);\n\nexport const toggleSeen = (m: FlagBearing): void => _set(m, _SEEN, !_has(m, _SEEN));\nexport const toggleFlagged = (m: FlagBearing): void => _set(m, _FLAGGED, !_has(m, _FLAGGED));\n\n// \u2500\u2500 Message Types \u2500\u2500\n\n/** Email address with optional display name */\nexport interface EmailAddress {\n name: string; /** Display name, may be empty */\n address: string; /** Email address */\n}\n\n/** Message envelope (headers only, for list display) */\nexport interface MessageEnvelope {\n id: number; /** Local store ID */\n accountId: string;\n folderId: number;\n folderName?: string; /** Leaf folder name; populated by cross-folder search so the UI can tag each hit */\n uid: number; /** IMAP UID (server-side identity; changes on move, UIDVALIDITY bump) */\n uuid?: string; /** Stable local identity, minted once at first-sight; never changes */\n messageId: string; /** RFC Message-ID header */\n inReplyTo: string; /** For threading */\n references: string[]; /** For threading */\n threadId?: string; /** Computed thread id (root Message-ID of the conversation) */\n date: number; /** Epoch ms */\n subject: string;\n from: EmailAddress;\n to: EmailAddress[];\n cc: EmailAddress[];\n flags: string[]; /** IMAP flags: [\"\\\\Seen\", \"\\\\Flagged\"] */\n size: number;\n hasAttachments: boolean;\n isReplied?: boolean; /** Local DB knows the user (or anyone in this account) replied \u2014 derived from In-Reply-To linkage, independent of \\Answered */\n preview: string; /** First ~200 chars of body text */\n bodyPath?: string; /** Local body location: \"idb:...\" or \"gmail:<id>\" */\n providerId?: string; /** Native server id (Gmail hex id, Outlook Graph id) \u2014 bypasses UID\u2192id pagination on body fetch */\n pending?: boolean; /** True when a queued local action (move/flag/delete) hasn't been ACK'd by the server yet \u2014 UI renders pink */\n}\n\n/** Full message with body content */\nexport interface Message extends MessageEnvelope {\n bodyHtml: string; /** HTML body */\n bodyText: string; /** Plain text body */\n attachments: Attachment[];\n}\n\n/** File attachment metadata */\nexport interface Attachment {\n id: number;\n filename: string;\n mimeType: string;\n size: number;\n contentId: string; /** For inline attachments (cid:) */\n}\n\n// \u2500\u2500 API Types \u2500\u2500\n\n/** Paginated list response */\nexport interface PagedResult<T> {\n items: T[];\n total: number;\n page: number;\n pageSize: number;\n}\n\n/** Message list query parameters */\nexport interface MessageQuery {\n accountId: string;\n folderId: number;\n page?: number;\n pageSize?: number;\n sort?: \"date\" | \"from\" | \"subject\";\n sortDir?: \"asc\" | \"desc\";\n search?: string;\n /** Restrict to messages with the \\Flagged flag set (whole-folder, not\n * just the currently-rendered page \u2014 lets the \"show flagged\" filter\n * find stars on messages that haven't been paged in yet). */\n flaggedOnly?: boolean;\n}\n\n/** Compose/send a message */\nexport interface ComposeMessage {\n from: string; /** Account ID to send from */\n to: EmailAddress[];\n cc?: EmailAddress[];\n bcc?: EmailAddress[];\n subject: string;\n bodyHtml: string;\n bodyText?: string;\n inReplyTo?: string; /** Message-ID of message being replied to */\n references?: string[];\n attachments?: { filename: string; data: string; mimeType: string }[];\n}\n\n// \u2500\u2500 Queue Types \u2500\u2500\n\nexport type QueueStatus = \"pending\" | \"sending\" | \"sent\" | \"failed\";\n\nexport interface QueueItem {\n id: number;\n status: QueueStatus;\n createdAt: number;\n sendAfter: number; /** Epoch ms, for deferred send */\n attempts: number;\n lastAttempt: number;\n error: string;\n subject: string;\n to: EmailAddress[];\n}\n\n// \u2500\u2500 WebSocket Event Types \u2500\u2500\n\nexport type WsEvent =\n | { type: \"newMessage\"; accountId: string; folderId: number; message: MessageEnvelope }\n | { type: \"messageDeleted\"; accountId: string; folderId: number; uid: number }\n | { type: \"messageMoved\"; accountId: string; fromFolderId: number; toFolderId: number; uid: number }\n | { type: \"folderCountsChanged\"; accountId: string; counts: Record<number, { total: number; unread: number }> }\n | { type: \"folderSynced\"; accountId: string; entries: { folderId: number; syncedAt: number }[] }\n | { type: \"syncProgress\"; accountId: string; phase: string; progress: number }\n | { type: \"queueUpdate\"; pending: number; sending: number; failed: number }\n | { type: \"connected\" }\n | { type: \"reload\" }\n | { type: \"error\"; message: string }\n | { type: \"accountError\"; accountId: string; error: string; hint: string; isOAuth: boolean };\n\n// \u2500\u2500 Settings Types \u2500\u2500\n\nexport interface MailxSettings {\n accounts: AccountConfig[];\n ui: {\n theme: \"system\" | \"dark\" | \"light\";\n editor: \"quill\" | \"tiptap\";\n folderWidth: number;\n listViewerSplit: number; /** Percentage for message list height */\n fontSize: number;\n };\n sync: {\n intervalMinutes: number;\n historyDays: number; /** 0 = all history */\n prefetch: boolean; /** Download message bodies during sync (default true) */\n };\n store: {\n basePath: string; /** Where message bodies are stored */\n compressionBoundaryDays: number; /** Messages older than this get compressed */\n };\n autocomplete?: AutocompleteSettings;\n}\n\n// \u2500\u2500 Autocomplete Types \u2500\u2500\n\nexport interface AutocompleteSettings {\n enabled: boolean;\n provider: \"ollama\" | \"claude\" | \"openai\" | \"off\";\n ollamaUrl: string;\n ollamaModel: string;\n cloudApiKey: string;\n cloudModel: string;\n debounceMs: number;\n maxTokens: number;\n /** Per-feature opt-in for non-autocomplete AI helpers. All default false\n * per user preference (2026-04-21): AI features should be controlled by\n * a flag, initially OFF in settings. Provider config is shared with\n * autocomplete (provider, cloudApiKey, cloudModel, etc.). */\n translateEnabled?: boolean;\n proofreadEnabled?: boolean;\n}\n\n/** AI provider API keys. Lives at the top of `accounts.jsonc` alongside\n * `accounts:` so all secrets are co-located in one cloud-synced file. The\n * keys field replaces `preferences.jsonc.autocomplete.cloudApiKey` (which\n * was tied to the *active* provider) so a user can set both Anthropic and\n * OpenAI keys once and switch providers without re-entering anything. */\nexport interface AiKeys {\n anthropic?: string;\n openai?: string;\n}\n\nexport interface AutocompleteRequest {\n subject: string;\n to: string;\n bodyText: string;\n cursorOffset: number;\n}\n\nexport interface AutocompleteResponse {\n suggestion: string;\n}\n\n// \u2500\u2500 AI Transform (translate / proofread / summarize) \u2500\u2500\n\nexport interface AiTransformRequest {\n /** translate = render in `targetLang`; proofread = corrected version\n * with grammar/spelling fixes; summarize = short paragraph summary;\n * extractEvent = parse natural-language event description into a\n * structured calendar event (title/start/end/location/notes). */\n action: \"translate\" | \"proofread\" | \"summarize\" | \"extractEvent\";\n text: string;\n /** ISO-639-1 (or BCP-47) language code for translate. Defaults to \"en\". */\n targetLang?: string;\n /** Caller's \"now\" hint, ISO 8601 (e.g. 2026-05-05T18:30:00). Used by\n * extractEvent to resolve relative dates (\"tomorrow at 3pm\"). The\n * service can't trust its own clock \u2014 the user might be on a phone\n * in a different timezone \u2014 so the caller supplies it. */\n nowISO?: string;\n}\n\n/** Structured calendar event fields parsed from a natural-language\n * description by aiTransform({ action: \"extractEvent\" }). All times are\n * ISO 8601 in the caller's local timezone (no `Z` suffix); the client\n * shapes them into Google Calendar's `dates=` URL parameter. */\nexport interface ExtractedEvent {\n title: string;\n startISO?: string;\n endISO?: string;\n allDay?: boolean;\n location?: string;\n notes?: string;\n}\n\nexport interface AiTransformResponse {\n /** Transformed text. Empty when AI is disabled / provider error / feature\n * not enabled \u2014 caller should treat empty as \"no result\". For\n * extractEvent this holds the JSON-stringified ExtractedEvent so the\n * response shape stays single-field across all actions. */\n text: string;\n /** Parsed ExtractedEvent, populated only for extractEvent. Lets the\n * client skip the JSON.parse step (the service already validated). */\n event?: ExtractedEvent;\n /** Optional reason for empty result, surfaced to UI status bar. */\n reason?: string;\n}\n\n// \u2500\u2500 Store Interface \u2500\u2500\n\n/** Body storage backend interface -- implementations are swappable */\nexport interface MessageStore {\n putMessage(accountId: string, folderId: number, uid: number, raw: Buffer): Promise<string>;\n getMessage(accountId: string, folderId: number, uid: number): Promise<Buffer>;\n deleteMessage(accountId: string, folderId: number, uid: number): Promise<void>;\n hasMessage(accountId: string, folderId: number, uid: number): Promise<boolean>;\n}\n\n// \u2500\u2500 Shared Utilities \u2500\u2500\n// Pure functions used by both desktop (mailx-service) and Android (web-service).\n// Kept here to avoid duplication \u2014 both platforms import from mailx-types.\n\n/** Sanitize HTML for safe display \u2014 strips scripts, inline handlers, remote images, forms, iframes. */\nexport function sanitizeHtml(html: string): { html: string; hasRemoteContent: boolean } {\n let hasRemoteContent = false;\n let clean = html.replace(/<script\\b[^>]*>[\\s\\S]*?<\\/script>/gi, \"\");\n clean = clean.replace(/\\s+on\\w+\\s*=\\s*(\"[^\"]*\"|'[^']*'|[^\\s>]+)/gi, \"\");\n clean = clean.replace(/<img\\b([^>]*)\\bsrc\\s*=\\s*(\"[^\"]*\"|'[^']*')/gi, (match, before, src) => {\n const url = src.slice(1, -1);\n if (url.startsWith(\"data:\") || url.startsWith(\"cid:\")) return match;\n hasRemoteContent = true;\n return `<img${before}src=\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Crect fill='%23888' width='20' height='20' rx='3'/%3E%3Ctext x='10' y='14' text-anchor='middle' fill='white' font-size='12'%3E\u2298%3C/text%3E%3C/svg%3E\" data-blocked-src=${src} title=\"Remote image blocked\"`;\n });\n clean = clean.replace(/<link\\b[^>]*rel\\s*=\\s*[\"']stylesheet[\"'][^>]*>/gi, (match) => {\n hasRemoteContent = true;\n return `<!-- blocked: ${match.replace(/--/g, \"\")} -->`;\n });\n clean = clean.replace(/url\\s*\\(\\s*(['\"]?)(https?:\\/\\/[^)]+)\\1\\s*\\)/gi, (_match, _q, url) => {\n hasRemoteContent = true;\n return `url(\"\") /* blocked: ${url} */`;\n });\n clean = clean.replace(/<\\/?form\\b[^>]*>/gi, \"\");\n // Strip every interactive form element. Even without the surrounding\n // <form>, an <input type=\"password\"> in an email body is dangerous \u2014\n // WebView2's password manager offers to autofill it, and the user\n // might type a real password into what looks like an in-email control.\n // Same for <textarea>, <select>/<option>, and <button>. Bob 2026-05-14:\n // observed a phishing-shaped \"CHANGE YOUR PASSWORD\" email rendering\n // with working input fields + the OS autofill chip. Replace with a\n // visible \"[blocked: input]\" marker so the user sees something was\n // stripped (a silent removal would hide the phishing attempt entirely\n // \u2014 they'd think \"this email is just text\" when it actually tried to\n // capture credentials).\n clean = clean.replace(/<input\\b[^>]*\\/?>/gi, \"[blocked: form input]\");\n clean = clean.replace(/<textarea\\b[^>]*>[\\s\\S]*?<\\/textarea>/gi, \"[blocked: textarea]\");\n clean = clean.replace(/<select\\b[^>]*>[\\s\\S]*?<\\/select>/gi, \"[blocked: select]\");\n clean = clean.replace(/<button\\b[^>]*>([\\s\\S]*?)<\\/button>/gi, \"$1\");\n clean = clean.replace(/<iframe\\b[^>]*>[\\s\\S]*?<\\/iframe>/gi, \"\");\n return { html: clean, hasRemoteContent };\n}\n\n/** Encode text as RFC 2045 quoted-printable. */\nexport function encodeQuotedPrintable(text: string): string {\n const encoder = new TextEncoder();\n const bytes = encoder.encode(text);\n let line = \"\";\n let result = \"\";\n for (let i = 0; i < bytes.length; i++) {\n const b = bytes[i];\n let encoded: string;\n if (b === 0x0D && bytes[i + 1] === 0x0A) {\n result += line + \"\\r\\n\";\n line = \"\";\n i++;\n continue;\n } else if (b === 0x0A) {\n result += line + \"\\r\\n\";\n line = \"\";\n continue;\n } else if ((b >= 33 && b <= 126 && b !== 61) || b === 9 || b === 32) {\n encoded = String.fromCharCode(b);\n } else {\n encoded = \"=\" + b.toString(16).toUpperCase().padStart(2, \"0\");\n }\n if (line.length + encoded.length > 75) {\n result += line + \"=\\r\\n\";\n line = \"\";\n }\n line += encoded;\n }\n result += line;\n return result;\n}\n\n/** Render an HTML document as a plain-text approximation suitable for the\n * text/plain alternative part of a multipart/alternative outgoing MIME\n * message. Not a full HTML-to-text engine \u2014 just enough to give non-HTML\n * clients (plain-text readers, spam filters scoring on text/plain, people\n * who turned HTML off) a readable fallback. Preserves line breaks for\n * `<br>` / `</p>` / `</div>` / `<li>`, strips all other tags, decodes the\n * common HTML entities, and collapses runs of whitespace.\n *\n * Spam filters (SpamAssassin, Rspamd) penalise HTML-only mail aggressively;\n * shipping a real text part typically drops the score by 1\u20132 points. Also\n * matches the behaviour of every other mainstream mail client \u2014 sending a\n * text/html part alone marks mailx as an outlier in mail logs. */\nexport function htmlToPlainText(html: string): string {\n if (!html) return \"\";\n let s = html;\n // Drop <style> / <script> entirely (their contents aren't readable text).\n s = s.replace(/<style\\b[^>]*>[\\s\\S]*?<\\/style>/gi, \"\");\n s = s.replace(/<script\\b[^>]*>[\\s\\S]*?<\\/script>/gi, \"\");\n // Block-level breaks \u2014 treat closing tags as line terminators so\n // paragraphs don't run together.\n s = s.replace(/<br\\s*\\/?\\s*>/gi, \"\\n\");\n s = s.replace(/<\\/(p|div|li|tr|h[1-6]|blockquote|pre|section|article)\\s*>/gi, \"\\n\");\n // List-item leading bullet (rough but readable).\n s = s.replace(/<li\\b[^>]*>/gi, \" \u2022 \");\n // Anchor: keep href in parens after the text so URLs survive.\n s = s.replace(/<a\\b[^>]*href\\s*=\\s*(['\"])([^'\"]*)\\1[^>]*>([\\s\\S]*?)<\\/a>/gi,\n (_m, _q, href, text) => {\n const t = text.replace(/<[^>]+>/g, \"\").trim();\n return t && t !== href ? `${t} (${href})` : href;\n });\n // Strip remaining tags.\n s = s.replace(/<[^>]+>/g, \"\");\n // Decode a pragmatic set of HTML entities \u2014 the rare ones survive as-is.\n s = s.replace(/ /gi, \" \")\n .replace(/&/gi, \"&\")\n .replace(/</gi, \"<\")\n .replace(/>/gi, \">\")\n .replace(/"/gi, \"\\\"\")\n .replace(/'/gi, \"'\")\n .replace(/'/gi, \"'\")\n .replace(/—/gi, \"\u2014\")\n .replace(/–/gi, \"\u2013\")\n .replace(/…/gi, \"\u2026\")\n .replace(/&#(\\d+);/g, (_m, n) => String.fromCodePoint(parseInt(n, 10)))\n .replace(/&#x([0-9a-f]+);/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16)));\n // Normalise whitespace: collapse runs of spaces/tabs, trim per-line,\n // cap consecutive blank lines at 2.\n s = s.replace(/[ \\t]+/g, \" \")\n .split(\"\\n\").map(l => l.replace(/^[ \\t]+|[ \\t]+$/g, \"\")).join(\"\\n\")\n .replace(/\\n{3,}/g, \"\\n\\n\")\n .trim();\n return s;\n}\n\n/** Parse search query into structured conditions.\n * Supports qualifiers: from:, to:, subject:, date:, has:attachment,\n * is:flagged, is:unread, is:read. Unqualified terms search across subject /\n * from / preview. Returns { conditions, params } for SQL WHERE clause with\n * LIKE plus structured predicates (flags_json LIKE, has_attachments=1, date\n * range comparisons).\n *\n * Date syntax (matches Gmail-ish conventions):\n * - date:2026-04-22 exact day\n * - date:2026-04 month\n * - date:>2026-04-01 after\n * - date:<2026-04-01 before\n * - date:2026-04-01..2026-04-30 range\n * - date:today / yesterday / last7 / last30\n */\nexport function parseSearchQuery(query: string): { conditions: string[]; params: (string | number)[] } {\n const parts = query.match(/(?:[^\\s\"]+|\"[^\"]*\")+/g) || [];\n const conditions: string[] = [];\n const params: (string | number)[] = [];\n\n const dayStart = (y: number, m: number, d: number) => new Date(y, m - 1, d).getTime();\n const parseDateSpec = (spec: string): { from?: number; to?: number } | null => {\n const now = new Date();\n const today0 = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();\n if (spec === \"today\") return { from: today0, to: today0 + 86400_000 };\n if (spec === \"yesterday\") return { from: today0 - 86400_000, to: today0 };\n const lastN = spec.match(/^last(\\d+)$/i);\n if (lastN) return { from: today0 - parseInt(lastN[1]) * 86400_000 };\n const rangeMatch = spec.match(/^(\\d{4})-(\\d{2})-(\\d{2})\\.\\.(\\d{4})-(\\d{2})-(\\d{2})$/);\n if (rangeMatch) return {\n from: dayStart(+rangeMatch[1], +rangeMatch[2], +rangeMatch[3]),\n to: dayStart(+rangeMatch[4], +rangeMatch[5], +rangeMatch[6]) + 86400_000,\n };\n const gtMatch = spec.match(/^>(\\d{4})-(\\d{2})-(\\d{2})$/);\n if (gtMatch) return { from: dayStart(+gtMatch[1], +gtMatch[2], +gtMatch[3]) + 86400_000 };\n const ltMatch = spec.match(/^<(\\d{4})-(\\d{2})-(\\d{2})$/);\n if (ltMatch) return { to: dayStart(+ltMatch[1], +ltMatch[2], +ltMatch[3]) };\n const monthMatch = spec.match(/^(\\d{4})-(\\d{2})$/);\n if (monthMatch) {\n const y = +monthMatch[1], m = +monthMatch[2];\n const from = dayStart(y, m, 1);\n const to = m === 12 ? dayStart(y + 1, 1, 1) : dayStart(y, m + 1, 1);\n return { from, to };\n }\n const dayMatch = spec.match(/^(\\d{4})-(\\d{2})-(\\d{2})$/);\n if (dayMatch) {\n const from = dayStart(+dayMatch[1], +dayMatch[2], +dayMatch[3]);\n return { from, to: from + 86400_000 };\n }\n return null;\n };\n\n for (const part of parts) {\n const fromMatch = part.match(/^from:(.+)$/i);\n const toMatch = part.match(/^to:(.+)$/i);\n const subjectMatch = part.match(/^subject:(.+)$/i);\n const hasMatch = part.match(/^has:(.+)$/i);\n const isMatch = part.match(/^is:(.+)$/i);\n const dateMatch = part.match(/^date:(.+)$/i);\n\n if (fromMatch) {\n const term = `%${fromMatch[1].replace(/\"/g, \"\")}%`;\n conditions.push(\"(from_name LIKE ? OR from_address LIKE ?)\");\n params.push(term, term);\n } else if (toMatch) {\n const term = `%${toMatch[1].replace(/\"/g, \"\")}%`;\n conditions.push(\"(to_json LIKE ? OR cc_json LIKE ?)\");\n params.push(term, term);\n } else if (subjectMatch) {\n const term = `%${subjectMatch[1].replace(/\"/g, \"\")}%`;\n conditions.push(\"subject LIKE ?\");\n params.push(term);\n } else if (hasMatch) {\n const v = hasMatch[1].toLowerCase();\n if (v === \"attachment\" || v === \"attachments\") {\n conditions.push(\"has_attachments = 1\");\n }\n // Unknown has: qualifier \u2014 silently drop; treating as a literal\n // search term would be confusing.\n } else if (isMatch) {\n const v = isMatch[1].toLowerCase();\n if (v === \"flagged\" || v === \"starred\") {\n conditions.push(\"flags_json LIKE ?\"); params.push(\"%\\\\\\\\Flagged%\");\n } else if (v === \"unread\") {\n conditions.push(\"flags_json NOT LIKE ?\"); params.push(\"%\\\\\\\\Seen%\");\n } else if (v === \"read\") {\n conditions.push(\"flags_json LIKE ?\"); params.push(\"%\\\\\\\\Seen%\");\n }\n } else if (dateMatch) {\n const spec = parseDateSpec(dateMatch[1]);\n if (spec) {\n if (spec.from !== undefined) {\n conditions.push(\"date >= ?\"); params.push(spec.from);\n }\n if (spec.to !== undefined) {\n conditions.push(\"date < ?\"); params.push(spec.to);\n }\n }\n } else {\n const term = `%${part}%`;\n conditions.push(\"(subject LIKE ? OR from_name LIKE ? OR from_address LIKE ? OR preview LIKE ?)\");\n params.push(term, term, term, term);\n }\n }\n\n return { conditions, params };\n}\n", "/**\n * Message viewer component -- displays full message in sandboxed iframe.\n *\n * Passive renderer. The list owns \"what's focused\"; this module exposes\n * `showMessage(accountId, uid, folderId, envelope?)` and `clearViewer()`\n * as the only ways content reaches the pane. There is no subscription \u2014\n * if the list never tells the viewer about a focus change, the viewer\n * simply doesn't update. That's the property that makes summary\u2194preview\n * drift impossible: there is exactly one path into this pane.\n */\n\nimport { getMessage, updateFlags, allowRemoteContent, flagSenderOrDomain, getAttachment, openAttachment, addContact, listContacts, upsertContact, unsubscribeOneClick, addPreferredContact, onEvent, subscribeStore } from \"../lib/api-client.js\";\nimport { showContextMenu } from \"./context-menu.js\";\nimport type { MenuItem } from \"./context-menu.js\";\nimport type { ListMessage } from \"../lib/message-state.js\";\nimport { updateMessageFlags as stateUpdateFlags } from \"../lib/message-state.js\";\nimport { setRowSeen } from \"./message-list.js\";\nimport { setSeen, seenOf } from \"@bobfrankston/mailx-types\";\n\n/** Currently displayed message (for reply/forward) */\nlet currentMessage: any = null;\nlet currentAccountId: string = \"\";\n// Track the most recent draft we auto-opened into compose so re-renders of\n// the same selection (e.g. a folder-counts event) don't spawn duplicate\n// compose windows. Reset implicitly when a different message is selected.\nlet lastAutoOpenedDraftUid: number = -1;\nlet showMessageGeneration = 0; // Cancel stale fetches\nlet retryCount = 0;\n/** Last envelope handed in by the list \u2014 used for envelope-first paint\n * on retry, where we don't have a fresh row click to pass it again. */\nlet lastEnvelope: ListMessage | null = null;\n\n/** Parsed-body LRU. Click \u2192 render must be synchronous; the only way to\n * honor that is to keep the parsed result in WebView memory keyed by\n * (accountId, uid). Bounded so a long session doesn't eat hundreds of\n * MB of decoded HTML. Hit = render now, no IPC, no spinner, no\n * \"Loading\u2026\". Miss = paint the envelope's preview as a transient body\n * and swap in the real one when the IPC returns (also caches). */\nconst PARSED_CACHE_LIMIT = 64;\nconst parsedCache = new Map<string, any>(); // key: `${accountId}:${uid}` \u2192 full message obj\nfunction parsedCacheGet(accountId: string, uid: number): any | null {\n const key = `${accountId}:${uid}`;\n const v = parsedCache.get(key);\n if (!v) return null;\n // Bump recency: re-insert moves to the end of Map iteration order.\n parsedCache.delete(key);\n parsedCache.set(key, v);\n return v;\n}\nfunction parsedCachePut(accountId: string, uid: number, msg: any): void {\n const key = `${accountId}:${uid}`;\n parsedCache.delete(key);\n parsedCache.set(key, msg);\n while (parsedCache.size > PARSED_CACHE_LIMIT) {\n const oldest = parsedCache.keys().next().value;\n if (oldest === undefined) break;\n parsedCache.delete(oldest);\n }\n}\n/** Drop one entry \u2014 called when flags change so re-render reads fresh state. */\nexport function invalidateParsedCache(accountId: string, uid: number): void {\n parsedCache.delete(`${accountId}:${uid}`);\n}\n\n/** Session-scoped \"user clicked Allow on this message\" set. Once a uuid\n * is in here, any subsequent showMessage call treats `hasRemoteContent`\n * as false regardless of what the daemon returned \u2014 covers the race\n * where the allowlist persist hasn't reached disk yet, the daemon\n * re-parses and reports remote content again, and a folder refresh\n * re-renders the banner on top of the unblocked iframe (Bob 2026-05-09:\n * \"still showing the blocked header\" while content was loaded). */\nconst sessionAllowedRemote = new Set<string>();\n\n/** Per-uid recent body-fetch errors. Populated by the bodyFetchError\n * listener so the \"No content\" code path can show the real reason\n * instead of a generic placeholder. Keyed `${accountId}:${uid}`. */\nconst recentFetchErrors = new Map<string, { error: string; transient: boolean; when: number }>();\nfunction markSessionAllowed(accountId: string, uid: number): void {\n sessionAllowedRemote.add(`${accountId}:${uid}`);\n}\nfunction isSessionAllowed(accountId: string, uid: number): boolean {\n return sessionAllowedRemote.has(`${accountId}:${uid}`);\n}\n\nexport function getCurrentMessage(): { accountId: string; message: any } | null {\n if (!currentMessage) return null;\n return { accountId: currentAccountId, message: currentMessage };\n}\n\n/** Build the body-context menu items (right-click outside a link in the\n * preview iframe). Called from app.ts's window message listener after the\n * iframe inline script postMessages a \"previewContextMenu\" event with the\n * current selection. Translate routes to the configured AI provider via\n * aiTransform; \"Translate via Google\" opens translate.google.com in the\n * default browser as a no-account-needed alternative. */\nexport function showPreviewBodyMenu(absX: number, absY: number, selectedText: string, sourceWindow: Window | null, linkUrl?: string, linkText?: string): void {\n const pct = Math.round(previewZoom * 100);\n const runSearch = (query: string): void => {\n const input = document.getElementById(\"search-input\") as HTMLInputElement | null;\n if (!input) return;\n input.value = query;\n input.dispatchEvent(new Event(\"input\"));\n input.dispatchEvent(new KeyboardEvent(\"keydown\", { key: \"Enter\", bubbles: true }));\n input.focus();\n };\n // Send a command back into the iframe (Copy, Select all) \u2014 the iframe\n // owns its own document and these only work in the iframe's context.\n const tellIframe = (cmd: string): void => {\n try { sourceWindow?.postMessage({ type: \"previewCommand\", cmd }, \"*\"); } catch { /* */ }\n };\n const fullBody = (): string => {\n try {\n for (const f of Array.from(document.querySelectorAll(\"iframe\"))) {\n if ((f as HTMLIFrameElement).contentWindow === sourceWindow) {\n return (f as HTMLIFrameElement).contentDocument?.body?.innerText || \"\";\n }\n }\n } catch { /* */ }\n return \"\";\n };\n const items: MenuItem[] = [];\n // Link actions first (when right-click was over a link). Quora-style\n // pages are wall-to-wall links \u2014 without this, body actions like\n // Translate were unreachable because every long-press hit a link.\n if (linkUrl) {\n const guessName = (() => {\n try { const u = new URL(linkUrl); const last = u.pathname.split(\"/\").pop() || \"\"; return last && last.includes(\".\") ? last : \"\"; }\n catch { return \"\"; }\n })();\n items.push(\n { label: \"Open link in browser\", action: () => window.open(linkUrl, \"_blank\", \"noopener,noreferrer\") },\n { label: guessName ? `Save link as \"${guessName}\"\u2026` : \"Save link as\u2026\", action: () => {\n const a = document.createElement(\"a\"); a.href = linkUrl;\n a.download = guessName || \"\"; a.style.display = \"none\";\n document.body.appendChild(a); a.click(); setTimeout(() => a.remove(), 1000);\n } },\n { label: \"Copy link URL\", action: async () => {\n try { await navigator.clipboard.writeText(linkUrl); } catch { prompt(\"URL:\", linkUrl); }\n } },\n { label: \"Copy link text\", action: async () => {\n try { await navigator.clipboard.writeText(linkText || linkUrl); } catch { prompt(\"Text:\", linkText || linkUrl); }\n } },\n { label: \"\", action: () => {}, separator: true },\n );\n }\n items.push(\n { label: \"Copy\", action: () => tellIframe(\"copy\") },\n { label: \"Select all\", action: () => tellIframe(\"selectAll\") },\n );\n if (selectedText) {\n items.push(\n { label: \"\", action: () => {}, separator: true },\n { label: `Search messages for \"${selectedText.length > 40 ? selectedText.slice(0, 40) + \"\u2026\" : selectedText}\"`,\n action: () => runSearch(selectedText) },\n { label: \"Copy as quoted (> prefix)\",\n action: async () => {\n const quoted = selectedText.split(/\\r?\\n/).map(l => \"> \" + l).join(\"\\n\");\n try { await navigator.clipboard.writeText(quoted); } catch { /* */ }\n } },\n );\n }\n const senderAddr = currentMessage?.from?.address || \"\";\n if (senderAddr) {\n items.push({ label: `Search messages from ${senderAddr}`, action: () => runSearch(`from:${senderAddr}`) });\n }\n items.push(\n { label: \"\", action: () => {}, separator: true },\n { label: selectedText ? \"Translate selection (AI)\" : \"Translate message (AI)\",\n action: () => translateAndShow(selectedText || fullBody()) },\n { label: selectedText ? \"Translate selection (Google Translate)\" : \"Translate message (Google Translate)\",\n action: () => {\n const text = (selectedText || fullBody()).slice(0, 4500); // GT URL has practical limits\n const url = `https://translate.google.com/?sl=auto&tl=en&op=translate&text=${encodeURIComponent(text)}`;\n window.open(url, \"_blank\", \"noopener,noreferrer\");\n } },\n { label: \"\", action: () => {}, separator: true },\n // Zoom group \u2014 current level (resets on click) first, then in / out.\n { label: `Zoom: ${pct}% \u2014 reset`, action: () => { for (const f of Array.from(document.querySelectorAll(\"iframe\"))) { const d = (f as HTMLIFrameElement).contentDocument; if (d) { setZoom(1, d); break; } } } },\n { label: \"Zoom in (+)\", action: () => { for (const f of Array.from(document.querySelectorAll(\"iframe\"))) { const d = (f as HTMLIFrameElement).contentDocument; if (d) { setZoom(previewZoom + ZOOM_STEP, d); break; } } } },\n { label: \"Zoom out (\u2212)\", action: () => { for (const f of Array.from(document.querySelectorAll(\"iframe\"))) { const d = (f as HTMLIFrameElement).contentDocument; if (d) { setZoom(previewZoom - ZOOM_STEP, d); break; } } } },\n { label: \"\", action: () => {}, separator: true },\n { label: \"Print\u2026 (Ctrl+P)\", action: () => printCurrentMessage() },\n );\n showContextMenu(absX, absY, items);\n}\n\n/** Toggle preview-fullscreen mode. CSS rule on body.fullscreen-preview hides\n * toolbar / rail / folder tree / list / statusbar and expands the viewer to\n * the full viewport. Exits via second toggle, Esc, or the floating \u2715. */\nexport function toggleFullscreenPreview(): void {\n const body = document.body;\n const on = !body.classList.contains(\"fullscreen-preview\");\n body.classList.toggle(\"fullscreen-preview\", on);\n let exit = document.getElementById(\"mv-exit-fullscreen\");\n if (on) {\n if (!exit) {\n exit = document.createElement(\"button\");\n exit.id = \"mv-exit-fullscreen\";\n exit.textContent = \"\u2715\";\n exit.title = \"Exit fullscreen (Esc or double-tap)\";\n exit.style.cssText = \"position:fixed;top:8px;right:8px;z-index:6000;width:36px;height:36px;border-radius:50%;border:none;background:rgba(0,0,0,0.55);color:#fff;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,0.4);\";\n exit.addEventListener(\"click\", () => toggleFullscreenPreview());\n document.body.appendChild(exit);\n }\n // Esc handler \u2014 installed once, reads the class on each press.\n if (!(window as any).__mvFullscreenEsc) {\n const onEsc = (e: KeyboardEvent) => {\n if (e.key === \"Escape\" && document.body.classList.contains(\"fullscreen-preview\")) {\n e.preventDefault();\n toggleFullscreenPreview();\n }\n };\n document.addEventListener(\"keydown\", onEsc, true);\n (window as any).__mvFullscreenEsc = onEsc;\n }\n } else {\n exit?.remove();\n }\n}\n\n/** Kept as an exported no-op for back-compat with app.ts wiring. The\n * state-subscribe model is gone; list now drives the viewer directly. */\nexport function initViewer(): void { /* no-op: list owns focus */ }\n\n// Zoom is persisted across messages via localStorage\nconst ZOOM_KEY = \"mailx-preview-zoom\";\nconst ZOOM_MIN = 0.5;\nconst ZOOM_MAX = 3.0;\nconst ZOOM_STEP = 0.1;\n// 1.15 default: smaller than browser-default text felt cramped per user\n// feedback 2026-05-20. Ctrl+= / Ctrl+- / Ctrl+wheel / right-click menu all\n// override and persist, so the default only matters on first-ever launch or\n// after a WebView2 profile wipe.\nlet previewZoom = clampZoom(parseFloat(localStorage.getItem(ZOOM_KEY) || \"1.15\"));\n\nfunction clampZoom(z: number): number {\n if (!Number.isFinite(z) || z <= 0) return 1;\n return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, Math.round(z * 100) / 100));\n}\n\nfunction applyZoom(doc: Document): void {\n // Zoom lives on <html>, not <body>, because WebView2/Chromium's scroll\n // container is the root element \u2014 body.style.zoom leaves the scroll\n // container out of sync with the zoomed content, breaking scrollbar\n // display and wheel scrolling. documentElement.style.zoom keeps them\n // aligned so the iframe scrolls normally at any zoom level.\n if (doc.documentElement) (doc.documentElement.style as any).zoom = String(previewZoom);\n}\n\nfunction setZoom(z: number, doc: Document): void {\n previewZoom = clampZoom(z);\n localStorage.setItem(ZOOM_KEY, String(previewZoom));\n applyZoom(doc);\n}\n\n/** Install preview iframe controls: key forwarding to parent, Ctrl+wheel zoom,\n * keyboard zoom shortcuts (Ctrl+= / Ctrl+- / Ctrl+0), and the right-click menu. */\n/** Run AI translate on `text` and show result in a small modal. Disabled\n * by default \u2014 user enables via Settings (translateEnabled in\n * AutocompleteSettings). When disabled, the modal explains how to enable. */\nasync function translateAndShow(text: string): Promise<void> {\n if (!text.trim()) return;\n const status = document.getElementById(\"status-sync\");\n if (status) status.textContent = \"Translating\u2026\";\n\n const overlay = document.createElement(\"div\");\n overlay.style.cssText = \"position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:10000;display:flex;align-items:center;justify-content:center;\";\n const modal = document.createElement(\"div\");\n modal.style.cssText = \"background:var(--bg, #fff);color:var(--fg, #000);border:1px solid var(--border, #ccc);border-radius:6px;width:560px;max-width:90vw;max-height:70vh;display:flex;flex-direction:column;box-shadow:0 4px 24px rgba(0,0,0,0.3);\";\n const header = document.createElement(\"div\");\n header.style.cssText = \"padding:10px 14px;border-bottom:1px solid var(--border, #ddd);font-weight:600;display:flex;justify-content:space-between;align-items:center;\";\n header.innerHTML = `<span>Translation</span><button style=\"cursor:pointer;border:0;background:transparent;font-size:16px;\" aria-label=\"Close\">\u00D7</button>`;\n const body = document.createElement(\"div\");\n body.style.cssText = \"flex:1;overflow:auto;padding:12px 14px;white-space:pre-wrap;font-size:13px;line-height:1.45;\";\n body.textContent = \"Working\u2026\";\n modal.appendChild(header);\n modal.appendChild(body);\n overlay.appendChild(modal);\n document.body.appendChild(overlay);\n const close = () => overlay.remove();\n header.querySelector(\"button\")?.addEventListener(\"click\", close);\n overlay.addEventListener(\"click\", (e) => { if (e.target === overlay) close(); });\n document.addEventListener(\"keydown\", function onKey(e: KeyboardEvent) {\n if (e.key === \"Escape\") { document.removeEventListener(\"keydown\", onKey); close(); }\n });\n\n try {\n const { aiTransform } = await import(\"../lib/api-client.js\");\n const r = await aiTransform({ action: \"translate\", text, targetLang: \"en\" });\n if (r.text) {\n body.textContent = r.text;\n if (status) status.textContent = \"\";\n } else {\n body.innerHTML = `<div style=\"color:var(--muted, #888);\">No result.</div>` +\n `<div style=\"margin-top:8px;font-size:12px;color:var(--muted, #888);\">${r.reason || \"\"}</div>` +\n `<div style=\"margin-top:14px;font-size:12px;\">Enable AI translate in Settings \u2192 AI features (off by default).</div>`;\n if (status) status.textContent = `Translate: ${r.reason || \"no result\"}`;\n }\n } catch (err: any) {\n body.textContent = `Error: ${err?.message || String(err)}`;\n if (status) status.textContent = `Translate error: ${err?.message || \"\"}`;\n }\n}\n\nfunction installPreviewControls(iframe: HTMLIFrameElement): void {\n const attach = () => {\n const doc = iframe.contentDocument;\n if (!doc) return;\n\n applyZoom(doc);\n\n doc.addEventListener(\"keydown\", (e) => {\n const target = e.target as HTMLElement | null;\n if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return;\n // Zoom is iframe-local \u2014 handle here, don't forward.\n if (e.ctrlKey && (e.key === \"=\" || e.key === \"+\")) { e.preventDefault(); setZoom(previewZoom + ZOOM_STEP, doc); return; }\n if (e.ctrlKey && e.key === \"-\") { e.preventDefault(); setZoom(previewZoom - ZOOM_STEP, doc); return; }\n if (e.ctrlKey && e.key === \"0\") { e.preventDefault(); setZoom(1, doc); return; }\n // Forward EVERY keydown to the parent \u2014 no duplicated hotkey list.\n // If the parent's handler calls preventDefault (because it owns the\n // shortcut), dispatchEvent returns false, and we preventDefault on\n // the iframe side too so the browser doesn't ALSO act on it\n // (Ctrl+N otherwise pops a new browser window in some hosts).\n // Single source of truth = app.ts hotkey handlers. Plain typing in\n // the email body \u2014 letters, etc. \u2014 propagates with no parent\n // handler matching, so dispatchEvent returns true and the iframe\n // event is left alone.\n const synth = new KeyboardEvent(\"keydown\", {\n key: e.key, code: e.code,\n ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, altKey: e.altKey, metaKey: e.metaKey,\n bubbles: true, cancelable: true,\n });\n const allowDefault = document.dispatchEvent(synth);\n if (!allowDefault) e.preventDefault();\n });\n\n doc.addEventListener(\"wheel\", (e) => {\n if (!e.ctrlKey) return;\n e.preventDefault();\n setZoom(previewZoom + (e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP), doc);\n }, { passive: false });\n\n // dblclick fullscreen toggle moved into the iframe's inline script\n // so it's wired by the time the document loads \u2014 the parent-side\n // contentDocument listener was racing with the load event and\n // sometimes never attached.\n\n // Link interception lives in the iframe's own inline <script> (see\n // wrapHtmlBody). That script runs under a CSP nonce so email-body\n // scripts stay blocked while ours forwards taps to the parent frame.\n\n // Body-context menu was previously built here on doc.contextmenu, but\n // the inline iframe script (wrapHtmlBody) now postMessages the request\n // to the parent unconditionally \u2014 see showPreviewContextMenu in app.ts\n // which calls showPreviewBodyMenu below. This route works on every\n // host; the doc-level handler missed cases where WebView2's native\n // menu fired before our parent listener got installed.\n };\n if (iframe.contentDocument?.readyState === \"complete\") attach();\n else iframe.addEventListener(\"load\", attach, { once: true });\n}\n\nexport function clearViewer(): void {\n currentMessage = null;\n currentAccountId = \"\";\n lastEnvelope = null;\n showMessageGeneration++;\n const headerEl = document.getElementById(\"mv-header\") as HTMLElement;\n const bodyEl = document.getElementById(\"mv-body\") as HTMLElement;\n const attEl = document.getElementById(\"mv-attachments\") as HTMLElement;\n if (bodyEl) bodyEl.innerHTML = `<div class=\"mv-empty\">Select a message to read</div>`;\n if (headerEl) headerEl.hidden = true;\n if (attEl) attEl.hidden = true;\n}\n\n/** Render the message identified by (accountId, uid, folderId). The\n * list passes the row's existing envelope as `envelope` so the header +\n * preview snippet paint *immediately*, before the body fetch returns \u2014\n * that's the \"no Fetching\u2026 empty pane\" guarantee. On retry, we reuse\n * the most recently provided envelope. */\nexport async function showMessage(accountId: string, uid: number, folderId?: number, specialUse?: string, isRetry = false, envelope?: ListMessage): Promise<void> {\n const gen = ++showMessageGeneration;\n if (!isRetry) retryCount = 0;\n const headerEl = document.getElementById(\"mv-header\") as HTMLElement;\n const bodyEl = document.getElementById(\"mv-body\") as HTMLElement;\n const attEl = document.getElementById(\"mv-attachments\") as HTMLElement;\n\n // Same-message re-show guard. The list rebuilds on folderCountsChanged\n // (sync events, IDLE, flag updates) and `restoreSelection()` then calls\n // `focusRow()` \u2192 `viewerShow()` on the row whose uid matches. If we\n // re-paint here, `bodyEl.innerHTML = \u2026` later in this function destroys\n // the iframe and resets the user's scroll position to top. Skip that\n // entirely when the message identity hasn't changed and we've got a\n // live iframe to keep. Header refresh is harmless (no scroll inside)\n // and keeps flag/read state visually current. Retry path bypasses the\n // guard so transient body fetches still get their delayed paint.\n //\n // Identity check: prefer the stable `uuid`. (uid, folderId) is NOT a\n // reliable identity \u2014 uid is per-folder, so a cross-folder search can\n // surface two different messages with the same uid, and a result whose\n // folderId is omitted made the guard mis-fire: the header refreshed to\n // the clicked message but the body kept the previously-shown message\n // (Bob 2026-05-18: the .eml shown \"does not match the header\"). Only\n // fall back to uid/folderId when a uuid isn't available on both sides\n // (e.g. thread-popup envelopes, which carry no uuid).\n const sameMessage = !!currentMessage && currentAccountId === accountId && (\n (envelope?.uuid && currentMessage.uuid)\n ? envelope.uuid === currentMessage.uuid\n : (currentMessage.uid === uid\n && (folderId === undefined || currentMessage.folderId === folderId))\n );\n if (!isRetry && sameMessage && bodyEl.querySelector(\"iframe\")) {\n if (envelope) {\n lastEnvelope = envelope;\n try { renderHeaderFromEnvelope(headerEl, envelope); headerEl.hidden = false; } catch { /* */ }\n }\n return;\n }\n\n // Click-to-preview timing \u2014 answers \"why is this slow?\" with numbers.\n // Marks: enter / cache-hit-or-miss / IPC-begin / IPC-done / iframe-set /\n // iframe-loaded. Without these the symptom \"previews very slow\" is\n // unmeasurable.\n const _previewT0 = performance.now();\n const _ptick = (label: string): void => {\n const line = `[preview ${(performance.now() - _previewT0).toFixed(0).padStart(5)} ms] ${label} ${accountId}/uid=${uid}`;\n console.log(\" \" + line);\n try { (window as any).mailxapi?.logClientEvent?.(line); } catch { /* */ }\n };\n _ptick(\"showMessage entry\");\n\n // Clear any \"current message\" identity at the START of a new render \u2014\n // before a single byte is fetched. Action buttons (Source, Edit Draft,\n // attachments) read from `currentMessage` and must NOT serve the\n // previous message's data while this one is still loading. The\n // closures they had pointed at the old message; clearing the shared\n // state forces them to read whatever this render eventually commits.\n currentMessage = null;\n currentAccountId = \"\";\n // Hide action buttons that depend on the message body \u2014 they'll be\n // restored when the new message data arrives.\n const srcBtn = document.getElementById(\"mv-view-source\") as HTMLButtonElement | null;\n if (srcBtn) srcBtn.hidden = true;\n const editBtn = document.getElementById(\"mv-edit-draft\") as HTMLButtonElement | null;\n if (editBtn) editBtn.hidden = true;\n\n // Envelope-first render: the row the user just clicked already has the\n // subject / from / to / cc / date / preview in the message-state. Use\n // that to populate the header + a snippet placeholder IMMEDIATELY so\n // tapping a message never shows just \"Fetching message body...\" with\n // nothing actionable. The full getMessage() call (which might block on\n // a slow IMAP body fetch) only fills in the body and attachments.\n const cached = (envelope && envelope.uid === uid && (envelope.accountId || accountId) === accountId)\n ? envelope\n : (lastEnvelope && lastEnvelope.uid === uid && (lastEnvelope.accountId || accountId) === accountId ? lastEnvelope : null);\n if (envelope) lastEnvelope = envelope;\n\n // Cache lookup BEFORE any placeholder paint. If this message's parsed\n // body is in WebView memory (recently viewed), skip the IPC entirely \u2014\n // render synchronously below.\n const cachedMsg = parsedCacheGet(accountId, uid);\n\n if (cached) {\n try { renderHeaderFromEnvelope(headerEl, cached); } catch { /* */ }\n // 2026-05-13: if the envelope has a non-empty `preview`, show it as\n // a miniature body until the parsed HTML arrives. If it's empty\n // (common for mailing-list / quoted-reply messages), show an\n // explicit \"Loading body\u2026\" so the user never sees a totally blank\n // pane and thinks the click didn't register. Earlier wording chose\n // silence on the theory that \"Loading\u2026\" is a lie when fast \u2014 but\n // an empty pane is a worse lie when the parse is slow.\n if (!cachedMsg) {\n const previewText = (cached.preview || \"\").trim();\n bodyEl.innerHTML = previewText\n ? `<div class=\"mv-preview-placeholder\">${escapeHtml(previewText)}</div>`\n : `<div class=\"mv-empty\">Loading body\u2026</div>`;\n }\n } else if (!cachedMsg) {\n // No envelope and no cache \u2014 show a loading indicator instead of\n // an empty pane. The IPC return will paint over it.\n bodyEl.innerHTML = `<div class=\"mv-empty\">Loading body\u2026</div>`;\n headerEl.hidden = true;\n }\n attEl.hidden = true;\n\n try {\n if (cachedMsg) _ptick(\"parsedCache HIT \u2014 sync render\");\n else _ptick(\"parsedCache MISS \u2014 IPC begin\");\n const msg = cachedMsg ? cachedMsg : await getMessage(accountId, uid, false, folderId);\n if (!cachedMsg) _ptick(`IPC done (cached=${(msg as any).cached !== false}, ${(msg as any).bodyHtml?.length || 0} bytes html)`);\n // Stale response \u2014 a newer showMessage was called while we were fetching\n if (gen !== showMessageGeneration) return;\n\n // User clicked Allow earlier this session \u2014 treat as unblocked\n // regardless of what the daemon currently reports. Covers the\n // race where the allowlist persist is still in flight.\n if (isSessionAllowed(accountId, uid) && (msg as any).hasRemoteContent) {\n (msg as any).hasRemoteContent = false;\n }\n\n // Body not yet on disk \u2014 service kicked off a background fetch and\n // will emit `bodyAvailable` when the bytes land. Show envelope-only\n // (header + preview snippet) and re-render on the event. No timer,\n // no client-side retry; the reconciler retries server-side.\n if ((msg as any).cached === false) {\n try { renderHeaderFromEnvelope(headerEl, msg as any); headerEl.hidden = false; } catch { /* */ }\n // Body genuinely missing from local store \u2014 the reconciler is\n // fetching it from IMAP/Gmail. Show preview as a miniature\n // body if available, otherwise a clear \"Fetching from server\u2026\"\n // so a slow fetch isn't indistinguishable from a hung pane.\n const previewText = ((msg as any).preview || cached?.preview || \"\").trim();\n bodyEl.innerHTML = previewText\n ? `<div class=\"mv-preview-placeholder\">${escapeHtml(previewText)}</div>`\n : `<div class=\"mv-empty\">Fetching body from server\u2026</div>`;\n const captureGen = gen;\n // Subscribe via Store bus (the post-refactor surface). The\n // reconciler publishes `bodyAvailable` on `message:<uuid>` (or\n // `folder:<id>` when uuid isn't available); the wildcard catches\n // both. Match by account+uid because the viewer keys off those.\n const off = subscribeStore(\"*\", (ev: any) => {\n if (ev.kind !== \"bodyAvailable\") return;\n if (ev.accountId !== accountId || ev.uid !== uid) return;\n if (captureGen !== showMessageGeneration) { off(); return; }\n off();\n showMessage(accountId, uid, folderId, specialUse, true, envelope || cached || undefined).catch(() => { /* */ });\n });\n return;\n }\n // Cache the fully-parsed result so re-clicking this message paints\n // synchronously next time (no IPC, no parse). Cache is bounded\n // (PARSED_CACHE_LIMIT) and skipped if we got here via cache hit.\n if (!cachedMsg) parsedCachePut(accountId, uid, msg);\n currentMessage = msg;\n currentAccountId = accountId;\n\n // Mark as read \u2014 gated by user prefs:\n // - mailx-automark-read (default \"true\"): if \"false\", never auto-mark\n // - mailx-automark-delay (default \"2\"): seconds to wait before\n // marking. Lets the user click through messages quickly without\n // marking ones they didn't actually read. The timer is tied to\n // showMessageGeneration; navigating to another message advances\n // the generation and cancels the pending mark.\n if (!seenOf(msg)) {\n let enabled = true;\n let delaySec = 2;\n try {\n enabled = localStorage.getItem(\"mailx-automark-read\") !== \"false\";\n const d = parseFloat(localStorage.getItem(\"mailx-automark-delay\") || \"2\");\n if (Number.isFinite(d) && d >= 0) delaySec = d;\n } catch { /* private mode \u2014 defaults */ }\n if (enabled) {\n const captureGen = gen;\n const apply = () => {\n // Stale: user moved on before the timer fired.\n if (captureGen !== showMessageGeneration) return;\n setSeen(msg, true);\n updateFlags(accountId, uid, msg.flags);\n // Mirror to message-state + row so list display flips.\n try { stateUpdateFlags(accountId, uid, msg.flags); } catch { /* */ }\n try { setRowSeen(accountId, uid, true); } catch { /* */ }\n };\n if (delaySec === 0) apply();\n else setTimeout(apply, delaySec * 1000);\n }\n }\n\n // Header\n headerEl.hidden = false;\n const fromEl = headerEl.querySelector(\".mv-from\")!;\n const toEl = headerEl.querySelector(\".mv-to\")!;\n fromEl.textContent = formatAddr(msg.from);\n let toLine = `To: ${msg.to.map(formatAddr).join(\", \")}`;\n if (msg.cc?.length) toLine += ` Cc: ${msg.cc.map(formatAddr).join(\", \")}`;\n // Always-visible Delivered-To line \u2014 shown when present and not already\n // covered by the To/Cc list. Critical for accounts with multiple aliases\n // where you need to see which one received the message at a glance.\n const toAddrs = (msg.to || []).map((a: { address: string }) => a.address.toLowerCase());\n const ccAddrs = (msg.cc || []).map((a: { address: string }) => a.address.toLowerCase());\n const dt = (msg.deliveredTo || \"\").toLowerCase();\n if (msg.deliveredTo && !toAddrs.includes(dt) && !ccAddrs.includes(dt)) {\n toLine += ` Delivered-To: ${msg.deliveredTo}`;\n }\n toEl.textContent = toLine;\n headerEl.querySelector(\".mv-subject\")!.textContent = msg.subject;\n document.dispatchEvent(new CustomEvent(\"mailx-message-shown\", { detail: { accountId } }));\n\n // Right-click on email addresses in header: copy name, copy address,\n // copy both, add to contacts, plus reply actions for the whole message.\n for (const el of [fromEl, toEl]) {\n el.addEventListener(\"contextmenu\", (e: Event) => {\n e.preventDefault();\n const me = e as MouseEvent;\n const items: MenuItem[] = [];\n const addrs = el === fromEl ? [msg.from] : [...(msg.to || []), ...(msg.cc || [])];\n for (const addr of addrs) {\n if (!addr?.address) continue;\n const name = addr.name || \"\";\n const both = name ? `${name} <${addr.address}>` : addr.address;\n if (name) {\n items.push({ label: `Copy name: ${name}`, action: () => navigator.clipboard.writeText(name) });\n }\n items.push({ label: `Copy address: ${addr.address}`, action: () => navigator.clipboard.writeText(addr.address) });\n if (name) {\n items.push({ label: `Copy both: ${both}`, action: () => navigator.clipboard.writeText(both) });\n }\n items.push({\n label: `Add to contacts: ${addr.address}`,\n action: async () => {\n await showAddContactDialog(name, addr.address);\n },\n });\n // \"Add to preferred\" \u2014 separate path: writes to\n // contacts.jsonc#preferred[] with an optional source tag.\n // Distinct from \"Add to contacts\" which goes into the DB +\n // pushes to Google. Preferred entries rank higher in\n // autocomplete and survive Google sync's churn.\n items.push({\n label: `Add to preferred: ${addr.address}`,\n action: async () => {\n const tag = prompt(\"Tag (e.g. work, family, vendor) \u2014 leave blank for default:\", \"\");\n if (tag === null) return; // user cancelled\n try {\n await addPreferredContact({ name, email: addr.address, source: tag.trim() || undefined });\n const status = document.getElementById(\"status-sync\");\n if (status) status.textContent = `Added to preferred: ${addr.address}${tag ? ` [${tag}]` : \"\"}`;\n } catch (e: any) {\n alert(`Couldn't add to preferred: ${e?.message || e}`);\n }\n },\n });\n // Mark / unmark sender as priority \u2014 visual highlight in\n // the message list (gold left bar + bold sender name);\n // strongest while unread, fades when read. Stored as\n // `priority: true` on the contacts.jsonc preferred[]\n // entry (auto-creates the entry if it doesn't exist).\n items.push({\n label: `\u2605 Mark sender as priority: ${addr.address}`,\n action: async () => {\n try {\n const { setPrioritySender } = await import(\"../lib/api-client.js\");\n await setPrioritySender(addr.address, true, name);\n const { refreshPriorityIndex } = await import(\"./message-list.js\");\n await refreshPriorityIndex();\n const status = document.getElementById(\"status-sync\");\n if (status) status.textContent = `Priority: ${addr.address}`;\n } catch (e: any) {\n alert(`Couldn't mark priority: ${e?.message || e}`);\n }\n },\n });\n items.push({ label: \"\", action: () => {}, separator: true });\n }\n items.push({ label: \"Reply\", action: () => document.dispatchEvent(new CustomEvent(\"mailx-compose\", { detail: { mode: \"reply\" } })) });\n items.push({ label: \"Reply All\", action: () => document.dispatchEvent(new CustomEvent(\"mailx-compose\", { detail: { mode: \"replyAll\" } })) });\n items.push({ label: \"Forward\", action: () => document.dispatchEvent(new CustomEvent(\"mailx-compose\", { detail: { mode: \"forward\" } })) });\n showContextMenu(me.clientX, me.clientY, items);\n });\n }\n headerEl.querySelector(\".mv-date\")!.textContent = new Date(msg.date).toLocaleString(undefined, { year: \"numeric\", month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\", hour12: false });\n\n // Unsubscribe button (upper right of header).\n // - One-Click (RFC 8058): POST via service; show result in status bar.\n // - Plain HTTPS URL: open externally for user confirmation.\n // - mailto: open a pre-filled compose so the reply uses the right account.\n const unsubBtn = document.getElementById(\"mv-unsubscribe\") as HTMLAnchorElement;\n const httpUrl = (msg as any).listUnsubscribeHttp || \"\";\n const mailUrl = (msg as any).listUnsubscribeMail || \"\";\n const oneClick = !!(msg as any).listUnsubscribeOneClick;\n const anyUrl = httpUrl || mailUrl || msg.listUnsubscribe || \"\";\n if (unsubBtn) {\n if (anyUrl) {\n unsubBtn.hidden = false;\n unsubBtn.textContent = \"Unsubscribe\";\n unsubBtn.removeAttribute(\"title\");\n unsubBtn.href = httpUrl || mailUrl || \"#\";\n unsubBtn.onclick = async (e) => {\n e.preventDefault();\n const status = document.getElementById(\"status-sync\");\n if (httpUrl && oneClick) {\n if (status) status.textContent = \"Unsubscribing\u2026\";\n try {\n const result = await unsubscribeOneClick(httpUrl);\n if (status) {\n status.textContent = result.ok\n ? `Unsubscribed (${result.status} ${result.statusText})`\n : `Unsubscribe failed: ${result.status} ${result.statusText}`;\n }\n } catch (err: any) {\n if (status) status.textContent = `Unsubscribe error: ${err?.message || err}`;\n }\n return;\n }\n if (httpUrl) {\n const api = (window as any).mailxapi;\n if (api?.openExternal) api.openExternal(httpUrl);\n else window.open(httpUrl, \"_blank\", \"noopener,noreferrer\");\n return;\n }\n if (mailUrl) {\n const m = mailUrl.match(/^mailto:([^?]*)(?:\\?(.*))?$/i);\n const to = m?.[1] ? decodeURIComponent(m[1]) : \"\";\n const qs = new URLSearchParams(m?.[2] || \"\");\n // 2026-05-13: when the unsubscribe-side specifies no\n // subject/body in the mailto, pre-fill from the\n // ORIGINAL message so the human (or filter) at the\n // other end sees what list this is for. Bare\n // mailto:unsub+<token>@... gives the recipient an\n // opaque token and no human-readable context.\n const origFrom = msg.from\n ? (msg.from.name ? `${msg.from.name} <${msg.from.address}>` : msg.from.address)\n : \"\";\n const origSubject = msg.subject || \"\";\n const origDate = msg.date\n ? new Date(msg.date).toLocaleString(undefined, { year: \"numeric\", month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\", hour12: false })\n : \"\";\n const subject = qs.get(\"subject\")\n || (origSubject ? `Unsubscribe: ${origSubject}` : \"Unsubscribe\");\n const inlineBody = qs.get(\"body\") || \"\";\n const escape = (s: string) => s\n .replace(/&/g, \"&\").replace(/</g, \"<\").replace(/>/g, \">\");\n const contextLines: string[] = [];\n if (origFrom) contextLines.push(`From: ${escape(origFrom)}`);\n if (origSubject) contextLines.push(`Subject: ${escape(origSubject)}`);\n if (origDate) contextLines.push(`Date: ${escape(origDate)}`);\n const contextBlock = contextLines.length\n ? `<p>\u2014<br>${contextLines.join(\"<br>\")}</p>`\n : \"\";\n const bodyHtml = inlineBody\n ? `<p>${escape(inlineBody)}</p>${contextBlock}`\n : `<p>Please unsubscribe me from this list.</p>${contextBlock}`;\n const init = {\n mode: \"new\",\n accountId: currentAccountId,\n to: to ? [{ name: \"\", address: to }] : [] as { name: string; address: string }[],\n cc: [] as { name: string; address: string }[],\n subject,\n bodyHtml,\n inReplyTo: \"\",\n references: [] as string[],\n accounts: [] as { id: string; name: string; email: string }[],\n };\n sessionStorage.setItem(\"composeInit\", JSON.stringify(init));\n document.dispatchEvent(new CustomEvent(\"mailx-compose\", { detail: { mode: \"new\" } }));\n }\n };\n } else {\n unsubBtn.hidden = true;\n }\n }\n\n // View Thread button \u2014 opens the thread popup from the message list\n // so the user can see all messages in the conversation. Works from\n // the viewer even when thread-grouping is off.\n const threadBtn = document.getElementById(\"mv-view-thread\") as HTMLButtonElement;\n if (threadBtn) {\n const tid = (msg as any).threadId || \"\";\n if (tid) {\n threadBtn.hidden = false;\n threadBtn.onclick = async () => {\n const { showThreadPopup } = await import(\"./message-list.js\");\n await showThreadPopup(threadBtn, { accountId, threadId: tid });\n };\n } else {\n threadBtn.hidden = true;\n }\n }\n\n // View Source button. CRITICAL: handler reads from the\n // `currentMessage` singleton, NOT a captured `msg` closure. If the\n // user clicks while a different message is mid-render the singleton\n // is null (cleared at top of showMessage) so the click is a no-op\n // \u2014 instead of returning the previous message's path. The principle\n // (per Bob): there is one source-of-truth identity for \"what's\n // shown\" and every action reads from it at click time.\n const srcBtn = document.getElementById(\"mv-view-source\") as HTMLButtonElement;\n if (srcBtn) {\n if (msg.emlPath) {\n srcBtn.hidden = false;\n srcBtn.title = `${msg.emlPath}\\n(click to copy path, double-click to open in default text editor)`;\n srcBtn.onclick = () => {\n const path = currentMessage?.emlPath;\n if (!path) return;\n navigator.clipboard.writeText(path).then(() => {\n const status = document.getElementById(\"status-sync\");\n if (status) status.textContent = `Path copied: ${path}`;\n }).catch(() => {\n prompt(\"EML file path:\", path);\n });\n };\n // Double-click opens the .eml in the OS default *text*\n // editor (Notepad / TextEdit / $EDITOR). Distinct from\n // the file's associated app, which for .eml is usually\n // Outlook \u2014 not what the user wants when inspecting the\n // raw MIME. Bob 2026-05-11: \"if anything it should open\n // up the default .txt editor.\"\n srcBtn.ondblclick = (e) => {\n e.preventDefault();\n const path = currentMessage?.emlPath;\n if (!path) return;\n import(\"../lib/api-client.js\").then(m => m.openInTextEditor(path)).then((r: any) => {\n const status = document.getElementById(\"status-sync\");\n if (status) {\n status.textContent = r?.ok\n ? `Opened in ${r.opener}: ${path}`\n : `Open failed: ${r?.reason || \"unknown\"}`;\n }\n }).catch((e: any) => {\n console.error(\"[mv] openInTextEditor failed:\", e);\n });\n };\n } else {\n srcBtn.hidden = true;\n srcBtn.onclick = null;\n srcBtn.ondblclick = null;\n }\n }\n\n // Drafts and Outbox messages open directly into compose. The Edit\n // Draft button stays as a fallback (and to keep the manual flow\n // discoverable), but selecting a draft no longer requires the user\n // to click it \u2014 opening a draft and editing it are the same action.\n // Guard: don't re-open compose for the SAME draft on every viewer\n // re-render (msg-state events fire on count updates etc.). And\n // don't auto-open while a compose overlay is already up \u2014 the user\n // is mid-edit on something and shouldn't lose focus to a new window.\n const editBtn = document.getElementById(\"mv-edit-draft\") as HTMLButtonElement;\n // Detect drafts by FLAG, not just folder. A message marked \\Draft\n // by the server is editable regardless of which folder the user\n // is currently viewing (e.g., clicking a draft from a thread in\n // INBOX, or from \"All Inboxes\" where currentSpecialUse is empty).\n // Bob 2026-05-12: \"How do I get into edit mode?\"\n const msgFlags: string[] = Array.isArray(msg?.flags) ? msg.flags : [];\n const flagged = msgFlags.includes(\"\\\\Draft\");\n const isDraft = flagged || specialUse === \"drafts\" || specialUse === \"outbox\";\n const openDraftInCompose = (cm: any) => {\n const init = {\n mode: \"draft\",\n accountId: currentAccountId,\n to: cm.to || [],\n cc: cm.cc || [],\n subject: cm.subject || \"\",\n bodyHtml: cm.bodyHtml || \"\",\n inReplyTo: cm.inReplyTo || \"\",\n references: cm.references || [],\n accounts: [] as { id: string; name: string; email: string }[],\n draftUid: cm.uid,\n draftFolderId: cm.folderId,\n };\n sessionStorage.setItem(\"composeInit\", JSON.stringify(init));\n document.dispatchEvent(new CustomEvent(\"mailx-compose\", { detail: { mode: \"draft\" } }));\n };\n if (editBtn) {\n if (isDraft) {\n editBtn.hidden = false;\n editBtn.textContent = specialUse === \"outbox\" ? \"Edit & Send\" : \"Edit Draft\";\n editBtn.onclick = () => { const cm = currentMessage; if (cm) openDraftInCompose(cm); };\n } else {\n editBtn.hidden = true;\n editBtn.onclick = null;\n }\n }\n if (isDraft && currentMessage\n && (currentMessage as any).uid !== lastAutoOpenedDraftUid\n && !document.querySelector(\".compose-overlay\")) {\n lastAutoOpenedDraftUid = (currentMessage as any).uid;\n openDraftInCompose(currentMessage);\n }\n\n // Details toggle \u2014 show extra headers (Delivered-To, Return-Path, Message-ID, etc.)\n const detailsEl = document.getElementById(\"mv-details\") as HTMLElement;\n const detailsBtn = document.getElementById(\"mv-toggle-details\") as HTMLButtonElement;\n if (detailsEl && detailsBtn) {\n // Q56: every row gets a Copy button so paths / IDs can be pasted\n // into accounts.jsonc hints or bug reports.\n const row = (label: string, value: string) =>\n `<div class=\"mv-details-row\"><span class=\"mv-details-label\">${label}:</span> <span class=\"mv-details-value\">${escapeText(value)}</span> <button type=\"button\" class=\"mv-details-copy\" data-copy=\"${escapeText(value).replace(/\"/g, \""\")}\" title=\"Copy\">\u29C9</button></div>`;\n const lines: string[] = [];\n if (msg.deliveredTo) lines.push(row(\"Delivered-To\", msg.deliveredTo));\n if (msg.returnPath) lines.push(row(\"Return-Path\", msg.returnPath));\n if (msg.messageId) lines.push(row(\"Message-ID\", msg.messageId));\n if (msg.listUnsubscribe) lines.push(row(\"Unsubscribe\", msg.listUnsubscribe));\n if (msg.emlPath) lines.push(row(\"EML file\", msg.emlPath));\n lines.push(row(\"Account\", accountId));\n lines.push(row(\"UID\", `${msg.uid} (folder ${msg.folderId})`));\n detailsEl.innerHTML = lines.join(\"\");\n detailsEl.hidden = true;\n detailsBtn.textContent = \"Details\";\n detailsBtn.onclick = () => {\n detailsEl.hidden = !detailsEl.hidden;\n detailsBtn.textContent = detailsEl.hidden ? \"Details\" : \"\\u2713 Details\";\n };\n // Wire copy buttons.\n detailsEl.querySelectorAll<HTMLButtonElement>(\".mv-details-copy\").forEach(btn => {\n btn.addEventListener(\"click\", async (e) => {\n e.stopPropagation();\n const val = btn.dataset.copy || \"\";\n try {\n await navigator.clipboard.writeText(val);\n btn.textContent = \"\u2713\";\n setTimeout(() => { btn.textContent = \"\u29C9\"; }, 1500);\n } catch {\n prompt(\"Copy:\", val);\n }\n });\n });\n }\n\n // Remote content banner (collapsible dropdown with sender/recipient details)\n bodyEl.innerHTML = \"\";\n if (msg.hasRemoteContent) {\n const senderAddr = msg.from?.address || \"\";\n const senderName = msg.from?.name || \"\";\n const senderDomain = senderAddr.split(\"@\")[1] || \"\";\n const deliveredTo = msg.deliveredTo || \"\";\n const toAddr = msg.to?.[0]?.address || \"\";\n const returnPath = msg.returnPath || \"\";\n const isFlagged = !!(msg as any).isFlagged;\n const reputation = (msg as any).reputation as {\n flagged: boolean; listedCount: number; checkedCount: number;\n sources: Array<{ service: string; verdict: string }>;\n verdict: string; service: string;\n } | null;\n const reputationFlagged = !!(reputation && reputation.flagged);\n const reputationText = reputationFlagged\n ? `\u26A0 ${reputation!.listedCount} of ${reputation!.checkedCount} reputation services flag <strong>${escapeText(senderDomain)}</strong> as <strong>${escapeText(reputation!.verdict)}</strong> (${escapeText(reputation!.sources.map(s => s.service).join(\", \"))})`\n : \"\";\n\n const banner = document.createElement(\"div\");\n banner.className = \"mv-remote-banner\"\n + (isFlagged ? \" mv-remote-banner-flagged\" : \"\")\n + (reputationFlagged ? \" mv-remote-banner-reputation\" : \"\");\n banner.innerHTML =\n (isFlagged\n ? `<div class=\"mv-rb-flagged\">\u26A0 Sender on your watch list (you marked this sender or domain suspicious)</div>`\n : \"\") +\n (reputationFlagged\n ? `<div class=\"mv-rb-reputation\">${reputationText}</div>`\n : \"\") +\n `<div class=\"mv-rb-summary\">` +\n `<span class=\"mv-rb-toggle\">▸</span>` +\n `<span>Remote content blocked</span>` +\n `<span class=\"mv-rb-buttons\">` +\n `<button id=\"btn-load-remote\">Load once</button>` +\n `<button id=\"btn-allow-sender\" title=\"${escapeText(senderAddr)}\">Always: ${escapeText(senderAddr)}</button>` +\n (senderDomain ? `<button id=\"btn-allow-domain\" title=\"*@${escapeText(senderDomain)}\">Always: *@${escapeText(senderDomain)}</button>` : \"\") +\n `</span>` +\n `</div>` +\n `<div class=\"mv-rb-details\" hidden>` +\n `<div class=\"mv-rb-info\">` +\n `<div><span class=\"mv-rb-label\">From:</span> ${escapeText(senderName ? `${senderName} <${senderAddr}>` : senderAddr)}</div>` +\n (deliveredTo ? `<div><span class=\"mv-rb-label\">Delivered-To:</span> ${escapeText(deliveredTo)}</div>` : \"\") +\n (toAddr && toAddr !== deliveredTo ? `<div><span class=\"mv-rb-label\">To:</span> ${escapeText(toAddr)}</div>` : \"\") +\n (returnPath && returnPath !== senderAddr ? `<div><span class=\"mv-rb-label\">Return-Path:</span> ${escapeText(returnPath)}</div>` : \"\") +\n `</div>` +\n (deliveredTo || toAddr ? `<div class=\"mv-rb-actions\"><button id=\"btn-allow-to\">Always allow to: ${escapeText(deliveredTo || toAddr)}</button></div>` : \"\") +\n `<div class=\"mv-rb-actions\">` +\n `<button id=\"btn-flag-sender\" class=\"mv-rb-flag-btn\" title=\"${escapeText(senderAddr)}\">${isFlagged ? \"Unwatch\" : \"Watch (mark suspicious)\"} sender</button>` +\n (senderDomain ? `<button id=\"btn-flag-domain\" class=\"mv-rb-flag-btn\" title=\"*@${escapeText(senderDomain)}\">Watch domain *@${escapeText(senderDomain)}</button>` : \"\") +\n `</div>` +\n `<div class=\"mv-rb-actions\"><button id=\"btn-edit-allowlist\" title=\"View / edit the full allowlist\">Edit allowlist\u2026</button></div>` +\n `</div>`;\n bodyEl.appendChild(banner);\n\n // Toggle dropdown \u2014 click arrow or text to expand details\n const summary = banner.querySelector(\".mv-rb-summary\")!;\n const details = banner.querySelector(\".mv-rb-details\") as HTMLElement;\n const toggle = banner.querySelector(\".mv-rb-toggle\")!;\n summary.addEventListener(\"click\", (e) => {\n if ((e.target as HTMLElement).tagName === \"BUTTON\") return;\n details.hidden = !details.hidden;\n toggle.textContent = details.hidden ? \"\\u25B8\" : \"\\u25BE\";\n });\n\n const loadRemote = async () => {\n banner.remove();\n const full = await getMessage(accountId, uid, true);\n if (full.bodyHtml) {\n const oldIframe = bodyEl.querySelector(\"iframe\");\n if (oldIframe) oldIframe.remove();\n const iframe = document.createElement(\"iframe\");\n iframe.srcdoc = wrapHtmlBody(full.bodyHtml, true);\n bodyEl.appendChild(iframe);\n installPreviewControls(iframe);\n }\n // Critical: refresh both `currentMessage` and the parsedCache\n // with the unblocked version. Otherwise any later showMessage\n // call (folder refresh, sync event, etc.) hits the stale\n // cached BLOCKED message, re-renders the banner, and the\n // user's click looks like it was forgotten \u2014 even though the\n // allowlist persist actually worked. Bob 2026-05-09:\n // \"you added it to allowed but don't know it!\"\n currentMessage = full;\n currentAccountId = accountId;\n parsedCachePut(accountId, uid, full);\n markSessionAllowed(accountId, uid);\n };\n\n banner.querySelector(\"#btn-load-remote\")!.addEventListener(\"click\", loadRemote);\n\n // Always-allow handlers: render the unblocked view IMMEDIATELY,\n // then persist the allowlist entry in the background. Pre-fix\n // these awaited the persist before re-rendering \u2014 if the persist\n // threw (GDrive mount unavailable, atomicWrite failure, IPC\n // error), the unhandled rejection silently aborted the handler\n // and the click looked like a no-op. Optimistic order matches\n // the rest of the local-first model: the user's action commits\n // visually, sync surfaces problems via the status bar.\n const persistAllow = async (type: \"sender\" | \"domain\" | \"recipient\", value: string): Promise<void> => {\n try {\n await allowRemoteContent(type, value);\n } catch (e: any) {\n const status = document.getElementById(\"status-sync\");\n if (status) {\n status.textContent = `Allowlist save failed (${type}: ${value}) \u2014 ${e?.message || e}`;\n status.style.color = \"oklch(0.65 0.2 25)\";\n }\n console.error(`[allowlist] ${type}=${value} save failed:`, e);\n }\n };\n\n banner.querySelector(\"#btn-allow-sender\")?.addEventListener(\"click\", () => {\n loadRemote();\n persistAllow(\"sender\", senderAddr);\n });\n\n banner.querySelector(\"#btn-allow-domain\")?.addEventListener(\"click\", () => {\n loadRemote();\n persistAllow(\"domain\", senderDomain);\n });\n\n banner.querySelector(\"#btn-allow-to\")?.addEventListener(\"click\", () => {\n const addr = deliveredTo || toAddr;\n if (!addr) return;\n loadRemote();\n persistAllow(\"recipient\", addr);\n });\n\n // Watch (or unwatch) sender / domain \u2014 toggles the allowlist's\n // flaggedSenders / flaggedDomains lists (kept under the legacy\n // JSONC field names for backwards compat; UI now uses \"watch /\n // suspicious\" wording to disambiguate from the per-message \u2691/\u2605\n // marker which means the OPPOSITE \u2014 \"important to me\"). Future\n // messages from this sender/domain show a \u26A0 banner at the top\n // of the viewer. Note: marking a single sender is unreliable\n // because spammers rotate addresses \u2014 see TODO for rules.jsonc\n // which will support pattern-based matching as a primary tool.\n const onFlagToggle = async (type: \"sender\" | \"domain\", value: string) => {\n if (!value) return;\n try {\n const result = await flagSenderOrDomain(type, value);\n const status = document.getElementById(\"status-sync\");\n if (status) status.textContent = result.flagged\n ? `Watching ${type}: ${value}`\n : `Unwatched ${type}: ${value}`;\n // Re-render this message so the banner picks up the new\n // flagged state without the user having to reselect.\n // `isRetry=true` bypasses the same-message guard at the\n // top of showMessage \u2014 we genuinely want a fresh paint\n // here, even though scroll position will reset (the\n // user just clicked a control, so scroll loss is\n // expected, not surprising).\n if (currentMessage) {\n invalidateParsedCache(currentAccountId, currentMessage.uid);\n showMessage(currentAccountId, currentMessage.uid, currentMessage.folderId, specialUse, true).catch(() => { /* */ });\n }\n } catch (e: any) {\n const status = document.getElementById(\"status-sync\");\n if (status) status.textContent = `Watch failed: ${e?.message || e}`;\n }\n };\n banner.querySelector(\"#btn-flag-sender\")?.addEventListener(\"click\", () => onFlagToggle(\"sender\", senderAddr));\n banner.querySelector(\"#btn-flag-domain\")?.addEventListener(\"click\", () => onFlagToggle(\"domain\", senderDomain));\n\n // \"Edit allowlist\u2026\" \u2014 fires a document-level event that app.ts\n // listens for and opens the JSONC editor pre-selected to\n // allowlist.jsonc. Keeps message-viewer free of the editor import.\n banner.querySelector(\"#btn-edit-allowlist\")?.addEventListener(\"click\", () => {\n document.dispatchEvent(new CustomEvent(\"mailx-open-jsonc-editor\", { detail: { file: \"allowlist.jsonc\" } }));\n });\n\n }\n\n // bodyError / bodyErrorTransient retired in the local-first refactor.\n // Background body fetches that fail emit `bodyFetchError`, surfaced\n // by the module-level listener as a banner \u2014 no inline retry.\n\n // Body in sandboxed iframe\n if (msg.bodyHtml) {\n const iframe = document.createElement(\"iframe\");\n iframe.sandbox.add(\"allow-same-origin\");\n iframe.sandbox.add(\"allow-popups\");\n iframe.sandbox.add(\"allow-popups-to-escape-sandbox\");\n iframe.sandbox.add(\"allow-top-navigation-by-user-activation\");\n // allow-scripts lets OUR injected <script> run (for Android link\n // interception \u2014 parent-side contentDocument listeners don't fire\n // reliably on Android WebView). CSP with a nonce restricts script\n // execution to our tag only; inline scripts in the email body are\n // still blocked.\n iframe.sandbox.add(\"allow-scripts\");\n iframe.srcdoc = wrapHtmlBody(msg.bodyHtml, msg.remoteAllowed);\n // Two timing marks: text-painted (DOMContentLoaded, which fires\n // as soon as the HTML is parsed \u2014 typically tens of ms) and\n // all-loaded (load, which waits for every <img> to resolve and\n // can be 10+ s on a marketing email with remote content). The\n // user can read the email at the DOMContentLoaded mark; the\n // earlier single `load` mark misled the timing story.\n iframe.addEventListener(\"load\", () => _ptick(\"iframe load (all resources)\"), { once: true });\n const onReady = () => {\n const doc = iframe.contentDocument;\n if (!doc) return;\n const fire = () => _ptick(\"iframe DOMContentLoaded (text painted)\");\n if (doc.readyState === \"interactive\" || doc.readyState === \"complete\") fire();\n else doc.addEventListener(\"DOMContentLoaded\", fire, { once: true });\n };\n // srcdoc-driven iframes go through a single `load` lifecycle, but\n // the contentDocument is observable as soon as the iframe is in\n // the DOM. Schedule the readiness check after append.\n bodyEl.appendChild(iframe);\n queueMicrotask(onReady);\n _ptick(\"iframe srcdoc set\");\n installPreviewControls(iframe);\n } else if (msg.bodyText) {\n const pre = document.createElement(\"pre\");\n // Match the HTML-body branch's typography: sans-serif system\n // font, same size + line-height. Default `<pre>` rendering is\n // monospace, which on some platforms substitutes to a serif\n // fallback (Bob 2026-05-12: \"why the font change\") and looks\n // like a different message from the same conversation.\n pre.style.cssText = \"padding: 1rem; white-space: pre-wrap; word-break: break-word; \"\n + \"font-family: system-ui, sans-serif; font-size: 17.5px; line-height: 1.5; \"\n + \"color: #1a1a2e; background: #fff; margin: 0;\";\n // Auto-linkify URLs in plain text\n pre.innerHTML = linkifyText(msg.bodyText);\n bodyEl.appendChild(pre);\n } else {\n // No bodyHtml AND no bodyText. The daemon thinks the body is\n // on disk (`cached === true`) but extracted nothing. Most\n // common: the body fetch errored earlier (network blip,\n // 401, 5xx), the row was marked cached anyway, and we have\n // an empty .eml. Surface the last known fetch error for\n // this uid if we have one \u2014 Bob 2026-05-09: \"if there is a\n // fail to fetch then report the problem rather than telling\n // me there is no content.\"\n const fetchErr = recentFetchErrors.get(`${accountId}:${uid}`);\n if (fetchErr) {\n bodyEl.innerHTML = `<div class=\"mv-system-message mv-system-error\">\n <div class=\"mv-system-tag\">mailx</div>\n <div class=\"mv-system-title\">Body fetch failed</div>\n <div class=\"mv-system-body\">${escapeHtml(fetchErr.error)}<br><span style=\"color:var(--color-text-muted);font-size:0.9em\">Recorded ${Math.round((Date.now() - fetchErr.when) / 1000)}s ago. ${fetchErr.transient ? \"Will retry automatically.\" : \"Permanent \u2014 server-side delete may have raced.\"}</span></div>\n </div>`;\n } else {\n // Empty parse result. Surface every diagnostic crumb the\n // user could use to tell \"real empty body\" from \"stub file\n // / parser bug / fetch incomplete\". Bob 2026-05-15: \"if\n // bodies are on disk per the blue dot then a 0-byte parse\n // is a bug \u2014 show as much useful information as possible.\"\n const emlPath = (msg as any).emlPath || \"\";\n const attCount = msg.attachments?.length || 0;\n const flags = Array.isArray((msg as any).flags) ? (msg as any).flags.join(\", \") : \"\";\n // App version on this diagnostic so a screenshot is traceable\n // to a build. Read from the version element the toolbar/status\n // bar already populates; falls back to \"?\" if not yet set.\n const appVer = (document.querySelector(\".app-version, #app-version, #status-version\")?.textContent || \"\").trim();\n bodyEl.innerHTML = `<div class=\"mv-system-message\">\n <div class=\"mv-system-tag\">mailx${appVer ? \" \" + escapeHtml(appVer) : \"\"}</div>\n <div class=\"mv-system-title\">Body parsed empty</div>\n <div class=\"mv-system-body\" style=\"white-space:pre-line\">${escapeHtml([\n `Parser produced 0 bytes for both bodyHtml and bodyText.`,\n emlPath ? `.eml file: ${emlPath}` : `.eml file: (no body_path in DB)`,\n `Attachments: ${attCount}`,\n flags ? `Flags: ${flags}` : \"\",\n `Message: ${accountId}/${uid}${folderId ? ` (folder ${folderId})` : \"\"}`,\n ``,\n `Likely causes: (a) sender included only attachments/images, (b) the fetched .eml is a stub (0 bytes on disk \u2014 fetch was incomplete), (c) the body is signed/encrypted in a way the parser doesn't unwrap.`,\n ].filter(Boolean).join(\"\\n\"))}</div>\n </div>`;\n }\n }\n\n // Attachments \u2014 always clear first to avoid stale chips from previous message\n attEl.innerHTML = \"\";\n attEl.hidden = true;\n if (msg.attachments?.length) {\n attEl.hidden = false;\n for (let i = 0; i < msg.attachments.length; i++) {\n const att = msg.attachments[i];\n // Button (not <a href=\"#\">): the browser/WebView2 native\n // context menu offered \"Open in new window\" which navigated\n // to msger.localhost/index.html# and 404'd. Switching to a\n // button removes that path; download happens via a\n // programmatic <a download> click which is reliable across\n // msger / WebView2 / Android WebView (window.open of a blob\n // URL is silently blocked in some hosts).\n const chip = document.createElement(\"button\");\n chip.type = \"button\";\n chip.className = \"mv-att-chip\";\n chip.textContent = `\\uD83D\\uDCCE ${att.filename} (${formatSize(att.size)})`;\n chip.title = `${att.filename} (${att.mimeType})`;\n chip.addEventListener(\"click\", async (e) => {\n e.preventDefault();\n // Click feedback + re-click guard. Opening hands off to the\n // OS viewer (~200ms) with no visible change in mailx, so the\n // user used to click 3-4 times. Show \"Opening\u2026\" for at least\n // 600ms and ignore clicks while it's in flight.\n if (chip.dataset.opening === \"1\") return;\n chip.dataset.opening = \"1\";\n const chipLabel = chip.textContent || \"\";\n chip.textContent = \"\u23F3 Opening\u2026\";\n const tStart = Date.now();\n const restoreChip = (): void => {\n const wait = Math.max(0, 600 - (Date.now() - tStart));\n setTimeout(() => { chip.textContent = chipLabel; chip.dataset.opening = \"\"; }, wait);\n };\n try {\n const bridge = (window as any)._nativeBridge;\n if (bridge?.openAttachment) {\n // Android: blob URLs don't work in WebView. Pass base64\n // to native bridge which saves to Downloads and opens\n // with the system viewer.\n const data = await getAttachment(accountId, uid, i, msg.folderId);\n await bridge.openAttachment(att.filename, data.contentType, data.content);\n return;\n }\n // Desktop (IPC): the Node service saves the file and opens\n // it with the OS default app. openAttachment() returns the\n // result object when an IPC host handled it, or `undefined`\n // only when the host has no such method (real browser /\n // --server mode). A genuine service failure throws \\u2014 we do\n // NOT blob-fall-back in that case (the service is the only\n // thing that can open a file on the desktop, and the blob\n // path just produced a misleading second error).\n const res = await openAttachment(accountId, uid, i, msg.folderId);\n if (res) return;\n // res === undefined \\u2192 real browser / --server mode only.\n const data = await getAttachment(accountId, uid, i, msg.folderId);\n const bytes = Uint8Array.from(atob(data.content), c => c.charCodeAt(0));\n const blob = new Blob([bytes], { type: data.contentType });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = att.filename || \"attachment\";\n a.style.display = \"none\";\n document.body.appendChild(a);\n a.click();\n // Defer revoke until the download has had time to start.\n setTimeout(() => { a.remove(); URL.revokeObjectURL(url); }, 5000);\n } catch (err: any) {\n // Non-blocking banner \\u2014 never alert(): a modal alert\n // freezes the WebView event loop, which stalls IPC event\n // delivery and makes sync look like it stopped.\n const m = `Couldn't open \"${att.filename}\": ${err?.message || err}`;\n console.error(m);\n window.dispatchEvent(new CustomEvent(\"mailx-alert\", { detail: { message: m, key: \"attachment-open\" } }));\n } finally {\n restoreChip();\n }\n });\n // Drag the chip to an external target (Explorer / Finder / Files app)\n // to drop the file there. Uses the Chromium `DownloadURL` dataTransfer\n // format: \"mime:filename:blob-url\". We fetch the attachment first so\n // the blob URL is valid by the time the drop lands.\n chip.draggable = true;\n chip.addEventListener(\"dragstart\", async (e) => {\n if (!e.dataTransfer) return;\n try {\n const data = await getAttachment(accountId, uid, i, msg.folderId);\n const bytes = Uint8Array.from(atob(data.content), c => c.charCodeAt(0));\n const blob = new Blob([bytes], { type: data.contentType || \"application/octet-stream\" });\n const url = URL.createObjectURL(blob);\n // Sanitize filename: no path separators, no newlines.\n const safeName = (att.filename || \"attachment\").replace(/[\\r\\n\"\\/\\\\]/g, \"_\");\n const downloadUrl = `${data.contentType || \"application/octet-stream\"}:${safeName}:${url}`;\n e.dataTransfer.setData(\"DownloadURL\", downloadUrl);\n e.dataTransfer.effectAllowed = \"copy\";\n } catch (err: any) {\n console.error(`Attachment drag-out failed: ${err.message || err}`);\n }\n });\n attEl.appendChild(chip);\n }\n }\n } catch (e: any) {\n const err = e.message || \"Unknown error\";\n console.error(\"showMessage error:\", e);\n // \"Message was deleted from the server\" \u2014 the service already dropped\n // the local row. Remove it from the list so the UI advances to the next\n // message instead of sitting on a stale error banner.\n const isNotFound = /deleted from the server|isNotFound|not found|Not Found|404/.test(err);\n if (isNotFound) {\n // Drop the stale row so the list auto-advances to the next message\n // (or clears the viewer). Leaves the user a way back on mobile where\n // the viewer takes the whole screen. The list owns focus handoff \u2014\n // we surface the removal as an event and let it run its own\n // removeMessages flow (which both filters the list and re-focuses\n // a survivor or clears this pane).\n document.dispatchEvent(new CustomEvent(\"mailx-remove-stale\", {\n detail: { accountId, uid },\n }));\n return;\n }\n if (retryCount < 3) {\n retryCount++;\n bodyEl.innerHTML = `<div class=\"mv-empty\">Loading failed: ${err} \u2014 retrying (${retryCount}/3)...</div>`;\n setTimeout(() => { if (gen === showMessageGeneration) showMessage(accountId, uid, folderId, specialUse, true); }, 3000);\n } else {\n bodyEl.innerHTML = `<div class=\"mv-empty\">Failed to load: ${err}</div>`;\n }\n }\n}\n\nfunction formatAddr(addr: { name: string; address: string }): string {\n if (addr.name) return `${addr.name} <${addr.address}>`;\n return addr.address;\n}\n\n/** Render the viewer header from a list-row envelope (instant \u2014 no body\n * fetch awaited). Used to populate the header pane the moment a message\n * is clicked so the user always sees something actionable; getMessage()\n * later overwrites the same fields with the authoritative values from the\n * body parse (which can add Cc, Delivered-To, etc. that the list row\n * doesn't track). */\nfunction renderHeaderFromEnvelope(headerEl: HTMLElement, env: any): void {\n headerEl.hidden = false;\n const fromEl = headerEl.querySelector(\".mv-from\");\n const toEl = headerEl.querySelector(\".mv-to\");\n const subjEl = headerEl.querySelector(\".mv-subject\");\n const dateEl = headerEl.querySelector(\".mv-date\");\n if (fromEl) fromEl.textContent = formatAddr(env.from);\n if (toEl) {\n let toLine = `To: ${(env.to || []).map(formatAddr).join(\", \")}`;\n if (env.cc?.length) toLine += ` Cc: ${env.cc.map(formatAddr).join(\", \")}`;\n toEl.textContent = toLine;\n }\n if (subjEl) subjEl.textContent = env.subject || \"\";\n if (dateEl) {\n try { dateEl.textContent = new Date(env.date).toLocaleString(); }\n catch { dateEl.textContent = \"\"; }\n }\n}\n\nfunction escapeHtml(s: string): string {\n return (s || \"\").replace(/[&<>\"']/g, c =>\n ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", \"\\\"\": \""\", \"'\": \"'\" }[c]!));\n}\n\n/** Convert plain text URLs into clickable links, escaping HTML */\nfunction linkifyText(text: string): string {\n // Escape HTML first\n const escaped = text.replace(/&/g, \"&\").replace(/</g, \"<\").replace(/>/g, \">\");\n // Then linkify URLs\n return escaped.replace(\n /(https?:\\/\\/[^\\s<>\"')\\]]+)/g,\n '<a href=\"$1\" target=\"_blank\" rel=\"noopener noreferrer\">$1</a>'\n );\n}\n\nfunction escapeText(s: string): string {\n const div = document.createElement(\"div\");\n div.textContent = s;\n return div.innerHTML;\n}\n\n/** Minimal add-contact modal: name + email + organization with a duplicate\n * check (checks the contacts DB for an existing row with the same email\n * and surfaces it so the user can update instead of creating a second\n * row with a different name). Future: AI-extracted fields from the letter\n * body populate the form before it opens. */\nasync function showAddContactDialog(nameIn: string, emailIn: string): Promise<void> {\n let dup: { name: string; email: string; source: string } | null = null;\n try {\n const existing = await listContacts(emailIn, 1, 10);\n const match = (existing?.items || []).find((c: any) => (c.email || \"\").toLowerCase() === emailIn.toLowerCase());\n if (match) dup = match;\n } catch { /* non-fatal \u2014 dialog still works without dup info */ }\n\n const backdrop = document.createElement(\"div\");\n backdrop.className = \"mailx-modal-backdrop\";\n const panel = document.createElement(\"div\");\n panel.className = \"mailx-modal\";\n panel.innerHTML = `\n <div class=\"mailx-modal-title\">\n <span class=\"mailx-modal-title-text\">${dup ? \"Update contact\" : \"Add contact\"}</span>\n <button type=\"button\" class=\"mailx-modal-close\" id=\"ac-close\" aria-label=\"Close\">×</button>\n </div>\n ${dup ? `<div class=\"mailx-modal-info\">Already in address book as <strong>${escapeText(dup.name || \"(no name)\")}</strong> (${escapeText(dup.source)}). Saving will update the name.</div>` : \"\"}\n <label class=\"mailx-modal-label\">Name\n <input class=\"mailx-modal-input\" id=\"ac-name\" type=\"text\" value=\"${escapeText(dup?.name || nameIn || \"\")}\" autofocus>\n </label>\n <label class=\"mailx-modal-label\">Email\n <input class=\"mailx-modal-input\" id=\"ac-email\" type=\"email\" value=\"${escapeText(emailIn)}\" readonly>\n </label>\n <label class=\"mailx-modal-label\">Organization <span style=\"color:var(--color-text-muted);font-size:0.85em\">(optional)</span>\n <input class=\"mailx-modal-input\" id=\"ac-org\" type=\"text\" placeholder=\"\">\n </label>\n <div class=\"mailx-modal-buttons\">\n <span class=\"mailx-modal-spacer\"></span>\n <button type=\"button\" class=\"mailx-modal-btn\" data-action=\"cancel\">Cancel</button>\n <button type=\"button\" class=\"mailx-modal-btn mailx-modal-btn-primary\" data-action=\"save\">${dup ? \"Update\" : \"Save\"}</button>\n </div>`;\n backdrop.appendChild(panel);\n document.body.appendChild(backdrop);\n\n const close = () => backdrop.remove();\n panel.querySelector<HTMLButtonElement>(\"#ac-close\")!.addEventListener(\"click\", close);\n panel.querySelectorAll<HTMLButtonElement>(\".mailx-modal-btn\").forEach(btn => {\n btn.addEventListener(\"click\", async () => {\n if (btn.dataset.action === \"cancel\") { close(); return; }\n const nameEl = panel.querySelector<HTMLInputElement>(\"#ac-name\")!;\n const emailEl = panel.querySelector<HTMLInputElement>(\"#ac-email\")!;\n btn.disabled = true;\n btn.textContent = \"Saving\u2026\";\n try {\n // upsertContact is the two-way cache path (enqueues a Google\n // People push); for pure local-first addContact would also\n // work but skips the Google sync. Use upsertContact so the\n // row propagates to Google Contacts next drain tick.\n await upsertContact(nameEl.value.trim(), emailEl.value.trim());\n close();\n } catch (e: any) {\n btn.disabled = false;\n btn.textContent = dup ? \"Update\" : \"Save\";\n alert(`Couldn't save: ${e?.message || e}`);\n }\n });\n });\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") { close(); document.removeEventListener(\"keydown\", onKey, true); }\n };\n document.addEventListener(\"keydown\", onKey, true);\n // addContact is kept as a legacy silent path (no-form) for any caller\n // that still invokes it \u2014 currently none after this refactor.\n void addContact;\n}\n\nfunction formatSize(bytes: number): string {\n if (bytes < 1024) return `${bytes} B`;\n if (bytes < 1048576) return `${(bytes / 1024).toFixed(0)} KB`;\n return `${(bytes / 1048576).toFixed(1)} MB`;\n}\n\nexport function wrapHtmlBody(html: string, allowRemote = false): string {\n // CSP blocks remote resources (tracking pixels, external CSS). Inline\n // scripts are allowed via 'unsafe-inline' so our injected link-tap handler\n // runs; email-body <script> tags and on* handlers are stripped server-side\n // by sanitizeHtml() in mailx-core, so this doesn't actually widen the\n // attack surface. (A per-render nonce would be tidier, but meta-CSP with\n // nonces isn't reliably honored across older WebViews \u2014 and when a nonce\n // is present, 'unsafe-inline' is ignored, so our script fell back to\n // blocked on those WebViews.)\n const csp = allowRemote\n ? \"\"\n : `<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: cid:; form-action 'none';\">`;\n return `<!DOCTYPE html>\n<html><head>\n<meta charset=\"UTF-8\">\n${csp}\n<style>\n html, body { touch-action: pan-y pinch-zoom; }\n html { height: 100%; overflow-y: auto; overflow-x: hidden; }\n body {\n font-family: system-ui, sans-serif;\n font-size: 17.5px;\n line-height: 1.5;\n color: #1a1a2e;\n background: #fff;\n padding: 1rem;\n margin: 0;\n min-height: 100%;\n word-break: break-word;\n color-scheme: dark light;\n }\n img { max-width: 100%; height: auto; }\n a { color: #1a6dd4; }\n pre, code { white-space: pre-wrap; }\n blockquote { border-left: 3px solid #ccc; padding-left: 1rem; margin-left: 0; color: #666; }\n @media (prefers-color-scheme: dark) {\n body { color: #cdd6f4; background: #282840; }\n a { color: #89b4fa; }\n blockquote { border-color: #45475a; color: #6c7086; }\n }\n</style>\n<base target=\"_blank\">\n<script>\n// Link interception \u2014 Android WebView doesn't fire the default <a target=\"_blank\">\n// new-window handler, so we postMessage to the parent which routes through the\n// native bridge (mailxapi://openurl) to Launcher.OpenAsync.\n(function () {\n function handleLinkTap(e) {\n var a = e.target && e.target.closest ? e.target.closest(\"a[href]\") : null;\n if (!a) return;\n var url = a.href;\n if (!url || url.indexOf(\"javascript:\") === 0 || url.charAt(0) === \"#\") return;\n e.preventDefault();\n e.stopPropagation();\n window.parent.postMessage({ type: \"linkClick\", url: url }, \"*\");\n }\n document.addEventListener(\"click\", handleLinkTap, true);\n // Android WebView fallback: some builds drop the synthetic click after\n // touchend, so treat a stationary touchstart\u2192touchend on the same link\n // as a tap. Anything that moves more than TAP_SLOP pixels is a scroll\n // and must NOT activate the link.\n var TAP_SLOP = 10;\n var lastTouchTarget = null;\n var lastTouchX = 0;\n var lastTouchY = 0;\n var touchMoved = false;\n // All touch listeners are passive so Android WebView can compositor-scroll\n // the iframe without waiting on our JS. handleLinkTap's preventDefault only\n // matters for the \"click\" path (which is non-passive by default).\n document.addEventListener(\"touchstart\", function (e) {\n var t0 = e.touches && e.touches[0];\n lastTouchX = t0 ? t0.clientX : 0;\n lastTouchY = t0 ? t0.clientY : 0;\n touchMoved = false;\n lastTouchTarget = (e.target && e.target.closest) ? e.target.closest(\"a[href]\") || e.target : e.target;\n }, { passive: true, capture: true });\n document.addEventListener(\"touchmove\", function (e) {\n if (touchMoved) return;\n var t = e.touches && e.touches[0];\n if (!t) return;\n if (Math.abs(t.clientX - lastTouchX) > TAP_SLOP || Math.abs(t.clientY - lastTouchY) > TAP_SLOP) {\n touchMoved = true;\n lastTouchTarget = null;\n }\n }, { passive: true, capture: true });\n document.addEventListener(\"touchend\", function (e) {\n if (touchMoved) { lastTouchTarget = null; touchMoved = false; return; }\n var t = (e.target && e.target.closest) ? e.target.closest(\"a[href]\") || e.target : e.target;\n if (lastTouchTarget && lastTouchTarget === t) handleLinkTap(e);\n lastTouchTarget = null;\n }, { passive: true, capture: true });\n document.addEventListener(\"touchcancel\", function () {\n lastTouchTarget = null; touchMoved = false;\n }, { passive: true, capture: true });\n // Link hover preview \u2014 posts the link URL to the parent on hover so\n // the parent can show a tooltip after a 500 ms dwell. Earlier removal\n // (2026-04-24) was because dismissers in the parent (mousedown / scroll\n // / blur) don't fire while the mouse is inside the iframe, leaving the\n // popover stuck. Restored 2026-05-09 with the proper fix: the iframe\n // explicitly posts an empty-URL message on link mouseout AND on body\n // mousemove-away, so the parent reliably hides regardless of which\n // dismissers it sees. The parent's debounced 500 ms show-timer still\n // suppresses flicker.\n var lastHoveredHref = \"\";\n function postLinkHover(href, rect) {\n // Only post on transitions to avoid spamming the parent on every\n // mousemove inside a link.\n if (href === lastHoveredHref) return;\n lastHoveredHref = href;\n window.parent.postMessage({\n type: \"linkHover\",\n url: href,\n rect: rect ? { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom } : null,\n }, \"*\");\n }\n document.addEventListener(\"mouseover\", function (e) {\n var a = e.target && e.target.closest ? e.target.closest(\"a[href]\") : null;\n if (!a) return;\n var href = a.getAttribute(\"href\") || \"\";\n if (!href || href.charAt(0) === \"#\" || href.indexOf(\"javascript:\") === 0) return;\n var r = a.getBoundingClientRect();\n postLinkHover(a.href || href, r);\n }, true);\n document.addEventListener(\"mouseout\", function (e) {\n var a = e.target && e.target.closest ? e.target.closest(\"a[href]\") : null;\n if (!a) return;\n // Only fire when leaving the link entirely (not into a child element).\n var to = e.relatedTarget;\n if (to && to.closest && to.closest(\"a[href]\") === a) return;\n postLinkHover(\"\", null);\n }, true);\n // Mouseleave on body covers the case where the cursor exits the\n // iframe entirely without firing mouseout on the link (Edge / older\n // WebView2 builds). Belt-and-suspenders dismissal.\n document.addEventListener(\"mouseleave\", function () {\n if (lastHoveredHref) postLinkHover(\"\", null);\n }, true);\n // Note: iframe-level dblclick fullscreen toggle REMOVED \u2014 it hijacked\n // word-selection (Bob 2026-05-11). Double-clicking text inside the\n // preview now selects the word like any other browser surface; the\n // fullscreen toggle lives on the viewer chrome (mv-header) handler\n // in app.ts, which explicitly excludes interactive controls but\n // operates on chrome, not iframe content.\n\n // Receive commands from the parent (Copy / Select all menu actions).\n // Browsers restrict execCommand(\"copy\") to the document where focus\n // lives \u2014 running it in the parent against the iframe's selection is\n // a no-op, so the parent posts the command in and we run it here.\n window.addEventListener(\"message\", function (e) {\n var d = e.data;\n if (!d || d.type !== \"previewCommand\") return;\n if (d.cmd === \"copy\") {\n try { document.execCommand(\"copy\"); } catch (_) {}\n } else if (d.cmd === \"selectAll\") {\n try {\n var s = window.getSelection && window.getSelection();\n if (s) {\n var r = document.createRange();\n r.selectNodeContents(document.body);\n s.removeAllRanges();\n s.addRange(r);\n }\n } catch (_) {}\n }\n });\n\n // Right-click \u2192 unified context menu. Always send 'previewContextMenu'\n // with both link info (when over a link) AND selection / body access.\n // Quora-style pages are wall-to-wall links \u2014 branching on target meant\n // body actions (Translate / Zoom) were unreachable. Combined menu\n // shows link actions first when applicable, body actions always.\n // Suppresses the native WebView2 menu unconditionally \u2014 without that,\n // its Back/Refresh/Save-as/Inspect menu fires before the parent\n // contentDocument listener attached.\n document.addEventListener(\"contextmenu\", function (e) {\n e.preventDefault();\n e.stopPropagation();\n var rect = { left: 0, top: 0 };\n try { rect = e.target.getBoundingClientRect ? e.target.getBoundingClientRect() : rect; } catch (_) {}\n var a = e.target && e.target.closest ? e.target.closest(\"a[href]\") : null;\n var sel = (window.getSelection && window.getSelection()) ? window.getSelection().toString() : \"\";\n window.parent.postMessage({\n type: \"previewContextMenu\",\n selectedText: sel,\n linkUrl: a ? a.href : \"\",\n linkText: a ? (a.textContent || \"\").slice(0, 100) : \"\",\n x: e.clientX, y: e.clientY,\n iframeLeft: rect.left, iframeTop: rect.top\n }, \"*\");\n });\n // Iframe pointerdown \u2192 tell the parent to dismiss any open context menu.\n // The parent's dismissListener listens to its own document's pointerdown,\n // but iframe events don't bubble across the boundary; without this, a\n // click in the message body to dismiss the menu would be invisible to\n // the parent and the menu would stick open.\n document.addEventListener(\"pointerdown\", function (e) {\n // The contextmenu handler above ALSO fires pointerdown on right-click;\n // that one opens a fresh menu, which already closes the previous.\n // Skip right-click (button 2) to avoid a publish/close race.\n if (e.button === 2) return;\n try { window.parent.postMessage({ type: \"iframePointerDown\" }, \"*\"); } catch (_) {}\n }, true);\n // Key forwarding \u2014 Delete, Ctrl+D, arrow keys, etc. need to reach app.ts\n // even when focus is inside the sandboxed iframe. Parent-side\n // contentDocument listeners (see installPreviewControls) work on\n // desktop WebView2 but not Android WebView, so we post every keydown\n // that isn't plain typing.\n document.addEventListener(\"keydown\", function (e) {\n var t = e.target;\n if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;\n // Zoom keys handled by parent-side installPreviewControls; don't double-send.\n if (e.ctrlKey && (e.key === \"=\" || e.key === \"+\" || e.key === \"-\" || e.key === \"0\")) return;\n // Preventing the iframe's default for keys we forward to the parent\n // is essential \u2014 the parent's preventDefault on the synthetic\n // keydown can't suppress the browser's reaction to the ORIGINAL\n // event (Ctrl+R reload, Ctrl+F find, etc.). Suppress here so the\n // browser doesn't act before the parent processes the action.\n var k = (e.key || \"\").toLowerCase();\n var isShortcut = e.ctrlKey && !e.altKey && !e.metaKey && (\n k === \"r\" || k === \"f\" || k === \"n\" || k === \"a\" || k === \"d\" ||\n k === \"z\" || k === \"y\" || k === \"k\"\n );\n if (isShortcut) e.preventDefault();\n window.parent.postMessage({\n type: \"previewKey\",\n key: e.key, code: e.code,\n ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, altKey: e.altKey, metaKey: e.metaKey,\n }, \"*\");\n });\n})();\n</script>\n</head><body>${html}</body></html>`;\n}\n\n/** Open the current message in a separate view: floating draggable overlay\n * on desktop (multiple at once, like compose), full-screen mode on mobile.\n * Threshold matches the layout.css responsive breakpoint so the experience\n * is consistent with other narrow-mode behavior. Snapshot in time \u2014 the\n * pop-out doesn't auto-update if the user clicks another message. */\nexport function popOutCurrentMessage(): void {\n if (!currentMessage) return;\n const isNarrow = window.innerWidth <= 768;\n if (isNarrow) {\n document.body.classList.toggle(\"viewer-fullscreen\");\n return;\n }\n // Drafts: popout means \"open in compose for editing\", not \"open as\n // read-only window\". The read-only popout has no Reply / Edit / Send\n // buttons, so handing a draft to it is a dead end. Same routing as\n // the double-click \u2192 mailx-popout-message handler in app.ts.\n const flags: string[] = Array.isArray((currentMessage as any).flags) ? (currentMessage as any).flags : [];\n if (flags.includes(\"\\\\Draft\")) {\n document.dispatchEvent(new CustomEvent(\"mailx-popout-message\", {\n detail: {\n accountId: currentAccountId,\n uid: (currentMessage as any).uid,\n folderId: (currentMessage as any).folderId,\n subject: (currentMessage as any).subject,\n },\n }));\n return;\n }\n spawnDesktopPopout(currentMessage, currentAccountId);\n}\n\n/** Print the currently-displayed message. Builds a self-contained printable\n * document \u2014 a header block (From/To/Cc/Subject/Date) plus the message body\n * \u2014 renders it in an off-screen iframe, and fires the print dialog. There\n * was no way to print a letter before (Bob 2026-05-21). */\nexport function printCurrentMessage(): void {\n if (!currentMessage) return;\n const m = currentMessage;\n const fmt = (a: { name?: string; address?: string }): string =>\n a?.name ? `${a.name} <${a.address || \"\"}>` : (a?.address || \"\");\n const esc = escapeHtmlLocal;\n const row = (label: string, value: string): string =>\n value ? `<div><strong>${label}:</strong> ${esc(value)}</div>` : \"\";\n const headerHtml =\n `<div style=\"font-family:system-ui,Segoe UI,sans-serif;font-size:11pt;` +\n `border-bottom:1px solid #999;padding-bottom:8px;margin-bottom:14px;line-height:1.5;\">` +\n row(\"From\", fmt(m.from || {})) +\n row(\"To\", (m.to || []).map(fmt).join(\", \")) +\n (m.cc?.length ? row(\"Cc\", m.cc.map(fmt).join(\", \")) : \"\") +\n row(\"Subject\", m.subject || \"\") +\n row(\"Date\", m.date ? new Date(m.date).toLocaleString() : \"\") +\n `</div>`;\n const bodyHtml = m.bodyHtml\n ? m.bodyHtml\n : `<pre style=\"white-space:pre-wrap;word-break:break-word;font-family:system-ui,sans-serif;\">${esc(m.bodyText || \"\")}</pre>`;\n const doc =\n `<!DOCTYPE html><html><head><meta charset=\"utf-8\">` +\n `<title>${esc(m.subject || \"Message\")}</title>` +\n `<style>@media print{body{margin:0;}}body{margin:16px;}img{max-width:100%;}</style>` +\n `</head><body>${headerHtml}${bodyHtml}</body></html>`;\n\n const iframe = document.createElement(\"iframe\");\n iframe.style.cssText = \"position:fixed;left:-9999px;width:1px;height:1px;border:0;\";\n iframe.srcdoc = doc;\n iframe.addEventListener(\"load\", () => {\n const win = iframe.contentWindow;\n if (!win) { iframe.remove(); return; }\n // afterprint fires when the dialog closes (Chromium/WebView2). A\n // fallback timeout covers hosts that don't emit it so the hidden\n // iframe never leaks.\n let removed = false;\n const cleanup = (): void => { if (!removed) { removed = true; iframe.remove(); } };\n win.addEventListener(\"afterprint\", cleanup, { once: true });\n setTimeout(cleanup, 120_000);\n try { win.focus(); win.print(); }\n catch (e) { console.error(\"[print] failed:\", e); cleanup(); }\n }, { once: true });\n document.body.appendChild(iframe);\n}\n\n/** Build a floating overlay carrying a snapshot of the message: header\n * (subject, from, to, date) + sandboxed body iframe + attachment chips.\n * Reuses the compose-overlay drag/resize/close pattern. Independent of the\n * main viewer \u2014 opening pop-out for message A then switching the main pane\n * to message B leaves A visible in its overlay. */\nfunction spawnDesktopPopout(msg: any, accountId: string): void {\n const wrapper = document.createElement(\"div\");\n wrapper.className = \"compose-overlay viewer-popout\";\n wrapper.style.cssText = \"position:fixed;top:60px;right:20px;width:min(720px,55vw);height:min(800px,80vh);z-index:1000;border:1px solid var(--color-border, #ccc);border-radius:8px;box-shadow:0 8px 32px rgba(0,0,0,0.3);display:flex;flex-direction:column;background:var(--color-bg, #fff);resize:both;overflow:hidden;\";\n\n const titleBar = document.createElement(\"div\");\n titleBar.style.cssText = \"display:flex;align-items:center;justify-content:space-between;padding:6px 10px;background:var(--color-bg-alt, #e8ecf0);color:var(--color-text, #000);border-radius:8px 8px 0 0;cursor:move;user-select:none;flex-shrink:0;font-size:13px;\";\n const titleText = document.createElement(\"span\");\n titleText.style.cssText = \"overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;font-weight:600;\";\n titleText.textContent = msg.subject || \"(no subject)\";\n titleBar.appendChild(titleText);\n\n const closeBtn = document.createElement(\"button\");\n closeBtn.textContent = \"\u2715\";\n closeBtn.title = \"Close pop-out\";\n closeBtn.style.cssText = \"background:none;border:none;font-size:16px;cursor:pointer;color:#666;padding:2px 8px;border-radius:4px;flex-shrink:0;\";\n closeBtn.addEventListener(\"mouseenter\", () => closeBtn.style.color = \"#c00\");\n closeBtn.addEventListener(\"mouseleave\", () => closeBtn.style.color = \"#666\");\n closeBtn.addEventListener(\"click\", () => wrapper.remove());\n titleBar.appendChild(closeBtn);\n\n const headerInfo = document.createElement(\"div\");\n headerInfo.style.cssText = \"padding:8px 12px;border-bottom:1px solid var(--color-border, #ddd);font-size:13px;line-height:1.4;flex-shrink:0;\";\n const formatAddrLocal = (a: { name?: string; address: string }) =>\n a.name ? `${a.name} <${a.address}>` : a.address;\n const fromStr = formatAddrLocal(msg.from || { address: \"\" });\n const toStr = (msg.to || []).map(formatAddrLocal).join(\", \");\n const ccStr = msg.cc?.length ? ` Cc: ${msg.cc.map(formatAddrLocal).join(\", \")}` : \"\";\n const dateStr = msg.date ? new Date(msg.date).toLocaleString() : \"\";\n headerInfo.innerHTML =\n `<div><strong>${escapeHtmlLocal(fromStr)}</strong></div>` +\n `<div style=\"color:var(--color-text-muted, #666)\">To: ${escapeHtmlLocal(toStr)}${escapeHtmlLocal(ccStr)}</div>` +\n `<div style=\"color:var(--color-text-muted, #666);font-size:12px\">${escapeHtmlLocal(dateStr)}</div>`;\n\n const bodyContainer = document.createElement(\"div\");\n bodyContainer.style.cssText = \"flex:1;overflow:hidden;display:flex;\";\n if (msg.bodyHtml) {\n const iframe = document.createElement(\"iframe\");\n iframe.sandbox.add(\"allow-same-origin\");\n iframe.sandbox.add(\"allow-popups\");\n iframe.sandbox.add(\"allow-popups-to-escape-sandbox\");\n iframe.sandbox.add(\"allow-top-navigation-by-user-activation\");\n iframe.sandbox.add(\"allow-scripts\");\n iframe.srcdoc = wrapHtmlBody(msg.bodyHtml, msg.remoteAllowed);\n iframe.style.cssText = \"flex:1;border:none;width:100%;background:#fff;\";\n bodyContainer.appendChild(iframe);\n // Same zoom + key-forwarding controls as the main viewer iframe \u2014\n // Ctrl+wheel / Ctrl\u00B1 / Ctrl+0 and the persisted `mailx-preview-zoom`.\n // Without this the pop-out had no zoom at all (Bob 2026-05-21).\n installPreviewControls(iframe);\n } else {\n const pre = document.createElement(\"pre\");\n pre.style.cssText = \"padding:12px;white-space:pre-wrap;word-break:break-word;margin:0;flex:1;overflow:auto;\";\n pre.textContent = msg.bodyText || \"(no content)\";\n bodyContainer.appendChild(pre);\n }\n\n // Drag \u2014 same pattern as compose-overlay: pointer-events:none on the\n // iframe so cursor crossing into it doesn't lose drag events.\n let dragX = 0, dragY = 0;\n titleBar.addEventListener(\"mousedown\", (e: MouseEvent) => {\n if (e.target === closeBtn) return;\n e.preventDefault();\n const rect = wrapper.getBoundingClientRect();\n dragX = e.clientX - rect.left;\n dragY = e.clientY - rect.top;\n const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));\n bodyContainer.style.pointerEvents = \"none\";\n document.body.style.userSelect = \"none\";\n const onMove = (ev: MouseEvent) => {\n ev.preventDefault();\n const left = clamp(ev.clientX - dragX, 0, window.innerWidth - 40);\n const top = clamp(ev.clientY - dragY, 0, window.innerHeight - 40);\n wrapper.style.left = `${left}px`;\n wrapper.style.top = `${top}px`;\n wrapper.style.right = \"auto\";\n wrapper.style.bottom = \"auto\";\n };\n const onUp = () => {\n bodyContainer.style.pointerEvents = \"\";\n document.body.style.userSelect = \"\";\n document.removeEventListener(\"mousemove\", onMove);\n document.removeEventListener(\"mouseup\", onUp);\n };\n document.addEventListener(\"mousemove\", onMove);\n document.addEventListener(\"mouseup\", onUp);\n });\n\n // Bring to front on click \u2014 shared with compose-overlay so they all\n // restack uniformly.\n wrapper.addEventListener(\"mousedown\", () => {\n document.querySelectorAll(\".compose-overlay\").forEach(el => (el as HTMLElement).style.zIndex = \"1000\");\n wrapper.style.zIndex = \"1001\";\n });\n\n // Cascade pop-outs so they don't all stack at the same coords.\n const existing = document.querySelectorAll(\".viewer-popout\").length;\n if (existing > 0) {\n wrapper.style.top = `${60 + existing * 28}px`;\n wrapper.style.right = `${20 + existing * 28}px`;\n }\n\n // Action toolbar \u2014 Reply / Reply All / Forward / Delete, acting on THIS\n // popped-out message. The pop-out can't reach app.ts's openCompose\n // directly, so each button fires `mailx-popout-action`; app.ts routes it.\n const toolbar = document.createElement(\"div\");\n toolbar.style.cssText = \"display:flex;gap:6px;padding:6px 12px;border-bottom:1px solid var(--color-border, #ddd);flex-shrink:0;\";\n const mkPopoutBtn = (label: string, title: string, onClick: () => void): HTMLButtonElement => {\n const b = document.createElement(\"button\");\n b.textContent = label;\n b.title = title;\n b.style.cssText = \"padding:4px 10px;font-size:13px;cursor:pointer;border:1px solid var(--color-border, #ccc);border-radius:4px;background:var(--color-bg, #fff);color:var(--color-text, #000);\";\n b.addEventListener(\"click\", onClick);\n return b;\n };\n const firePopoutAction = (action: string): void => {\n document.dispatchEvent(new CustomEvent(\"mailx-popout-action\", { detail: { action, msg, accountId } }));\n };\n toolbar.appendChild(mkPopoutBtn(\"Reply\", \"Reply\", () => firePopoutAction(\"reply\")));\n toolbar.appendChild(mkPopoutBtn(\"Reply All\", \"Reply to all\", () => firePopoutAction(\"replyAll\")));\n toolbar.appendChild(mkPopoutBtn(\"Forward\", \"Forward\", () => firePopoutAction(\"forward\")));\n toolbar.appendChild(mkPopoutBtn(\"Delete\", \"Delete this message\", () => {\n firePopoutAction(\"delete\");\n wrapper.remove(); // the message is gone \u2014 close its window too\n }));\n\n wrapper.appendChild(titleBar);\n wrapper.appendChild(headerInfo);\n wrapper.appendChild(toolbar);\n wrapper.appendChild(bodyContainer);\n document.body.appendChild(wrapper);\n}\n\nfunction escapeHtmlLocal(s: string): string {\n return (s || \"\").replace(/&/g, \"&\").replace(/</g, \"<\").replace(/>/g, \">\");\n}\n\n// Background body-fetch failure \u2014 when the user clicked an uncached row\n// and the reconciler's IMAP fetch failed (rate limit, network drop, auth),\n// surface a banner above the (empty) body for the *currently-shown* message\n// so the user knows the spinner isn't going to resolve. Other windows /\n// stale rows are ignored \u2014 no banner clutter for messages they walked away\n// from. The reconciler retries on its own cadence; no client-side retry.\nsubscribeStore(\"*\", (ev: any) => {\n if (ev.kind !== \"bodyFetchError\") return;\n // Always record \u2014 even if the failed message isn't the current\n // viewer target \u2014 so a later click on that row can surface the\n // reason instead of \"No content\".\n recentFetchErrors.set(`${ev.accountId}:${ev.uid}`, {\n error: String(ev.error || \"fetch failed\"),\n transient: !!ev.transient,\n when: Date.now(),\n });\n if (!currentMessage || currentAccountId !== ev.accountId) return;\n if (currentMessage.uid !== ev.uid) return;\n const bodyEl = document.getElementById(\"mv-body\");\n if (!bodyEl) return;\n if (bodyEl.querySelector(\".mv-body-fetch-error\")) return; // already shown\n const banner = document.createElement(\"div\");\n banner.className = \"mv-system-message mv-system-error mv-body-fetch-error\";\n const transientNote = ev.transient\n ? \"Will retry automatically.\"\n : \"Move/delete may have raced with the server.\";\n banner.innerHTML = `\n <div class=\"mv-system-tag\">mailx</div>\n <div class=\"mv-system-title\">Couldn't fetch message body</div>\n <div class=\"mv-system-body\">${escapeHtmlLocal(String(ev.error || \"fetch failed\"))}<br><span style=\"color:var(--color-text-muted);font-size:0.9em\">${transientNote}</span></div>`;\n const placeholder = bodyEl.querySelector(\".mv-empty\");\n if (placeholder) placeholder.replaceWith(banner);\n else bodyEl.prepend(banner);\n});\n\n// `draftSaved` arrives when the compose window's autosave or Ctrl+S lands\n// new body content on disk for the message currently displayed in the\n// preview pane. Without re-rendering, the preview shows the BEFORE-save\n// body until the user clicks the row again. Match by `previousDraftUid` \u2014\n// that's the UID currently displayed; the daemon will eventually IMAP\n// APPEND a fresh UID and delete the old one, but until that round-trip\n// lands `currentMessage.uid` still refers to the old UID.\nonEvent((ev: any) => {\n if (!ev || ev.type !== \"draftSaved\") return;\n if (!currentMessage || currentAccountId !== ev.accountId) return;\n if (currentMessage.uid !== ev.previousDraftUid) return;\n const newBody = String(ev.bodyHtml || \"\");\n currentMessage.bodyHtml = newBody;\n if (ev.subject) currentMessage.subject = ev.subject;\n const bodyEl = document.getElementById(\"mv-body\");\n const iframe = bodyEl?.querySelector(\"iframe\") as HTMLIFrameElement | null;\n if (iframe) {\n iframe.srcdoc = wrapHtmlBody(newBody, !!(currentMessage as any).remoteAllowed);\n }\n const subjEl = document.querySelector(\".mv-subject\");\n if (subjEl && ev.subject) subjEl.textContent = String(ev.subject);\n});\n", "/**\n * Folder picker \u2014 small modal for choosing a destination folder.\n * Used by the message-list right-click \"Move to folder\u2026\" item and any\n * other UI that needs the user to pick a folder.\n *\n * Reads folders from the local DB via getFolders() (local-first \u2014 no\n * server round-trip). Filters by typed text. Returns the selected\n * folder, or null if the user dismissed.\n */\n\nimport { getFolders } from \"../lib/api-client.js\";\n\nexport interface FolderPickResult {\n accountId: string;\n folderId: number;\n folderPath: string;\n folderName: string;\n}\n\n/** Show a modal folder picker. Returns a promise resolving to the picked\n * folder, or null if dismissed. The list is restricted to one account\n * (the current message's account) so it doesn't get cluttered with\n * unrelated folders; cross-account moves can be added later via an\n * account selector at the top of the picker. */\nexport function pickFolder(accountId: string, opts?: { excludeFolderIds?: number[]; title?: string }): Promise<FolderPickResult | null> {\n return new Promise(async (resolve) => {\n const overlay = document.createElement(\"div\");\n overlay.className = \"folder-picker-overlay\";\n overlay.style.cssText = \"position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:10000;display:flex;align-items:center;justify-content:center;\";\n\n const modal = document.createElement(\"div\");\n modal.className = \"folder-picker-modal\";\n modal.style.cssText = \"background:var(--bg, #fff);color:var(--fg, #000);border:1px solid var(--border, #ccc);border-radius:6px;box-shadow:0 4px 24px rgba(0,0,0,0.3);width:380px;max-width:90vw;max-height:70vh;display:flex;flex-direction:column;overflow:hidden;\";\n\n const header = document.createElement(\"div\");\n header.style.cssText = \"padding:10px 14px;border-bottom:1px solid var(--border, #ddd);font-weight:600;\";\n header.textContent = opts?.title || \"Move to folder\u2026\";\n modal.appendChild(header);\n\n const search = document.createElement(\"input\");\n search.type = \"text\";\n search.placeholder = \"Filter folders\u2026\";\n search.style.cssText = \"margin:8px 12px;padding:6px 10px;border:1px solid var(--border, #ccc);border-radius:4px;font-size:13px;\";\n modal.appendChild(search);\n\n const listEl = document.createElement(\"div\");\n listEl.style.cssText = \"flex:1;overflow-y:auto;padding:4px 0;\";\n modal.appendChild(listEl);\n\n const footer = document.createElement(\"div\");\n footer.style.cssText = \"padding:8px 12px;border-top:1px solid var(--border, #ddd);display:flex;justify-content:flex-end;gap:8px;\";\n const cancelBtn = document.createElement(\"button\");\n cancelBtn.textContent = \"Cancel\";\n cancelBtn.style.cssText = \"padding:6px 14px;cursor:pointer;\";\n footer.appendChild(cancelBtn);\n modal.appendChild(footer);\n\n overlay.appendChild(modal);\n document.body.appendChild(overlay);\n\n const dismiss = (result: FolderPickResult | null) => {\n overlay.remove();\n document.removeEventListener(\"keydown\", onKey);\n resolve(result);\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") { e.preventDefault(); dismiss(null); }\n if (e.key === \"Enter\") {\n const first = listEl.querySelector(\".folder-picker-row.match\") as HTMLElement | null;\n if (first) first.click();\n }\n };\n document.addEventListener(\"keydown\", onKey);\n overlay.addEventListener(\"click\", (e) => { if (e.target === overlay) dismiss(null); });\n cancelBtn.addEventListener(\"click\", () => dismiss(null));\n\n // Local-first: load from DB synchronously-ish (one IPC round-trip).\n let folders: any[] = [];\n try {\n folders = (await getFolders(accountId)) || [];\n } catch (e) {\n listEl.textContent = \"Failed to load folders\";\n return;\n }\n\n // Hide special-use that don't make sense as targets (Outbox).\n // Allow Trash / Junk so users can manually file into them.\n const excluded = new Set(opts?.excludeFolderIds || []);\n const targets = folders\n .filter((f: any) => !excluded.has(f.id))\n .filter((f: any) => f.specialUse !== \"outbox\")\n .sort((a: any, b: any) => a.path.localeCompare(b.path));\n\n function render(filter: string): void {\n listEl.innerHTML = \"\";\n const lc = filter.toLowerCase().trim();\n let firstMatchSet = false;\n for (const f of targets) {\n const row = document.createElement(\"div\");\n row.className = \"folder-picker-row\";\n row.style.cssText = \"padding:6px 14px;cursor:pointer;font-size:13px;display:flex;justify-content:space-between;gap:8px;\";\n const name = document.createElement(\"span\");\n name.textContent = f.path;\n const tag = document.createElement(\"span\");\n tag.style.cssText = \"color:var(--muted, #888);font-size:11px;\";\n tag.textContent = f.specialUse || \"\";\n row.appendChild(name);\n row.appendChild(tag);\n const matches = !lc || f.path.toLowerCase().includes(lc);\n if (matches) {\n row.classList.add(\"match\");\n if (!firstMatchSet) { row.style.background = \"var(--hover, #eee)\"; firstMatchSet = true; }\n }\n row.addEventListener(\"mouseenter\", () => row.style.background = \"var(--hover, #eee)\");\n row.addEventListener(\"mouseleave\", () => row.style.background = \"\");\n row.addEventListener(\"click\", () => {\n dismiss({ accountId, folderId: f.id, folderPath: f.path, folderName: f.path.split(/[./]/).pop() || f.path });\n });\n if (!matches) row.style.display = \"none\";\n listEl.appendChild(row);\n }\n }\n render(\"\");\n search.addEventListener(\"input\", () => render(search.value));\n setTimeout(() => search.focus(), 0);\n });\n}\n", "/**\n * Message list component \u2014 renders paginated message rows.\n * Reads from message-state; operations mutate state, list reacts.\n */\n\nimport { getMessages as apiGetMessages, getUnifiedInbox as apiGetUnifiedInbox, searchMessages, abortMessageListRequests, updateFlags, getThreadMessages, moveMessages as apiMoveMessages, getPriorityLists, onEvent } from \"../lib/api-client.js\";\nimport * as state from \"../lib/message-state.js\";\nimport type { ListMessage } from \"../lib/message-state.js\";\nimport { showMessage as viewerShow, clearViewer as viewerClear } from \"./message-viewer.js\";\nimport { showContextMenu, type MenuItem } from \"./context-menu.js\";\nimport { pickFolder } from \"./folder-picker.js\";\nimport { seenOf, flaggedOf, draftOf, setSeen, setFlagged } from \"@bobfrankston/mailx-types\";\n\ntype MessageSelectHandler = (accountId: string, uid: number, folderId: number) => void;\n\nlet onMessageSelect: MessageSelectHandler;\nlet currentAccountId: string;\nlet currentFolderId: number;\nlet currentSpecialUse = \"\"; // Cached for reloads \u2014 an empty value on reload used to reset Sent/Drafts/Outbox to \"From\"\nlet lastClickedRow: HTMLElement | null = null;\nlet currentPage: number;\nlet totalMessages: number;\nlet loading = false;\nlet unifiedMode = false;\nlet searchMode = false;\n\nlet currentSearchQuery = \"\";\n// Remember the pre-search view mode so clearSearchMode() can restore it.\n// Without this, searching from \"All Inboxes\" loses the unified-mode flag\n// (loadSearchResults sets unifiedMode=false), and clearing the search\n// drops into reloadCurrentFolder() which finds no folder and no unified\n// mode and renders nothing \u2014 the empty-list / spurious-timeout symptom\n// the user saw 2026-05-05.\nlet wasUnifiedBeforeSearch = false;\nlet showToInsteadOfFrom = false;\nlet touchWasScroll = false;\n// Current sort column/direction \u2014 cycled by clicking the ml-header columns.\n// \"date desc\" is the default (newest first). Clicking a column flips direction\n// if it's already active, or switches to that column with its own default dir\n// (text columns default asc, date defaults desc).\nlet currentSort = \"date\";\nlet currentSortDir: \"asc\" | \"desc\" = \"desc\";\n\n/** Generation counter \u2014 incremented on every load* call (loadMessages,\n * loadUnifiedInbox, loadSearchResults). Each load captures the current\n * value at the top, then checks before rendering \u2014 if the captured gen\n * no longer matches `loadGen`, the user has switched to a different\n * view in the meantime and this stale response should be silently\n * dropped instead of overwriting the new view. Bob 2026-05-08:\n * rapid-fire folder clicks were producing \"list shows folder X but\n * preview shows folder Y\" because folder X's getMessages eventually\n * resolved AFTER folder Y's render and clobbered it. */\nlet loadGen = 0;\n\n/** In-memory message cache for instant folder switches. Keyed by view \u2014\n * `unified`, `account:folder:flagged?`, `search:query`. The first click\n * on a folder paints from this cache (~1 ms) BEFORE the IPC round-trip\n * even fires; the IPC runs in background and only re-renders if the\n * result diverges from the cached version. Bob 2026-05-09: clicking\n * \"All Inboxes\" felt slow even though IPC returned in 18 ms because\n * the round-trip + DOM build was the user-visible pause. With the\n * cache, second-and-later clicks are instant. */\ninterface CachedListResult {\n items: any[];\n total: number;\n timestamp: number;\n}\nconst listCache = new Map<string, CachedListResult>();\nconst CACHE_KEY_UNIFIED = \"unified\";\nfunction cacheKey(mode: \"folder\" | \"search\", a?: string, f?: number, flagged?: boolean, q?: string): string {\n if (mode === \"folder\") return `folder:${a}:${f}:${flagged ? \"flag\" : \"\"}`;\n return `search:${q}`;\n}\n\n/** Per-view position memory \u2014 remembered selected UID + scroll position\n * per folder / unified inbox / saved search. Keyed by the same string\n * `cacheKey()` uses so each view has its own slot.\n *\n * Restore rule (Bob 2026-05-12): on return to a view, focus the saved\n * uid if it still exists; otherwise focus the first row whose uid is\n * less than the saved uid (next-older entry in a date-desc list \u2014 uid\n * is roughly monotonic with arrival time on most IMAP servers and on\n * Gmail's hash-derived ids). If no smaller uid exists either, fall\n * back to whatever row sits at the same numeric index. Survives a\n * session reload via sessionStorage. */\ninterface ViewPosition { uid: number; uuid?: string; scroll: number; }\nconst positionMemory = new Map<string, ViewPosition>();\nconst POSITION_STORAGE_KEY = \"mailx-list-positions\";\ntry {\n const raw = sessionStorage.getItem(POSITION_STORAGE_KEY);\n if (raw) {\n const parsed = JSON.parse(raw) as Record<string, ViewPosition>;\n for (const [k, v] of Object.entries(parsed || {})) {\n if (typeof v?.uid === \"number\") positionMemory.set(k, v);\n }\n }\n} catch { /* */ }\nfunction persistPositions(): void {\n try {\n const obj: Record<string, ViewPosition> = {};\n for (const [k, v] of positionMemory) obj[k] = v;\n sessionStorage.setItem(POSITION_STORAGE_KEY, JSON.stringify(obj));\n } catch { /* */ }\n}\nfunction currentViewKey(): string | null {\n if (searchMode) return cacheKey(\"search\", undefined, undefined, undefined, currentSearchQuery);\n if (unifiedMode) return CACHE_KEY_UNIFIED;\n if (!currentAccountId || currentFolderId == null) return null;\n const flaggedOnly = document.getElementById(\"ml-body\")?.classList.contains(\"flagged-only\") || false;\n return cacheKey(\"folder\", currentAccountId, currentFolderId, flaggedOnly);\n}\nfunction rememberPosition(): void {\n const key = currentViewKey();\n if (!key) return;\n const body = document.getElementById(\"ml-body\");\n if (!body) return;\n const sel = body.querySelector(\".ml-row.selected\") as HTMLElement | null;\n if (!sel) return;\n const uid = Number(sel.dataset.uid);\n if (!Number.isFinite(uid)) return;\n // Remember the stable `uuid` as the primary identity \u2014 bare `uid` is only\n // unique within (account, folder), so restoring by it can rebind the\n // viewer to a different message that happens to share the uid number.\n // `uid` is kept only for the next-older-neighbor fallback when the\n // remembered message has since been deleted.\n positionMemory.set(key, { uid, uuid: sel.dataset.uuid || \"\", scroll: body.scrollTop });\n persistPositions();\n}\n/** Choose the row to focus when re-entering a view with saved position.\n * Returns the uid to focus, or null to fall back to selectFirst. */\nfunction pickRestoreUid(items: any[], saved: ViewPosition): string | null {\n if (!items.length) return null;\n // Exact restore by stable uuid \u2014 globally unique, so this can never\n // rebind the viewer to a same-uid-number message in another folder\n // (the bug: viewer silently jumped to a different letter mid-cleanup).\n if (saved.uuid) {\n const exact = items.find(m => m.uuid && m.uuid === saved.uuid);\n if (exact?.uuid) return exact.uuid;\n }\n // Saved message is gone (deleted). Pick the next-older entry by uid \u2014\n // uid is roughly monotonic with arrival on IMAP/Gmail \u2014 and return ITS\n // uuid so the restore stays uuid-keyed end to end.\n let best: any = null;\n for (const m of items) {\n if (typeof m.uid !== \"number\" || !m.uuid) continue;\n if (m.uid < saved.uid && (!best || m.uid > best.uid)) best = m;\n }\n if (best) return best.uuid;\n // No older entry \u2014 saved was the bottom. Snap to the top of the list.\n return items[0]?.uuid || null;\n}\n\n/** Quick equality check \u2014 same UID set, same flag pattern, same total.\n * Skip re-render when this returns true to avoid DOM churn / scroll-\n * jump on a refresh that didn't change anything. */\nfunction listResultsEqual(a: any[] | undefined, b: any[]): boolean {\n if (!a || a.length !== b.length) return false;\n for (let i = 0; i < a.length; i++) {\n if (a[i].uid !== b[i].uid) return false;\n if (a[i].accountId !== b[i].accountId) return false;\n // Flags-changed \u2192 must re-render (read/unread, flagged).\n if ((a[i].flags || []).join(\",\") !== (b[i].flags || []).join(\",\")) return false;\n }\n return true;\n}\n\n/** Single source of truth for \"which row is focused\" in the list.\n *\n * Each rendered row is a `MessageRow` instance owning its DOM element,\n * its message envelope, and its event handlers. `focusRow(row)` runs\n * the atomic transition: unfocus the previous row, mark this one\n * `.selected`, drive the viewer with the row's envelope, dispatch\n * `mailx-focus-changed`. There is no \"select state\" anywhere else; the\n * viewer has no subscriptions. If `focusRow` isn't called, the preview\n * pane doesn't update \u2014 drift between the highlighted row and the\n * preview is structurally impossible.\n *\n * When the focused row's data leaves the list (delete, move, search\n * reload, folder switch), the controller hands focus to a survivor\n * via `focusByIdentity` or, if no survivor exists, calls\n * `releaseFocus()` which clears highlight + viewer in the same call. */\nlet focusedRow: MessageRow | null = null;\nconst rowByKey = new Map<string, MessageRow>();\n\n/** Priority sender / domain index, populated on app load and refreshed when\n * the user marks/unmarks a sender. Module-scoped so MessageRow can hit it\n * during construction without a service round-trip per row. */\nlet prioritySenders: Set<string> = new Set();\nlet priorityDomains: Set<string> = new Set();\nfunction isPriorityAddr(addr: string): boolean {\n const lower = (addr || \"\").trim().toLowerCase();\n if (!lower) return false;\n if (prioritySenders.has(lower)) return true;\n const at = lower.indexOf(\"@\");\n const domain = at >= 0 ? lower.slice(at + 1) : \"\";\n return !!domain && priorityDomains.has(domain);\n}\n/** Refresh the priority index from the service. Call on app load, after\n * any user action that changes priority (mark/unmark), or whenever\n * contacts.jsonc reloads. After refresh, every visible row's `.priority`\n * class is recomputed without a full re-render. */\nexport async function refreshPriorityIndex(): Promise<void> {\n try {\n const r = await getPriorityLists();\n prioritySenders = new Set((r?.senders || []).map(s => (s || \"\").toLowerCase()));\n priorityDomains = new Set((r?.domains || []).map(d => (d || \"\").toLowerCase()));\n // Re-tag any currently rendered rows.\n for (const row of rowByKey.values()) {\n row.el.classList.toggle(\"priority\", isPriorityAddr(row.msg?.from?.address || \"\"));\n }\n } catch { /* service may not be ready yet */ }\n}\n\nfunction rowKey(accountId: string, uid: number | string): string {\n return `${accountId}:${uid}`;\n}\n\nfunction focusRow(row: MessageRow, opts: { scroll?: boolean } = {}): void {\n // Single-select invariant: this row is the ONLY highlight, and it is the\n // row the viewer shows. Sweep any stray .selected so the list can never\n // present a highlighted row that doesn't match the viewer (Bob\n // 2026-05-18: \"the message display and the highlight are supposed to be\n // tied together\"). Multi-select keeps its own highlights \u2014 focusRow is\n // also called there for the just-toggled row, so skip the sweep then.\n const body = row.el.parentElement as HTMLElement | null;\n if (!body?.classList.contains(\"multi-select-on\")) {\n body?.querySelectorAll(\".ml-row.selected\").forEach(r => {\n if (r !== row.el) r.classList.remove(\"selected\");\n });\n }\n if (focusedRow && focusedRow !== row) focusedRow.setSelected(false);\n row.setSelected(true);\n focusedRow = row;\n // Bring the focused row into view ONLY for user-initiated focus changes\n // (click, keyboard nav, post-delete auto-advance). Programmatic restores\n // (restoreSelection after sync rerender) explicitly opt out via\n // {scroll:false}: otherwise the user's scroll-away-from-focused-row gets\n // yanked back every time folderCountsChanged triggers a list reload.\n // Bob 2026-05-14: \"it's not letting me scroll down the list\" \u2014 symptom\n // of repeated scrollIntoView during sync churn.\n if (opts.scroll !== false) {\n row.el.scrollIntoView({ block: \"nearest\" });\n }\n // Drive the viewer with the row's own envelope. Pass currentSpecialUse\n // so the viewer can recognize Drafts/Outbox rows and auto-open them in\n // compose instead of the read-only preview. Without this the draft was\n // showing in the viewer pane as if it were any received message \u2014\n // wrong font (browser default Times), no edit-mode handoff (Bob\n // 2026-05-12: \"when I open the draft it is not in edit mode as it\n // should be\").\n viewerShow(row.accountId, row.msg.uid, row.msg.folderId, currentSpecialUse || undefined, false, row.msg);\n onMessageSelect(row.accountId, row.msg.uid, row.msg.folderId);\n document.dispatchEvent(new CustomEvent(\"mailx-focus-changed\", { detail: row.msg }));\n // Persist position so returning to this view re-focuses this row.\n rememberPosition();\n}\n\n/** Read the currently-focused message envelope. Used by app-level\n * features (flag toggle, mark unread, status bar) that need to know\n * what's open in the viewer. */\nexport function getCurrentFocused(): ListMessage | null {\n return focusedRow ? focusedRow.msg : null;\n}\n\n/** Programmatic focus by identity. Used for thread-popup clicks,\n * keyboard nav, post-delete handoff. Returns true if a row was found\n * and focused. */\nfunction focusByIdentity(accountId: string, uid: number, opts: { scroll?: boolean } = {}): boolean {\n const row = rowByKey.get(rowKey(accountId, uid));\n if (!row) return false;\n focusRow(row, opts);\n return true;\n}\n\n/** Update the flagged class + star on a specific row by identity. Used by\n * the toolbar Flag button so the visible star tracks the data instead of\n * drifting until the next list rebuild. Calls into the row object's own\n * setFlaggedClass \u2014 atomic class+text update, can't desync. */\nexport function setRowFlagged(accountId: string, uid: number, yes: boolean): void {\n const row = rowByKey.get(rowKey(accountId, uid));\n if (row) row.setFlaggedClass(yes);\n}\n\n/** Update the unread class on a specific row by identity. Used by\n * message-viewer's auto-mark-as-read so the visual state flips off\n * ONLY after the dwell timer fires + the IMAP STORE \\Seen succeeds \u2014\n * not on raw click. Previous click-time `setUnreadClass(false)` meant\n * a right-click during the 2-s dwell window read the row as \"read\"\n * and offered \"Mark unread\" on a message that wasn't actually marked\n * yet. */\nexport function setRowSeen(accountId: string, uid: number, yes: boolean): void {\n const row = rowByKey.get(rowKey(accountId, uid));\n if (row) row.setUnreadClass(!yes);\n}\n\n/** Scroll the focused row into view. Wired to a keyboard shortcut so the\n * user can recover after scrolling the list away from the preview. */\nexport function scrollFocusedIntoView(): void {\n if (focusedRow) focusedRow.el.scrollIntoView({ block: \"center\" });\n}\n\n/** Release the focus slot and clear the preview pane in one call. */\nexport function releaseFocus(): void {\n focusedRow = null;\n // Strip EVERY .selected row, not just the focused one. A stray highlight\n // (a multi-select remnant, or a row that kept the class across an\n // incremental rerender) was left behind as a blue row with an empty\n // viewer. The highlight and the preview are one thing: no focus \u27F9 no\n // highlight anywhere.\n const body = document.getElementById(\"ml-body\");\n body?.querySelectorAll(\".ml-row.selected\").forEach(r => r.classList.remove(\"selected\"));\n viewerClear();\n document.dispatchEvent(new CustomEvent(\"mailx-focus-changed\", { detail: null }));\n}\n\n/** Flip the \"not-downloaded\" indicator off for rows whose bodies just cached.\n * Called from the bodyCached service event \u2014 covers both background prefetch\n * and on-demand fetch. No-op for rows not currently rendered. */\nexport function markBodiesCached(items: { accountId: string; uid: number }[]): void {\n const body = document.getElementById(\"ml-body\");\n if (!body || items.length === 0) return;\n for (const { accountId, uid } of items) {\n const row = body.querySelector(`.ml-row[data-uid=\"${uid}\"][data-account-id=\"${CSS.escape(accountId)}\"]`)\n || body.querySelector(`.ml-row[data-uid=\"${uid}\"]`);\n if (row) {\n row.classList.remove(\"not-downloaded\");\n }\n }\n}\n\n/** Get all selected message rows */\nexport function getSelectedMessages(): { accountId: string; uid: number; folderId: number }[] {\n const body = document.getElementById(\"ml-body\");\n if (!body) return [];\n const rows = body.querySelectorAll(\".ml-row.selected\");\n return Array.from(rows).map(r => ({\n accountId: (r as HTMLElement).dataset.accountId || \"\",\n uid: Number((r as HTMLElement).dataset.uid),\n folderId: Number((r as HTMLElement).dataset.folderId),\n }));\n}\n\nfunction clearSelection(): void {\n const body = document.getElementById(\"ml-body\");\n if (body) body.querySelectorAll(\".ml-row.selected\").forEach(r => r.classList.remove(\"selected\"));\n // The focused-row invariant is \"the Row whose .selected is currently\n // mine\". clearSelection wipes all .selected, so the invariant breaks\n // unless we drop the focused-row reference too.\n if (focusedRow) {\n focusedRow = null;\n viewerClear();\n document.dispatchEvent(new CustomEvent(\"mailx-focus-changed\", { detail: null }));\n }\n}\n\n/** Deterministic sender-avatar color from a seed string (typically the\n * email address). Hash \u2192 hue at 12 evenly-spaced positions on the wheel.\n * Saturation + lightness fixed so all colors carry the same visual weight\n * regardless of hue, and so light/dark themes don't have to override. */\nfunction senderColor(seed: string): string {\n let h = 0;\n for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) | 0;\n const hue = ((Math.abs(h) % 12) * 30) + 15; // 15, 45, 75, \u2026, 345\n return `oklch(0.62 0.14 ${hue})`;\n}\n\n/** Render the attachment / replied / forwarded icon strip into a container.\n * Mirrors classic Outlook's date-column status glyphs: paperclip for\n * attachments, leftwards-hook arrow for replied (In-Reply-To linkage in\n * the local DB; \\Answered flag as fallback), rightwards-hook arrow for\n * forwarded ($Forwarded keyword). Empty strip when none apply \u2014 no leading\n * space, so the date sits flush right as before. */\nfunction renderStatusIcons(host: HTMLElement, msg: any): void {\n host.textContent = \"\";\n if (msg.hasAttachments) {\n const a = document.createElement(\"span\");\n a.className = \"ml-icon ml-icon-attach\";\n a.textContent = \"\uD83D\uDCCE\";\n a.title = \"Has attachments\";\n host.appendChild(a);\n }\n const flags: string[] = msg.flags || [];\n // Primary signal: local DB knows the user (or anyone in this account)\n // replied \u2014 derived from In-Reply-To header linkage, instant on send,\n // works cross-account and across servers that drop \\Answered. Plan B:\n // the server-side \\Answered flag (e.g. another client replied from a\n // different machine before mailx synced the reply back).\n if (msg.isReplied || flags.includes(\"\\\\Answered\")) {\n const r = document.createElement(\"span\");\n r.className = \"ml-icon ml-icon-replied\";\n r.textContent = \"\u21A9\";\n r.title = \"Replied\";\n host.appendChild(r);\n }\n if (flags.includes(\"$Forwarded\")) {\n const f = document.createElement(\"span\");\n f.className = \"ml-icon ml-icon-forwarded\";\n f.textContent = \"\u21AA\";\n f.title = \"Forwarded\";\n host.appendChild(f);\n }\n}\n\n/** Exit multi-select mode (entered via touch long-press). Clears selection\n * and the sticky body flag so subsequent taps open messages again. */\nfunction exitMultiSelect(): void {\n const body = document.getElementById(\"ml-body\");\n if (!body?.classList.contains(\"multi-select-on\")) return;\n body.classList.remove(\"multi-select-on\");\n clearSelection();\n updateBulkBar();\n}\n\n/** Bulk-actions bar retired 2026-04-24 \u2014 trash + spam live on the main\n * toolbar now and every other bulk op (mark-read, flag, move) is one\n * right-click away. Kept as a no-op stub so existing call sites\n * (avatar tap, row click, long-press) don't need to be touched. */\nfunction updateBulkBar(): void { /* bar removed; nothing to render */ }\n\n// Escape key + click-outside-list exit multi-select mode. Attached once\n// (idempotent because document only has one listener scope per handler).\nif (!(window as any).__mailxMultiSelectWired) {\n (window as any).__mailxMultiSelectWired = true;\n document.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Escape\") exitMultiSelect();\n });\n document.addEventListener(\"pointerdown\", (e) => {\n const body = document.getElementById(\"ml-body\");\n if (!body?.classList.contains(\"multi-select-on\")) return;\n const target = e.target as HTMLElement;\n // A tap on a row is handled by the row's own click listener.\n // The toolbar must also be exempt: its trash / spam / etc.\n // buttons operate ON the current multi-selection, so a tap on\n // them should NOT clear selection before the button's click\n // handler runs (otherwise getSelectedMessages returns empty\n // and the action no-ops \u2014 Android-reported 2026-04-30: \"press\n // multiple circles, press trashcan, checks vanish, nothing\n // deleted\"). Same logic for the folder-tree (drop targets,\n // future: bulk move). Exit only on a tap to genuine neutral\n // ground.\n // Exempt: rows (handled by their own listener), toolbar buttons\n // (delete/spam/etc. operate ON the selection \u2014 clearing it here\n // empties the selection before the click runs), folder-tree\n // (drop targets / future bulk move), and the context menu\n // (right-click \u2192 \"mark read\" / \"move to\" / etc. all need the\n // selection intact when the menu item runs).\n if (target.closest(\".ml-row, .toolbar, .folder-tree, .ctx-menu, #btn-tb-delete, #btn-tb-spam\")) return;\n exitMultiSelect();\n }, true);\n}\n\nfunction selectRange(from: HTMLElement, to: HTMLElement): void {\n const body = document.getElementById(\"ml-body\");\n if (!body) return;\n const rows = Array.from(body.querySelectorAll(\".ml-row\"));\n const fromIdx = rows.indexOf(from);\n const toIdx = rows.indexOf(to);\n if (fromIdx < 0 || toIdx < 0) return;\n const lo = Math.min(fromIdx, toIdx);\n const hi = Math.max(fromIdx, toIdx);\n // A shift-click range IS a multi-selection \u2014 flag the body so focusRow()\n // (called right after, to drive the viewer off the clicked row) skips\n // its single-select sweep. Without this the sweep wiped every row in the\n // range except the one clicked (Bob 2026-05-19: \"shift click should\n // extend a selection but it does not\" \u2014 regression from the v1.1.88\n // highlight-desync fix).\n if (hi > lo) body.classList.add(\"multi-select-on\");\n for (let i = lo; i <= hi; i++) rows[i].classList.add(\"selected\");\n}\n\n/** The row to anchor a shift-click range against. `lastClickedRow` is the\n * primary anchor, but it can become a detached DOM node after a list\n * re-render (folder switch, sort, search reload, paging) \u2014 `selectRange`\n * would then no-op. Fall back to whichever live row is `.selected` (the\n * one in the viewer) before giving up. */\nfunction resolveShiftAnchor(): HTMLElement | null {\n if (lastClickedRow?.isConnected) return lastClickedRow;\n const body = document.getElementById(\"ml-body\");\n if (!body) return null;\n return body.querySelector(\".ml-row.selected\") as HTMLElement | null;\n}\n\nconst timeFmt: Intl.DateTimeFormatOptions = { hour: \"2-digit\", minute: \"2-digit\", hour12: false };\nconst dateFmt: Intl.DateTimeFormatOptions = { year: \"numeric\", month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\", hour12: false };\nconst dateFmtSameYear: Intl.DateTimeFormatOptions = { month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\", hour12: false };\n\nexport function initMessageList(handler: MessageSelectHandler): void {\n onMessageSelect = handler;\n\n // Infinite scroll\n const body = document.getElementById(\"ml-body\");\n if (body) {\n // Touch scroll vs tap: the WebView occasionally synthesizes a click on\n // touchend even when the user clearly scrolled, which opened a message\n // just from swiping the list. Multi-signal detection so a scroll is\n // reliably classified:\n // 1. touchmove movement \u2265 TAP_SLOP \u2014 the primary signal\n // 2. actual scrollTop change between touchstart and touchend \u2014 always\n // set the flag when the container moved, even if touchmove never\n // fired (some Android builds coalesce events under momentum)\n // 3. longer TAP_SLOP (15px) \u2014 fingers are wide; 10px was too twitchy\n let touchStartY = 0;\n let touchStartX = 0;\n let touchStartScrollTop = 0;\n const TAP_SLOP = 15;\n body.addEventListener(\"touchstart\", (e) => {\n const t = e.touches[0];\n touchStartY = t.clientY;\n touchStartX = t.clientX;\n touchStartScrollTop = body.scrollTop;\n touchWasScroll = false;\n }, { passive: true });\n body.addEventListener(\"touchmove\", (e) => {\n const t = e.touches[0];\n if (Math.abs(t.clientY - touchStartY) > TAP_SLOP || Math.abs(t.clientX - touchStartX) > TAP_SLOP) {\n touchWasScroll = true;\n }\n }, { passive: true });\n body.addEventListener(\"touchend\", () => {\n // If the container actually scrolled during this touch, the user\n // was scrolling regardless of how small their finger movement was.\n if (body.scrollTop !== touchStartScrollTop) {\n touchWasScroll = true;\n }\n }, { passive: true });\n\n body.addEventListener(\"scroll\", () => {\n if (loading) return;\n const nearBottom = body.scrollHeight - body.scrollTop - body.clientHeight < 200;\n if (nearBottom) {\n if (currentPage * 50 < totalMessages) {\n loadMoreMessages();\n } else {\n // Diagnostic: at bottom but not loading more. Log why\n // so the user can see whether totalMessages is wrong,\n // a filter is on, or we've truly reached the end.\n // Only log once per \"stuck at bottom\" episode.\n if (!(body as any)._mlScrollEndLogged) {\n (body as any)._mlScrollEndLogged = true;\n const rows = body.querySelectorAll(\".ml-row\").length;\n console.log(` [ml-scroll] reached bottom \u2014 currentPage=${currentPage} pageSize=50 loadedRows=${rows} totalMessages=${totalMessages} searchMode=${searchMode} unifiedMode=${unifiedMode} flaggedOnly=${body.classList.contains(\"flagged-only\")}`);\n }\n }\n } else {\n // Reset the once-flag so next time we hit bottom we log again.\n (body as any)._mlScrollEndLogged = false;\n }\n });\n }\n\n // Viewer signals \"this row is gone server-side\" via mailx-remove-stale.\n // The list owns row lifecycle, so it runs the removal here \u2014 filtering\n // state, removing the DOM row, and handing focus to a survivor (or\n // clearing the pane) in one transaction.\n document.addEventListener(\"mailx-remove-stale\", (e: any) => {\n const { accountId, uid } = e.detail || {};\n if (typeof uid === \"number\" && typeof accountId === \"string\") {\n removeMessagesAndReconcile([{ accountId, uid }]);\n }\n });\n\n // Sort column headers \u2014 click to cycle. Date defaults desc (newest first);\n // From/Subject default asc on first click so alphabetical order reads\n // naturally. Clicking the currently-active column flips direction.\n const header = document.getElementById(\"ml-header\");\n if (header) {\n header.addEventListener(\"click\", (e) => {\n const col = (e.target as HTMLElement).closest<HTMLElement>(\".ml-col-sortable\");\n if (!col) return;\n const key = col.dataset.sort;\n if (!key) return;\n if (currentSort === key) {\n currentSortDir = currentSortDir === \"asc\" ? \"desc\" : \"asc\";\n } else {\n currentSort = key;\n currentSortDir = key === \"date\" ? \"desc\" : \"asc\";\n }\n // Only per-folder lists support server-side sort today; unified and\n // search paths sort client-side on the fetched page. Reload if we\n // have an active per-folder context.\n if (!searchMode && !unifiedMode && currentAccountId && currentFolderId) {\n loadMessages(currentAccountId, currentFolderId, 1, \"\", false);\n } else {\n applyClientSideSort();\n updateSortIndicators();\n }\n });\n }\n updateSortIndicators();\n}\n\n/** Reorder currently-loaded state messages in-place by currentSort/currentSortDir.\n * Used for unified-inbox and search results where the server can't re-sort\n * a single page on our behalf. */\nfunction applyClientSideSort(): void {\n const items = [...state.getMessages()];\n const sign = currentSortDir === \"asc\" ? 1 : -1;\n items.sort((a: any, b: any) => {\n if (currentSort === \"from\") {\n const av = (a.from?.name || a.from?.address || \"\").toLowerCase();\n const bv = (b.from?.name || b.from?.address || \"\").toLowerCase();\n return av < bv ? -sign : av > bv ? sign : 0;\n }\n if (currentSort === \"subject\") {\n const av = (a.subject || \"\").replace(/^(re:|fwd:|fw:)\\s*/i, \"\").toLowerCase();\n const bv = (b.subject || \"\").replace(/^(re:|fwd:|fw:)\\s*/i, \"\").toLowerCase();\n return av < bv ? -sign : av > bv ? sign : 0;\n }\n // date\n return ((a.date || 0) - (b.date || 0)) * sign;\n });\n state.setMessages(items as any);\n}\n\nfunction updateSortIndicators(): void {\n const header = document.getElementById(\"ml-header\");\n if (!header) return;\n header.querySelectorAll<HTMLElement>(\".ml-col-sortable\").forEach(c => {\n c.classList.remove(\"ml-col-sort-asc\", \"ml-col-sort-desc\");\n if (c.dataset.sort === currentSort) {\n c.classList.add(currentSortDir === \"asc\" ? \"ml-col-sort-asc\" : \"ml-col-sort-desc\");\n }\n });\n}\n\n/**\n * Remove the named messages from the list and reconcile DOM + focus.\n *\n * Single-transaction transition: filters the underlying state, deletes\n * the DOM rows, and either re-focuses a surviving row or releases focus\n * (clearing the viewer). Replaces the old subscribe-and-sync model where\n * state.removeMessages broadcast a \"removed\" event to two independent\n * subscribers \u2014 that path could leave the highlight and preview out of\n * sync if the list and viewer noticed the change in different orders.\n *\n * Call this whenever local rows need to disappear (delete, move,\n * server-side stale removal, undo). Pass identities; the function\n * decides what to focus next.\n */\nexport function removeMessagesAndReconcile(\n uids: { accountId: string; uid: number }[],\n): void {\n const focusedIdent = focusedRow\n ? { accountId: focusedRow.accountId, uid: focusedRow.msg.uid }\n : null;\n const outcome = state.removeMessages(uids, focusedIdent);\n\n // Invalidate the in-memory list cache so a window-switch or folder\n // re-visit doesn't re-paint the deleted rows from a stale snapshot.\n // Bob 2026-05-09: \"I deleted entries, switched windows, came back \u2014\n // the deleted entries reappeared.\" Cache is per-folder; without\n // knowing which folders were affected we drop the lot. Cheap (just\n // a Map clear) and the next IPC repopulates each on demand.\n listCache.clear();\n\n const body = document.getElementById(\"ml-body\");\n if (body) {\n const stateUids = new Set(state.getMessages().map(m => `${m.accountId}:${m.uid}`));\n for (const row of Array.from(body.querySelectorAll(\".ml-row\"))) {\n const el = row as HTMLElement;\n const key = `${el.dataset.accountId}:${el.dataset.uid}`;\n if (!stateUids.has(key)) {\n // Use the row object's detach() not el.remove() so rowByKey\n // stays clean. Stale entries there broke the auto-advance\n // path: focusByIdentity(survivor) could match a deleted row\n // whose .el was already gone from the DOM, then focusRow\n // would call setSelected on a detached node \u2014 visually\n // nothing happened, viewer cleared. Single ownership =\n // rowByKey is in sync with the DOM.\n const dead = rowByKey.get(key);\n if (dead) dead.detach();\n else el.remove();\n }\n }\n if (state.getMessages().length === 0) {\n body.innerHTML = `<div class=\"ml-empty\">No messages</div>`;\n }\n }\n\n if (outcome.focusedWasRemoved) {\n const survivor = outcome.nextSurvivor;\n if (survivor && focusByIdentity(survivor.accountId, survivor.uid)) {\n // focusByIdentity handled the transition (DOM class + viewer).\n return;\n }\n // Survivor identity didn't resolve to a rendered row. Most common\n // case: state computed a survivor that's still loading or got\n // filtered. Fall back to the first row currently in state \u2014 there\n // IS a list, so showing \"Select a message\" while rows are visible\n // is the architectural fuckup (Bob 2026-05-14). Walk state in\n // order; the first row that has a live DOM/rowByKey entry wins.\n const remaining = state.getMessages();\n for (const m of remaining) {\n if (focusByIdentity(m.accountId, m.uid)) return;\n }\n // Truly empty folder (state.length === 0). Now releasing focus is\n // honest \u2014 there's no row to show.\n releaseFocus();\n }\n}\n\n/** Reload the currently displayed folder (preserves current selection).\n * No-op when in search mode \u2014 search is a query snapshot, not a live\n * feed. Sync / IDLE arrivals re-running the search would yank focus\n * away from whatever the user is reading and reshuffle the result\n * list every few seconds. The user can re-trigger search explicitly\n * by pressing Enter or modifying the query. */\nexport function reloadCurrentFolder(): void {\n if (searchMode) return;\n if (unifiedMode) {\n loadUnifiedInbox(false);\n } else if (currentAccountId && currentFolderId) {\n loadMessages(currentAccountId, currentFolderId, 1, \"\", false);\n }\n}\n\n/** Exit search mode without triggering a reload \u2014 caller decides what to load\n * next. Restores the pre-search view mode (unified vs folder) so the\n * subsequent reloadCurrentFolder() call lands on the right branch.\n * Without the unifiedMode restore, clearing a search that was started\n * from \"All Inboxes\" left the list permanently empty. */\nexport function clearSearchMode(): void {\n searchMode = false;\n currentSearchQuery = \"\";\n if (wasUnifiedBeforeSearch) unifiedMode = true;\n wasUnifiedBeforeSearch = false;\n}\n\n/** Load unified inbox (all accounts) */\nexport async function loadUnifiedInbox(autoSelect = true): Promise<void> {\n const myGen = ++loadGen;\n unifiedMode = true;\n searchMode = false;\n currentSpecialUse = \"\";\n showToInsteadOfFrom = false; // Unified inbox always shows From, not To\n currentPage = 1;\n totalMessages = 0;\n\n const body = document.getElementById(\"ml-body\");\n if (!body) return;\n const fromHeader = document.querySelector(\".ml-col-from\");\n if (fromHeader) fromHeader.textContent = \"From\";\n\n // Saved per-view position wins over current DOM selection \u2014 same\n // rationale as loadMessages.\n const remembered = positionMemory.get(CACHE_KEY_UNIFIED);\n const savedScroll = remembered?.scroll ?? (!autoSelect ? body.scrollTop : 0);\n\n // Instant first paint from cache. The IPC fires in background below\n // and only triggers a re-render if the result diverges from the\n // cached snapshot.\n const cached = listCache.get(CACHE_KEY_UNIFIED);\n if (cached) {\n totalMessages = cached.total;\n state.setMessages(cached.items);\n renderMessages(body, \"\", cached.items);\n const targetUuid = remembered ? pickRestoreUid(cached.items, remembered) : null;\n if (targetUuid) {\n body.scrollTop = savedScroll;\n restoreSelection(body, targetUuid);\n } else if (autoSelect) {\n selectFirst(body);\n }\n } else if (autoSelect) {\n body.innerHTML = `<div class=\"ml-empty\">Loading...</div>`;\n }\n\n try {\n const result = await apiGetUnifiedInbox(1);\n if (myGen !== loadGen) return; // user moved on; drop stale response\n totalMessages = result.total;\n listCache.set(CACHE_KEY_UNIFIED, { items: result.items, total: result.total, timestamp: Date.now() });\n\n // Skip the re-render entirely if the IPC result matches what\n // the cache already painted. Avoids DOM churn / scroll-jump\n // on the common no-change refresh.\n if (cached && listResultsEqual(cached.items, result.items)) return;\n\n if (result.items.length === 0) {\n state.setMessages([]);\n body.innerHTML = `<div class=\"ml-empty\">${result.total > 0 ? `${result.total} messages syncing...` : \"Syncing \u2014 messages will appear shortly\"}</div>`;\n return;\n }\n\n state.setMessages(result.items);\n renderMessages(body, \"\", result.items);\n\n const targetUuid = remembered ? pickRestoreUid(result.items, remembered) : null;\n if (targetUuid) {\n body.scrollTop = savedScroll;\n restoreSelection(body, targetUuid);\n } else if (autoSelect) {\n selectFirst(body);\n }\n } catch (e: any) {\n if (e.name === \"AbortError\") return;\n if (myGen !== loadGen) return;\n body.innerHTML = `<div class=\"ml-empty\">Error: ${e.message}</div>`;\n }\n}\n\n/** Load search results.\n * `includeTrashSpam` only matters for global (\"all\") and \"server\" scopes \u2014\n * per-folder search ALWAYS returns matches in that folder, including when\n * the folder itself is trash/junk. */\nexport async function loadSearchResults(query: string, scope = \"all\", accountId = \"\", folderId = 0, includeTrashSpam = false): Promise<void> {\n const myGen = ++loadGen;\n // Capture the pre-search mode on the first transition only \u2014 repeated\n // searches (typing more characters) shouldn't overwrite it with the\n // intermediate `unifiedMode = false` state.\n if (!searchMode) wasUnifiedBeforeSearch = unifiedMode;\n searchMode = true;\n unifiedMode = false;\n currentSearchQuery = query;\n currentPage = 1;\n totalMessages = 0;\n\n const body = document.getElementById(\"ml-body\");\n if (!body) return;\n\n // Search reload tears down the current row set \u2014 focus must release\n // along with the rows. releaseFocus clears the preview pane in the\n // same call frame, so no orphan preview lingers behind a list that\n // no longer contains the previously-shown row.\n releaseFocus();\n\n // Only wipe to \"Searching...\" when there are no rows already showing.\n // Otherwise the list blanks every time sync fires `reloadCurrentFolder`\n // (which re-runs loadSearchResults), making the user see results\n // appear then disappear when an IDLE/sync event lands mid-typing.\n // The gen counter still guarantees only the latest query's rows\n // render \u2014 stale results don't sneak through.\n const hasExistingRows = body.querySelector(\".ml-row\") !== null;\n if (!hasExistingRows) {\n body.innerHTML = `<div class=\"ml-empty\">Searching...</div>`;\n }\n\n const _searchT0 = performance.now();\n const _stamp = (label: string): void => {\n const line = `[search ${(performance.now() - _searchT0).toFixed(0).padStart(5)} ms] ${label} q=${JSON.stringify(query)}`;\n console.log(\" \" + line);\n try { (window as any).mailxapi?.logClientEvent?.(line); } catch { /* */ }\n };\n _stamp(\"start\");\n try {\n // Regex search: filter client-side\n if (query.startsWith(\"/\") && query.endsWith(\"/\") && query.length > 2) {\n const pattern = query.slice(1, -1);\n let regex: RegExp;\n try { regex = new RegExp(pattern, \"i\"); } catch { body.innerHTML = `<div class=\"ml-empty\">Invalid regex</div>`; return; }\n\n const source = scope === \"current\" && accountId\n ? await apiGetMessages(accountId, folderId, 1, 10000)\n : await apiGetUnifiedInbox(1, 10000);\n if (myGen !== loadGen) return;\n const matches = source.items.filter((m: any) =>\n regex.test(m.subject || \"\") || regex.test(m.from?.name || \"\") || regex.test(m.from?.address || \"\") || regex.test(m.preview || \"\")\n );\n totalMessages = matches.length;\n state.setMessages(matches);\n if (matches.length === 0) { body.innerHTML = `<div class=\"ml-empty\">No regex matches</div>`; return; }\n body.innerHTML = \"\";\n appendMessages(body, \"\", matches);\n selectFirst(body);\n return;\n }\n\n _stamp(\"IPC begin\");\n const result = await searchMessages(query, 1, 50, scope, accountId, folderId, includeTrashSpam);\n _stamp(`IPC done (${result.items.length}/${result.total})`);\n if (myGen !== loadGen) return;\n totalMessages = result.total;\n\n // Server search may have failed in some folders. Surfacing it matters\n // most for the zero-results case: \"No results\" + 5 folders that never\n // got searched is NOT the same as \"definitely not on the server\".\n const partial = !!(result as any).partial;\n const foldersFailed = (result as any).foldersFailed || 0;\n const foldersSearched = (result as any).foldersSearched || 0;\n const partialNote = partial\n ? `<div class=\"ml-search-warning\">\u26A0 Searched ${foldersSearched} folder(s); ${foldersFailed} could not be searched (timeout / connection). Results may be incomplete \u2014 retry to search them again.</div>`\n : \"\";\n\n if (result.items.length === 0) {\n state.setMessages([]);\n body.innerHTML = partialNote +\n `<div class=\"ml-empty\">No results for \"${query}\"${partial ? \" in the folders that responded\" : \"\"}</div>`;\n return;\n }\n\n state.setMessages(result.items);\n body.innerHTML = partialNote;\n appendMessages(body, \"\", result.items);\n // Auto-focus the first result so the preview shows immediately \u2014\n // matching the regex-search path and the loadMessages default.\n selectFirst(body);\n } catch (e: any) {\n body.innerHTML = `<div class=\"ml-empty\">Search error: ${e.message}</div>`;\n }\n}\n\nexport async function loadMessages(accountId: string, folderId: number, page = 1, specialUse = \"\", autoSelect = true): Promise<void> {\n const myGen = ++loadGen;\n searchMode = false;\n unifiedMode = false;\n // Folder switch clears any in-progress multi-select \u2014 carrying a \"3\n // selected\" state across folders would lie about what rows the bulk\n // buttons would act on.\n exitMultiSelect();\n // specialUse is either the DB tag (\"sent\"/\"drafts\"/\"outbox\") or the\n // folder path lowercased (folder-tree fallback when tag is missing \u2014 common\n // on Dovecot which doesn't advertise \\Sent). Match both cases.\n // Empty specialUse on reload means \"keep what we had\" \u2014 otherwise a\n // folderCountsChanged event or sort-header click resets Sent/Drafts/Outbox\n // back to showing From (user-reported regression 2026-04-24).\n if (specialUse) currentSpecialUse = specialUse;\n const su = currentSpecialUse.toLowerCase();\n showToInsteadOfFrom = su === \"sent\" || su === \"drafts\" || su === \"outbox\"\n || su.endsWith(\"sent\") || su.endsWith(\"drafts\") || su.endsWith(\"outbox\")\n || su === \"sent items\" || su === \"sent mail\" || su.endsWith(\"/sent items\") || su.endsWith(\".sent items\");\n currentAccountId = accountId;\n currentFolderId = folderId;\n currentPage = 1;\n totalMessages = 0;\n\n const body = document.getElementById(\"ml-body\");\n if (!body) return;\n\n // Update header label\n const fromHeader = document.querySelector(\".ml-col-from\");\n if (fromHeader) fromHeader.textContent = showToInsteadOfFrom ? \"To\" : \"From\";\n\n const flaggedOnly = document.getElementById(\"ml-body\")?.classList.contains(\"flagged-only\") || false;\n const cKey = cacheKey(\"folder\", accountId, folderId, flaggedOnly);\n // Saved per-view position takes priority over the DOM's current\n // selection \u2014 when switching from folder A to B and back, the DOM\n // shows B's selected row but we want to land on whatever was last\n // focused in A.\n const remembered = positionMemory.get(cKey);\n const savedScroll = remembered?.scroll ?? (!autoSelect ? body.scrollTop : 0);\n const cached = listCache.get(cKey);\n if (cached) {\n totalMessages = cached.total;\n state.setMessages(cached.items);\n renderMessages(body, accountId, cached.items);\n const targetUuid = remembered ? pickRestoreUid(cached.items, remembered) : null;\n if (targetUuid) {\n requestAnimationFrame(() => {\n body.scrollTop = savedScroll;\n restoreSelection(body, targetUuid);\n });\n } else if (autoSelect) {\n selectFirst(body);\n }\n } else if (autoSelect) {\n body.innerHTML = `<div class=\"ml-empty\">Loading...</div>`;\n }\n\n try {\n const result = await apiGetMessages(accountId, folderId, 1, 50, flaggedOnly, currentSort, currentSortDir);\n // Stale-response guard: a newer load* fired while we were\n // awaiting; the new view already painted. Drop this result\n // silently rather than overwriting.\n if (myGen !== loadGen) return;\n totalMessages = result.total;\n listCache.set(cKey, { items: result.items, total: result.total, timestamp: Date.now() });\n updateSortIndicators();\n // Skip re-render if data unchanged from the painted cache.\n if (cached && listResultsEqual(cached.items, result.items)) return;\n\n if (result.items.length === 0) {\n state.setMessages([]);\n body.innerHTML = `<div class=\"ml-empty\">${flaggedOnly ? \"No flagged messages\" : \"No messages\"}</div>`;\n return;\n }\n\n state.setMessages(result.items);\n renderMessages(body, accountId, result.items);\n\n // Prefer saved position; otherwise default by autoSelect.\n const targetUuid = remembered ? pickRestoreUid(result.items, remembered) : null;\n if (targetUuid) {\n requestAnimationFrame(() => {\n if (myGen !== loadGen) return;\n body.scrollTop = savedScroll;\n restoreSelection(body, targetUuid);\n });\n } else if (autoSelect) {\n selectFirst(body);\n }\n } catch (e: any) {\n if (e.name === \"AbortError\") return;\n if (myGen !== loadGen) return; // user moved on; suppress the error message\n body.innerHTML = `<div class=\"ml-empty\">Error: ${e.message}</div>`;\n }\n}\n\nasync function loadMoreMessages(): Promise<void> {\n const body = document.getElementById(\"ml-body\");\n if (!body) return;\n\n loading = true;\n currentPage++;\n\n try {\n const flaggedOnly = body.classList.contains(\"flagged-only\");\n const result = searchMode\n ? await searchMessages(currentSearchQuery, currentPage)\n : unifiedMode\n ? await apiGetUnifiedInbox(currentPage)\n : await apiGetMessages(currentAccountId, currentFolderId, currentPage, 50, flaggedOnly);\n // Append to state\n const current = state.getMessages();\n state.setMessages([...current, ...result.items]);\n appendMessages(body, unifiedMode ? \"\" : currentAccountId, result.items);\n } catch (e: any) {\n console.error(`Load more error: ${e.message}`);\n } finally {\n loading = false;\n }\n}\n\n/** Replace body contents with rendered rows */\nfunction renderMessages(body: HTMLElement, accountId: string, items: any[]): void {\n const fragment = document.createDocumentFragment();\n const tempDiv = document.createElement(\"div\");\n appendMessages(tempDiv, accountId, items);\n while (tempDiv.firstChild) fragment.appendChild(tempDiv.firstChild);\n body.replaceChildren(fragment);\n}\n\nfunction selectFirst(body: HTMLElement): void {\n // Narrow viewports (Android, phone-sized): don't auto-select. The\n // click handler in app.ts switches the layout to \"narrow-active\" on\n // any list-row click, which on a phone means the message viewer takes\n // over the screen and hides the list. Auto-selecting at startup\n // therefore lands the user in the LAST letter they read instead of\n // the inbox summary they wanted. Desktop unchanged \u2014 auto-select\n // remains useful when the list and viewer are side-by-side.\n if (window.innerWidth <= 768) return;\n const firstRow = body.querySelector(\".ml-row\") as HTMLElement;\n if (firstRow) firstRow.click();\n}\n\nfunction restoreSelection(body: HTMLElement, savedUuid: string | null | undefined): void {\n if (!savedUuid) return;\n // Locate the row by stable uuid \u2014 unique, so this is the exact message\n // the user had selected, never a uid-number collision in another folder.\n const row = body.querySelector(`.ml-row[data-uuid=\"${CSS.escape(savedUuid)}\"]`) as HTMLElement | null;\n if (!row) return;\n const accountId = row.dataset.accountId;\n const uid = Number(row.dataset.uid);\n if (accountId && Number.isFinite(uid)) {\n // {scroll:false} \u2014 programmatic restore after a sync-driven reload.\n // The user might be reading rows ABOVE the focused one; do NOT yank\n // their scroll back to the focused row every time the list rebuilds.\n focusByIdentity(accountId, uid, { scroll: false });\n }\n}\n\n/** Show a floating list of all messages in a thread when the pill is clicked.\n * Each entry in the popup selects that message in the viewer when clicked.\n * This is simpler than inline expansion and avoids duplicating the row builder. */\nexport async function showThreadPopup(pillEl: HTMLElement, headMsg: any): Promise<void> {\n // Remove any existing popup\n document.querySelectorAll(\".ml-thread-popup\").forEach(el => el.remove());\n let thread: any[] = [];\n try { thread = await getThreadMessages(headMsg.accountId, headMsg.threadId); } catch { /* ignore */ }\n if (!thread || thread.length === 0) return;\n thread.sort((a, b) => (a.date || 0) - (b.date || 0));\n const popup = document.createElement(\"div\");\n popup.className = \"ml-thread-popup\";\n for (const msg of thread) {\n const item = document.createElement(\"div\");\n item.className = \"ml-thread-popup-item\";\n if (!seenOf(msg)) item.classList.add(\"unread\");\n const from = document.createElement(\"span\");\n from.className = \"ml-thread-popup-from\";\n from.textContent = msg.from?.name || msg.from?.address || \"?\";\n const date = document.createElement(\"span\");\n date.className = \"ml-thread-popup-date\";\n date.textContent = formatDate(msg.date);\n const subject = document.createElement(\"span\");\n subject.className = \"ml-thread-popup-subject\";\n subject.textContent = msg.subject || \"(no subject)\";\n item.appendChild(from);\n item.appendChild(date);\n item.appendChild(subject);\n item.addEventListener(\"click\", async () => {\n // Thread popup \u2192 viewer-only update. The list keeps showing the\n // thread head highlighted (it's the row in the actual list);\n // the viewer pivots to the clicked thread member. Single path\n // in: the viewer's show() call. No state, no event, no drift.\n const envelope = {\n accountId: msg.accountId,\n uid: msg.uid,\n folderId: msg.folderId,\n subject: msg.subject,\n from: msg.from,\n to: msg.to,\n cc: msg.cc,\n date: msg.date,\n flags: msg.flags,\n size: msg.size,\n preview: msg.preview,\n hasAttachments: msg.hasAttachments,\n };\n viewerShow(msg.accountId, msg.uid, msg.folderId, currentSpecialUse || undefined, false, envelope as ListMessage);\n onMessageSelect(msg.accountId, msg.uid, msg.folderId);\n popup.remove();\n });\n popup.appendChild(item);\n }\n document.body.appendChild(popup);\n const rect = pillEl.getBoundingClientRect();\n popup.style.left = `${rect.left}px`;\n popup.style.top = `${rect.bottom + 4}px`;\n // Dismiss on outside click\n setTimeout(() => {\n const dismiss = (e: MouseEvent) => {\n if (!popup.contains(e.target as Node)) {\n popup.remove();\n document.removeEventListener(\"mousedown\", dismiss, true);\n }\n };\n document.addEventListener(\"mousedown\", dismiss, true);\n }, 0);\n}\n\n/** A rendered row in the message list.\n *\n * Owns its DOM element, the message envelope it represents, and all of\n * its event handlers (click, dblclick, drag, contextmenu, touch\n * long-press, plus the avatar / flag / thread-pill child handlers).\n * State changes that affect appearance (selected, unread, flagged,\n * body-cached) go through methods so the OO layer is the single point\n * of mutation \u2014 no `el.classList.toggle(\"selected\")` scattered through\n * the codebase.\n *\n * Lifecycle: constructor builds DOM and wires handlers; `attach(body)`\n * inserts into the list; `detach()` removes from DOM and from the\n * module-level rowByKey map. The list controller diff-updates rows on\n * list mutations.\n *\n * Multi-select still uses `.selected` class queries \u2014 that's a single\n * DOM-level concept where drift isn't an issue (no separate render\n * surface to drift against). The Row class wraps the *focus* concept\n * (which couples to the viewer) as a fate-shared unit. */\nclass MessageRow {\n el: HTMLElement;\n private flagEl: HTMLSpanElement;\n\n constructor(\n public msg: any,\n public accountId: string,\n threadHead: boolean,\n threadCount: number,\n showAccountTag: boolean,\n ) {\n const row = document.createElement(\"div\");\n this.el = row;\n row.className = \"ml-row\";\n row.draggable = true;\n if (!seenOf(msg)) row.classList.add(\"unread\");\n if (flaggedOf(msg)) row.classList.add(\"flagged\");\n if (!msg.bodyPath) row.classList.add(\"not-downloaded\");\n if (msg.pending) row.classList.add(\"pending-reconcile\");\n if (msg.inReplyTo) row.classList.add(\"is-reply\");\n if (isPriorityAddr(msg.from?.address || \"\")) row.classList.add(\"priority\");\n row.dataset.uid = String(msg.uid);\n row.dataset.accountId = accountId;\n row.dataset.folderId = String(msg.folderId);\n // Stable local identity. `uid` is only unique within (account, folder)\n // \u2014 in a unified / multi-folder list two messages can share a uid\n // NUMBER, so selection memory keyed on bare uid can rebind the viewer\n // to the wrong letter after a re-render. `uuid` is globally unique\n // and never changes; selection restore keys on it.\n if (msg.uuid) row.dataset.uuid = msg.uuid;\n if (msg.threadId) row.dataset.threadId = msg.threadId;\n if (threadHead) row.classList.add(\"thread-head\");\n\n // \u2500\u2500 Avatar (sender circle, doubles as multi-select affordance) \u2500\u2500\n const fromName = (showToInsteadOfFrom && msg.to?.length)\n ? (msg.to[0].name || msg.to[0].address || \"?\")\n : (msg.from?.name || msg.from?.address || \"?\");\n const seedAddr = (msg.from?.address || msg.from?.name || \"?\").toLowerCase();\n const initial = (fromName.replace(/^[\\W_]+/, \"\") || \"?\").charAt(0).toUpperCase();\n const avatar = document.createElement(\"span\");\n avatar.className = \"ml-avatar\";\n avatar.textContent = initial;\n avatar.style.background = senderColor(seedAddr);\n avatar.title = msg.from?.address || \"\";\n avatar.addEventListener(\"click\", (e) => this.onAvatarClick(e));\n avatar.addEventListener(\"contextmenu\", (e) => this.onAvatarContextMenu(e));\n\n // \u2500\u2500 Flag star (toggle on click) \u2500\u2500\n const flag = document.createElement(\"span\");\n flag.className = \"ml-flag\";\n flag.textContent = flaggedOf(msg) ? \"\u2605\" : \"\u2606\";\n flag.title = \"Toggle flag\";\n flag.addEventListener(\"click\", (e) => this.onFlagClick(e));\n this.flagEl = flag;\n\n // \u2500\u2500 From column \u2500\u2500\n const from = document.createElement(\"span\");\n from.className = \"ml-from\";\n if (showToInsteadOfFrom && msg.to?.length) {\n from.textContent = msg.to.map((a: any) => a.name || a.address).join(\", \");\n } else {\n from.textContent = msg.from.name || msg.from.address;\n }\n if (showAccountTag && accountId) {\n const tag = document.createElement(\"span\");\n tag.className = \"ml-account-tag\";\n tag.textContent = accountId.charAt(0).toUpperCase();\n tag.title = accountId;\n from.prepend(tag);\n }\n if (msg.folderName) {\n const folderTag = document.createElement(\"span\");\n folderTag.className = \"ml-folder-tag\";\n folderTag.textContent = msg.folderName;\n folderTag.title = `In folder: ${msg.folderName}`;\n from.prepend(folderTag);\n }\n if ((msg.dupeCount as number) >= 2) {\n const dupe = document.createElement(\"span\");\n dupe.className = \"ml-dupe-tag\";\n dupe.textContent = \"\u21C6\";\n dupe.title = `Same message on ${msg.dupeCount} accounts`;\n from.prepend(dupe);\n }\n\n // \u2500\u2500 Subject (with optional thread pill + preview snippet) \u2500\u2500\n const subject = document.createElement(\"span\");\n subject.className = \"ml-subject\";\n subject.innerHTML = escapeHtml(msg.subject);\n if (threadHead && threadCount > 1 && msg.threadId) {\n const threadPill = document.createElement(\"span\");\n threadPill.className = \"ml-thread-pill\";\n threadPill.textContent = String(threadCount);\n threadPill.title = `${threadCount} messages in this thread \u2014 click to see list`;\n threadPill.addEventListener(\"click\", async (e) => {\n e.stopPropagation();\n await showThreadPopup(threadPill, msg);\n });\n subject.prepend(threadPill);\n }\n if (msg.preview) {\n const preview = document.createElement(\"span\");\n preview.className = \"ml-preview\";\n preview.textContent = ` \u2014 ${msg.preview}`;\n subject.appendChild(preview);\n }\n\n // \u2500\u2500 Date column (with status icons: attachment / replied / forwarded) \u2500\u2500\n const date = document.createElement(\"span\");\n date.className = \"ml-date\";\n const icons = document.createElement(\"span\");\n icons.className = \"ml-status-icons\";\n renderStatusIcons(icons, msg);\n const dateText = document.createElement(\"span\");\n dateText.className = \"ml-date-text\";\n dateText.textContent = formatDate(msg.date);\n date.appendChild(icons);\n date.appendChild(dateText);\n\n row.appendChild(avatar);\n row.appendChild(flag);\n row.appendChild(from);\n row.appendChild(date);\n row.appendChild(subject);\n\n row.addEventListener(\"click\", (e) => this.onRowClick(e));\n row.addEventListener(\"dblclick\", (e) => this.onRowDoubleClick(e));\n row.addEventListener(\"dragstart\", (e) => this.onDragStart(e));\n row.addEventListener(\"dragend\", () => row.classList.remove(\"dragging\"));\n row.addEventListener(\"contextmenu\", (e) => this.onRowContextMenu(e));\n this.wireLongPress(row);\n }\n\n /** Insert this row into a list body and register it in the lookup map. */\n attach(body: HTMLElement): void {\n body.appendChild(this.el);\n rowByKey.set(rowKey(this.accountId, this.msg.uid), this);\n }\n\n /** Remove this row from the DOM and the lookup map. If it was the\n * focused row, the controller is responsible for releasing focus\n * (this method doesn't auto-clear the viewer because the controller\n * may want to hand focus to a sibling instead). */\n detach(): void {\n this.el.remove();\n rowByKey.delete(rowKey(this.accountId, this.msg.uid));\n }\n\n setSelected(yes: boolean): void { this.el.classList.toggle(\"selected\", yes); }\n get isSelected(): boolean { return this.el.classList.contains(\"selected\"); }\n setUnreadClass(yes: boolean): void { this.el.classList.toggle(\"unread\", yes); }\n setFlaggedClass(yes: boolean): void {\n this.el.classList.toggle(\"flagged\", yes);\n this.flagEl.textContent = yes ? \"\u2605\" : \"\u2606\";\n }\n markBodyCached(): void { this.el.classList.remove(\"not-downloaded\"); }\n /** Update the row's date column. Used by the draft-saved listener so\n * Ctrl+S in compose bumps the time in the Drafts list immediately\n * rather than waiting for the next IMAP APPEND + sync round-trip. */\n setDate(epochMs: number): void {\n this.msg.date = epochMs;\n const el = this.el.querySelector(\".ml-date-text\") as HTMLElement | null;\n if (el) el.textContent = formatDate(epochMs);\n }\n\n /** Re-render the attachment / replied / forwarded glyphs in the date\n * cell. Called when a flag-change event (Answered, $Forwarded) lands\n * for this row so the icon strip reflects current state without a\n * full row rebuild. */\n refreshStatusIcons(): void {\n const icons = this.el.querySelector(\".ml-status-icons\") as HTMLElement | null;\n if (icons) renderStatusIcons(icons, this.msg);\n }\n\n // Visual-state accessors for flag toggles. Read the *visual* state\n // (CSS class), not `this.msg.flags`, because the flag array can lag\n // the rendered class \u2014 auto-mark-as-read removes the `unread` class\n // immediately on click but `\\Seen` doesn't land in `msg.flags` until\n // the auto-mark timer fires (default 2 s). Right-click menus and\n // keyboard shortcuts ask \"what does the user see?\" \u2014 that's what\n // these getters answer. Setters keep both visual + flag-array in\n // sync so the next read by either side agrees.\n get isSeen(): boolean { return !this.el.classList.contains(\"unread\"); }\n set isSeen(yes: boolean) {\n this.setUnreadClass(!yes);\n setSeen(this.msg, yes);\n }\n get isFlagged(): boolean { return this.el.classList.contains(\"flagged\"); }\n set isFlagged(yes: boolean) {\n this.setFlaggedClass(yes);\n setFlagged(this.msg, yes);\n }\n\n private onAvatarClick(e: MouseEvent): void {\n e.stopPropagation();\n const body = document.getElementById(\"ml-body\");\n if (!body) return;\n if (body.classList.contains(\"multi-select-on\")) {\n this.setSelected(!this.isSelected);\n } else {\n clearSelection();\n this.setSelected(true);\n body.classList.add(\"multi-select-on\");\n }\n lastClickedRow = this.el;\n updateBulkBar();\n }\n\n private async onAvatarContextMenu(e: MouseEvent): Promise<void> {\n e.preventDefault();\n e.stopPropagation();\n const { showContextMenu: showMenu } = await import(\"./context-menu.js\");\n const body = document.getElementById(\"ml-body\");\n const visibleRows = body\n ? Array.from(body.querySelectorAll<HTMLElement>(\".ml-row:not(.filter-hidden)\"))\n : [];\n const selectedCount = body\n ? body.querySelectorAll(\".ml-row.selected\").length\n : 0;\n showMenu(e.clientX, e.clientY, [\n {\n label: `Select all (${visibleRows.length})`,\n action: () => {\n if (!body) return;\n body.classList.add(\"multi-select-on\");\n for (const r of visibleRows) r.classList.add(\"selected\");\n lastClickedRow = visibleRows[visibleRows.length - 1] || null;\n updateBulkBar();\n },\n disabled: visibleRows.length === 0,\n },\n {\n label: `Clear selection${selectedCount ? ` (${selectedCount})` : \"\"}`,\n action: () => exitMultiSelect(),\n disabled: selectedCount === 0,\n },\n {\n label: \"Invert selection\",\n action: () => {\n if (!body) return;\n body.classList.add(\"multi-select-on\");\n for (const r of visibleRows) r.classList.toggle(\"selected\");\n lastClickedRow = visibleRows[visibleRows.length - 1] || null;\n updateBulkBar();\n },\n disabled: visibleRows.length === 0,\n },\n ]);\n }\n\n private async onFlagClick(e: MouseEvent): Promise<void> {\n e.stopPropagation();\n // Toggle relative to visual state. The row's `isFlagged` setter\n // would update msg.flags + class together, but we want to send\n // the new array to the server BEFORE persisting locally so a\n // network failure leaves the row in a known state.\n const newFlaggedState = !this.el.classList.contains(\"flagged\");\n const probe = { flags: [...(this.msg.flags || [])] };\n setFlagged(probe, newFlaggedState);\n try {\n await updateFlags(this.accountId, this.msg.uid, probe.flags);\n this.msg.flags = probe.flags;\n this.setFlaggedClass(newFlaggedState);\n } catch { /* ignore \u2014 visual state unchanged */ }\n }\n\n private onRowClick(e: MouseEvent): void {\n if (touchWasScroll) { touchWasScroll = false; return; }\n const body = this.el.parentElement as HTMLElement | null;\n if (body?.classList.contains(\"multi-select-on\")) {\n const willBeSelected = !this.isSelected;\n this.setSelected(willBeSelected);\n lastClickedRow = this.el;\n // 2026-05-13: clicking a row in multi-select mode wasn't showing\n // it in the viewer \u2014 Bob saw a blue-selected row with the empty\n // \"Select a message to read\" pane. Drive the viewer off the\n // last-toggled-on row so the message you just clicked is the\n // one you see. Deselecting doesn't change the focus.\n if (willBeSelected) focusRow(this);\n updateBulkBar();\n return;\n }\n // Note: no `setUnreadClass(false)` on click. The unread\u2192read flip\n // is the auto-mark-as-read path's job (showMessage runs an STORE\n // \\Seen after the dwell delay; on success it calls setRowSeen).\n // Pre-2026-05-13 we flipped the class immediately on click which\n // produced the \"menu says 'Mark unread' on a row whose body never\n // loaded\" complaint.\n if (e.shiftKey) {\n const anchor = resolveShiftAnchor();\n if (anchor) {\n clearSelection();\n selectRange(anchor, this.el);\n lastClickedRow = this.el;\n focusRow(this);\n } else {\n clearSelection();\n focusRow(this);\n lastClickedRow = this.el;\n }\n } else if (e.ctrlKey || e.metaKey) {\n this.setSelected(!this.isSelected);\n lastClickedRow = this.el;\n } else {\n clearSelection();\n focusRow(this);\n lastClickedRow = this.el;\n }\n updateBulkBar();\n }\n\n private onRowDoubleClick(e: MouseEvent): void {\n e.preventDefault();\n e.stopPropagation();\n document.dispatchEvent(new CustomEvent(\"mailx-popout-message\", {\n detail: { accountId: this.accountId, uid: this.msg.uid, folderId: this.msg.folderId, subject: this.msg.subject },\n }));\n }\n\n private onDragStart(e: DragEvent): void {\n if (!this.isSelected) {\n clearSelection();\n this.setSelected(true);\n lastClickedRow = this.el;\n }\n const selected = getSelectedMessages();\n e.dataTransfer!.setData(\"application/x-mailx-messages\", JSON.stringify(selected));\n e.dataTransfer!.setData(\"application/x-mailx-message\", JSON.stringify({\n accountId: this.accountId,\n uid: this.msg.uid,\n folderId: this.msg.folderId,\n subject: this.msg.subject,\n }));\n e.dataTransfer!.effectAllowed = \"copyMove\";\n this.el.classList.add(\"dragging\");\n if (selected.length > 1) {\n const badge = document.createElement(\"div\");\n badge.textContent = `${selected.length} messages`;\n badge.style.cssText = \"position:absolute;top:-1000px;background:#333;color:white;padding:4px 8px;border-radius:4px;font-size:12px\";\n document.body.appendChild(badge);\n e.dataTransfer!.setDragImage(badge, 0, 0);\n setTimeout(() => badge.remove(), 0);\n }\n }\n\n private wireLongPress(row: HTMLElement): void {\n let longPressTimer: ReturnType<typeof setTimeout> | null = null;\n const LONG_PRESS_MS = 550;\n row.addEventListener(\"touchstart\", (_e: TouchEvent) => {\n if (longPressTimer) clearTimeout(longPressTimer);\n longPressTimer = setTimeout(() => {\n longPressTimer = null;\n const body = row.parentElement as HTMLElement | null;\n const alreadyMulti = body?.classList.contains(\"multi-select-on\");\n if (alreadyMulti) {\n this.setSelected(!this.isSelected);\n } else {\n clearSelection();\n this.setSelected(true);\n body?.classList.add(\"multi-select-on\");\n }\n lastClickedRow = this.el;\n updateBulkBar();\n try { (navigator as any).vibrate?.(20); } catch { /* */ }\n }, LONG_PRESS_MS);\n }, { passive: true });\n const cancelLongPress = () => {\n if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }\n };\n row.addEventListener(\"touchmove\", cancelLongPress, { passive: true });\n row.addEventListener(\"touchend\", cancelLongPress, { passive: true });\n row.addEventListener(\"touchcancel\", cancelLongPress, { passive: true });\n }\n\n private onRowContextMenu(e: MouseEvent): void {\n e.preventDefault();\n const body = this.el.parentElement as HTMLElement | null;\n const inMulti = !!body?.classList.contains(\"multi-select-on\");\n if (!this.isSelected) {\n if (inMulti) {\n this.setSelected(true);\n lastClickedRow = this.el;\n } else {\n clearSelection();\n lastClickedRow = this.el;\n focusRow(this);\n }\n }\n\n // Read state for the menu label tracks the VISUAL state, not the\n // flag array. On click we remove the `unread` class immediately\n // (optimistic) but `msg.flags` doesn't pick up `\\Seen` until the\n // auto-mark-as-read timer fires (default 2 s). Bob 2026-05-13:\n // right-clicking a freshly-clicked row offered \"Mark read\" while\n // the row already looked read. Reading the class keeps the label\n // honest with what the user sees.\n const isSeen = !this.el.classList.contains(\"unread\");\n const isFlagged = this.el.classList.contains(\"flagged\");\n const accountId = this.accountId;\n const msg = this.msg;\n const self = this;\n\n const items: MenuItem[] = [\n {\n label: isSeen ? \"Mark unread\" : \"Mark read\",\n action: async () => {\n // Target state = inverse of what the menu showed,\n // matching what the user clicked. Visual-state-based\n // labels stay coherent with the auto-mark-as-read\n // debounce; the new flag array is computed via the\n // typed setter and sent to the server.\n const probe = { flags: [...(msg.flags || [])] };\n setSeen(probe, !isSeen);\n try {\n await updateFlags(accountId, msg.uid, probe.flags);\n msg.flags = probe.flags;\n state.updateMessageFlags(accountId, msg.uid, probe.flags);\n self.setUnreadClass(isSeen);\n } catch { /* ignore */ }\n },\n },\n {\n label: isFlagged ? \"Unflag\" : \"Flag\",\n action: async () => {\n const probe = { flags: [...(msg.flags || [])] };\n setFlagged(probe, !isFlagged);\n try {\n await updateFlags(accountId, msg.uid, probe.flags);\n msg.flags = probe.flags;\n self.setFlaggedClass(!isFlagged);\n } catch { /* ignore */ }\n },\n },\n { label: \"\", action: () => {}, separator: true },\n // Drafts get an explicit \"Edit draft\" entry as the primary action \u2014\n // double-click already opens the row in compose, but the context\n // menu had no equivalent (Bob 2026-05-13). Detect via \\Draft flag\n // or the Drafts folder context. Edit triggers the same path as\n // double-click: dispatching popout-message lets app.ts's draft\n // detection route through to compose.\n ...((() => {\n const isDraftRow = draftOf(msg) || currentSpecialUse.toLowerCase() === \"drafts\";\n if (!isDraftRow) return [] as MenuItem[];\n return [\n {\n label: \"Edit draft\",\n action: () => document.dispatchEvent(new CustomEvent(\"mailx-popout-message\", {\n detail: { accountId, uid: msg.uid, folderId: msg.folderId, subject: msg.subject },\n })),\n },\n { label: \"\", action: () => {}, separator: true },\n ];\n })()),\n { label: \"Reply\", action: () => document.dispatchEvent(new CustomEvent(\"mailx-compose\", { detail: { mode: \"reply\" } })) },\n { label: \"Reply All\", action: () => document.dispatchEvent(new CustomEvent(\"mailx-compose\", { detail: { mode: \"replyAll\" } })) },\n { label: \"Forward\", action: () => document.dispatchEvent(new CustomEvent(\"mailx-compose\", { detail: { mode: \"forward\" } })) },\n { label: \"\", action: () => {}, separator: true },\n {\n label: \"Move to folder\u2026\",\n action: async () => {\n const selectedRows = Array.from(document.querySelectorAll(\".ml-row.selected\"));\n const uids = selectedRows.length > 0\n ? selectedRows.map((r: Element) => Number((r as HTMLElement).dataset.uid)).filter(u => !isNaN(u))\n : [msg.uid];\n const pick = await pickFolder(accountId, { excludeFolderIds: [msg.folderId] });\n if (!pick) return;\n // Local-first: optimistic remove first, fire-and-forget IPC.\n // The IPC response may stall behind a long simpleParser\n // (event-loop block) and time out at 120s, but the server-\n // side move IS already queued at that point. Awaiting +\n // alerting on timeout misreports a success as a failure.\n removeMessagesAndReconcile(uids.map(u => ({ accountId, uid: u })));\n const statusSync = document.getElementById(\"status-sync\");\n if (statusSync) statusSync.textContent = `Moving ${uids.length} message${uids.length !== 1 ? \"s\" : \"\"} to ${pick.folderName}\u2026`;\n apiMoveMessages(accountId, uids, pick.folderId)\n .then(() => { if (statusSync) statusSync.textContent = `Moved ${uids.length} to ${pick.folderName}`; })\n .catch((err: any) => {\n console.error(`Move failed: ${err?.message || err}`);\n if (statusSync) statusSync.textContent = `Move sync issue: ${err?.message || err}`;\n });\n },\n },\n { label: \"Delete\", action: () => document.dispatchEvent(new CustomEvent(\"mailx-delete\")) },\n { label: \"\", action: () => {}, separator: true },\n { label: \"\u26A0 Mark as spam\", action: () => document.getElementById(\"btn-spam\")?.click() },\n { label: \"\", action: () => {}, separator: true },\n {\n label: \"Copy Message-ID\",\n action: async () => {\n if (!msg.messageId) { alert(\"No Message-ID on this row.\"); return; }\n try { await navigator.clipboard.writeText(msg.messageId); } catch { /* */ }\n },\n },\n ];\n\n showContextMenu(e.clientX, e.clientY, items);\n }\n}\n\nfunction appendMessages(body: HTMLElement, accountId: string, items: any[]): void {\n // Thread grouping: when the list has the \"threaded\" class, collapse\n // messages sharing the same threadId to a single row showing the most\n // recent message, with a small pill indicating the thread size.\n const threaded = body.classList.contains(\"threaded\");\n let rowsToRender: any[] = items;\n let threadSize: Map<any, number> | null = null;\n if (threaded) {\n const threadMap = new Map<string, any>();\n threadSize = new Map<any, number>();\n for (const msg of items) {\n const key = msg.threadId || `_msg_${msg.accountId || accountId}_${msg.uid}`;\n const existing = threadMap.get(key);\n if (!existing || (msg.date || 0) > (existing.date || 0)) {\n threadMap.set(key, msg);\n }\n }\n for (const msg of items) {\n const key = msg.threadId || `_msg_${msg.accountId || accountId}_${msg.uid}`;\n const head = threadMap.get(key);\n if (head) threadSize.set(head, (threadSize.get(head) || 0) + 1);\n }\n rowsToRender = Array.from(threadMap.values()).sort((a, b) => (b.date || 0) - (a.date || 0));\n }\n for (const msg of rowsToRender) {\n const msgAccountId = msg.accountId || accountId;\n const threadCount = threadSize ? (threadSize.get(msg) || 1) : 1;\n const isThreadHead = threadCount > 1 && !!msg.threadId;\n // showAccountTag: true when rendering the unified inbox (no\n // single accountId for the page; each row carries its own and\n // gets a one-letter account chip).\n const showAccountTag = !accountId && !!msgAccountId;\n const row = new MessageRow(msg, msgAccountId, isThreadHead, threadCount, showAccountTag);\n row.attach(body);\n }\n}\n\n\nfunction formatDate(epochMs: number): string {\n const d = new Date(epochMs);\n const now = new Date();\n const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());\n const msgDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n\n if (msgDay.getTime() === today.getTime())\n return d.toLocaleTimeString(undefined, timeFmt);\n\n if (d.getFullYear() === now.getFullYear())\n return d.toLocaleString(undefined, dateFmtSameYear);\n\n return d.toLocaleString(undefined, dateFmt);\n}\n\nfunction escapeHtml(s: string): string {\n const div = document.createElement(\"div\");\n div.textContent = s;\n return div.innerHTML;\n}\n\n// `draftSaved` from the service tells us a Ctrl+S / autosave wrote new\n// content to disk for the draft identified by `previousDraftUid`. Bump\n// the time in the row immediately so the Drafts list reflects the save\n// instead of waiting for the IMAP APPEND of the new copy + the next sync\n// of the Drafts folder. The new UID is unknown at this point; we update\n// the existing row's date so the user sees something happen on save.\n// Once sync brings the new UID in, the row gets replaced naturally by\n// the list rebuild and the date is whatever the server says.\nonEvent((ev: any) => {\n if (!ev || ev.type !== \"draftSaved\") return;\n if (!ev.accountId || ev.previousDraftUid == null) return;\n const row = rowByKey.get(rowKey(ev.accountId, ev.previousDraftUid));\n if (row) row.setDate(typeof ev.savedAt === \"number\" ? ev.savedAt : Date.now());\n});\n", "/**\n * Outbox view modal \u2014 lists .ltr files currently queued on disk.\n * Pink rows = local-only, not yet reconciled with the server.\n * Clicking the status-bar queue pill opens this.\n */\nimport { listQueuedOutgoing, cancelQueuedOutgoing } from \"../lib/api-client.js\";\n\nlet isOpen = false;\n\nexport async function openOutboxView(): Promise<void> {\n if (isOpen) return;\n isOpen = true;\n\n const backdrop = document.createElement(\"div\");\n backdrop.className = \"mailx-modal-backdrop\";\n const panel = document.createElement(\"div\");\n panel.className = \"mailx-modal mailx-modal-wide\";\n panel.innerHTML = `\n <div class=\"mailx-modal-title\">\n <span class=\"mailx-modal-title-text\">Outbox \u2014 Local Queue</span>\n <button type=\"button\" class=\"mailx-modal-close\" id=\"ob-close\" title=\"Close (Esc)\" aria-label=\"Close\">×</button>\n </div>\n <div class=\"ob-info\">Messages waiting to be sent. Pink rows are local-only until the server accepts them. Click Cancel to drop a queued message.</div>\n <div class=\"ob-list\" id=\"ob-list\">Loading\u2026</div>\n <div class=\"mailx-modal-buttons\">\n <span class=\"mailx-modal-spacer\"></span>\n <button type=\"button\" class=\"mailx-modal-btn\" data-action=\"refresh\">Refresh</button>\n <button type=\"button\" class=\"mailx-modal-btn\" data-action=\"close\">Close</button>\n </div>`;\n backdrop.appendChild(panel);\n document.body.appendChild(backdrop);\n const listEl = panel.querySelector<HTMLElement>(\"#ob-list\")!;\n\n const renderList = (items: any[]) => {\n if (items.length === 0) {\n listEl.innerHTML = `<div class=\"ob-empty\">Outbox is clean \u2014 no messages queued.</div>`;\n return;\n }\n const fmtDate = (ms: number) => new Date(ms).toLocaleString();\n const ageStr = (ms: number): string => {\n const sec = Math.max(0, Math.round((Date.now() - ms) / 1000));\n if (sec < 60) return `${sec}s`;\n if (sec < 3600) return `${Math.round(sec / 60)}m`;\n return `${Math.round(sec / 3600)}h`;\n };\n listEl.innerHTML = items.map((m, i) => {\n // A claim that's \"young\" (under 60 s) is presumed actively\n // sending. Older claims are stuck \u2014 server hung mid-SMTP,\n // worker crashed, PID recycled. Cancel must always be\n // available either way: the user knows their intent. A\n // brief in-flight cancel may cross the SMTP-send window\n // (rare; <60s of risk), in which case the X-Mailx-Retry +\n // Message-ID dedup on the receiving end catches the dup.\n const claimAge = m.claimed ? ageStr(m.createdAt) : \"\";\n const stuckClaim = m.claimed && (Date.now() - m.createdAt) > 60_000;\n const claimBadge = !m.claimed\n ? \"\"\n : stuckClaim\n ? `<span class=\"ob-badge ob-stuck\" title=\"Claim is older than 60 s \u2014 likely stuck. Cancel will drop it.\">stuck (${claimAge})</span>`\n : `<span class=\"ob-badge ob-claimed\" title=\"Sending now on this host\">sending\u2026 (${claimAge})</span>`;\n return `\n <div class=\"ob-row ob-pink\" data-idx=\"${i}\">\n <div class=\"ob-row-hdr\">\n <span class=\"ob-acct\">${escapeHtml(m.accountId)}</span>\n <span class=\"ob-subject\">${escapeHtml(m.subject || \"(no subject)\")}</span>\n <span class=\"ob-created\">${fmtDate(m.createdAt)}</span>\n ${claimBadge}\n ${m.attempts > 0 ? `<span class=\"ob-badge ob-retry\" title=\"Retry attempts made so far\">retry \u00D7${m.attempts}</span>` : \"\"}\n </div>\n <div class=\"ob-row-meta\">\n <span class=\"ob-from\">${escapeHtml(m.from || \"\")}</span>\n \u2192 <span class=\"ob-to\">${escapeHtml(m.to || \"\")}</span>\n ${m.cc ? ` \u00B7 Cc: ${escapeHtml(m.cc)}` : \"\"}\n <span class=\"ob-size\">\u00B7 ${(m.sizeBytes / 1024).toFixed(1)}kB</span>\n </div>\n <div class=\"ob-row-path\">${escapeHtml(m.path)}</div>\n <div class=\"ob-row-actions\">\n <button type=\"button\" class=\"ob-cancel\">Cancel</button>\n </div>\n </div>`;\n }).join(\"\");\n listEl.querySelectorAll<HTMLElement>(\".ob-row\").forEach((row, idx) => {\n row.querySelector<HTMLButtonElement>(\".ob-cancel\")!.addEventListener(\"click\", async () => {\n const m = items[idx];\n const warning = m.claimed\n ? `This message is currently in flight to the SMTP server.\\nIf SMTP already finished, the recipient may have received it.\\nMessage-ID dedup catches the duplicate side; cancel is still safe.\\n\\nDrop anyway?\\n\\nTo: ${m.to}\\nSubject: ${m.subject}`\n : `Drop this queued message?\\n\\nTo: ${m.to}\\nSubject: ${m.subject}`;\n if (!confirm(warning)) return;\n try {\n await cancelQueuedOutgoing(m.path);\n await reload();\n } catch (e: any) {\n alert(`Cancel failed: ${e?.message || e}`);\n }\n });\n });\n };\n\n const reload = async () => {\n try {\n const items = await listQueuedOutgoing();\n renderList(items || []);\n } catch (e: any) {\n listEl.innerHTML = `<div class=\"ob-empty\">Load failed: ${escapeHtml(e?.message || String(e))}</div>`;\n }\n };\n\n const close = () => {\n backdrop.remove();\n document.removeEventListener(\"keydown\", onKey, true);\n isOpen = false;\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") { e.stopPropagation(); e.preventDefault(); close(); }\n };\n document.addEventListener(\"keydown\", onKey, true);\n panel.querySelector<HTMLButtonElement>(\"#ob-close\")!.addEventListener(\"click\", close);\n panel.querySelector<HTMLButtonElement>('[data-action=\"close\"]')!.addEventListener(\"click\", close);\n panel.querySelector<HTMLButtonElement>('[data-action=\"refresh\"]')!.addEventListener(\"click\", reload);\n backdrop.addEventListener(\"mousedown\", (e) => { if (e.target === backdrop) close(); });\n\n await reload();\n}\n\nfunction escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, c => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", \"\\\"\": \""\", \"'\": \"'\" }[c]!));\n}\n", "/**\n * Calendar sidebar \u2014 Thunderbird Lightning \"Events and Tasks\" pane.\n *\n * Right-docked vertical panel showing:\n * - Day-grouped upcoming events (Today / Tomorrow / Friday Apr 24 / \u2026)\n * - Tasks list (no due date \u2014 just a checkable to-do list)\n *\n * Toggled by the View menu's \"Calendar sidebar\" checkbox. Reads from the\n * primary account's Google Calendar / Tasks via the existing OAuth token.\n * Sidebar and the full-screen calendar modal (calendar.ts) read the SAME\n * underlying data \u2014 two views onto one source.\n *\n * All storage goes through the service-side two-way cache (calendar_events\n * and tasks tables); this file does not use localStorage for data.\n */\n\nimport {\n getPrimaryAccount,\n getCalendarEvents, getCalendars, createCalendarEvent,\n getTasks, createTask, updateTask, deleteTask,\n reauthGoogleScopes,\n getSettings, saveSettings,\n} from \"../lib/api-client.js\";\nimport { showContextMenu } from \"./context-menu.js\";\n\nconst SIDEBAR_PREF = \"mailx-calendar-sidebar-on\";\nconst SHOW_RECURRING_PREF = \"mailx-cal-show-recurring\";\nconst SHOW_DONE_PREF = \"mailx-task-show-done\";\nconst HORIZON_DAYS_PREF = \"mailx-cal-horizon-days\";\nconst HORIZON_DEFAULT_DAYS = 30;\n\ninterface CalEvent {\n id: string;\n title: string;\n start: number; // ms since epoch\n end: number;\n allDay?: boolean;\n location?: string;\n notes?: string;\n source?: \"local\" | \"google\";\n recurringEventId?: string | null;\n htmlLink?: string | null;\n isHoliday?: boolean;\n /** Google calendar id this row was fetched from. Distinguishes\n * holiday sources (en.usa#holiday vs en.jewish#holiday) so the\n * sidebar can color them differently. */\n calendarId?: string;\n}\n\n/** Cap holiday rows at this lookahead window regardless of the user's\n * overall horizon. Holidays mostly clutter a list 2 weeks out; the\n * user can always extend by opening Google Calendar directly.\n *\n * Recurring events deliberately DON'T get this cap any more \u2014 a weekly\n * meeting whose next occurrence is on day 16 would otherwise vanish\n * from a 30-day-horizon view, which surprised users. Recurring follows\n * the overall horizon. */\nconst HOLIDAY_HORIZON_MS = 14 * 86400_000;\n\nlet viewYear = new Date().getFullYear();\nlet viewMonth = new Date().getMonth();\nlet viewDay = new Date().getDate();\nlet lastEvents: CalEvent[] = [];\n/** Overdue tasks (due before today, not completed) \u2014 pinned to the top of\n * the calendar list. Cached so renderEvents() can be re-run on a calendar\n * visibility toggle without re-fetching tasks. */\nlet lastOverdueTasks: Array<{ uuid: string; title: string; dueMs?: number }> = [];\n\n// \u2500\u2500 Per-calendar metadata \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// The user curates which calendars exist by selecting them in Google\n// Calendar; mailx enumerates them via getCalendars() and renders one\n// checkbox + icon per calendar. Holiday calendars are no longer hard-coded.\n\ntype CalKind = \"personal\" | \"usHoliday\" | \"jewishHoliday\" | \"birthday\" | \"otherHoliday\" | \"other\";\n\ninterface CalInfo {\n id: string;\n name: string;\n color: string;\n primary: boolean;\n}\n\n/** Selected Google calendars, by id. The primary calendar is also keyed\n * under the literal \"primary\" so legacy/stale rows (whose `calendar_id`\n * predates per-calendar tagging) still resolve. */\nconst calById = new Map<string, CalInfo>();\nlet calendarList: CalInfo[] = [];\n/** Calendar IDs the user has hidden in mailx (still selected in Google \u2014\n * hidden is a display filter, not a fetch filter). Persisted in\n * settings.calendar.hiddenCalendars. */\nlet hiddenCalendars = new Set<string>();\n\n/** Classify a calendar by its Google id. Birthdays live on Google's\n * auto `addressbook#contacts` calendar; holiday calendars carry\n * `#holiday@group.v.calendar.google.com`. The personal calendar is the\n * `primary` one. Everything else is a generic named calendar. */\nfunction calendarKind(id: string, primary: boolean): CalKind {\n if (primary) return \"personal\";\n const c = (id || \"\").toLowerCase();\n if (c.includes(\"addressbook#contacts\")) return \"birthday\";\n if (c.includes(\"#holiday@\") || c.includes(\"holiday@group\")) {\n if (c.includes(\"usa\")) return \"usHoliday\";\n if (c.includes(\"judaism\") || c.includes(\"jewish\")) return \"jewishHoliday\";\n return \"otherHoliday\";\n }\n return \"other\";\n}\n\n/** Icon markup for a calendar: blue dot = personal, themed emoji for\n * holiday/birthday calendars, monogram badge (first letter in a circle\n * tinted with the Google calendar color) for any other named calendar. */\nfunction calIconHtml(info: CalInfo): string {\n const kind = calendarKind(info.id, info.primary);\n const t = escapeHtml(info.name);\n if (kind === \"personal\") return `<span class=\"cal-ico cal-ico-dot\" title=\"${t}\"></span>`;\n if (kind === \"usHoliday\") return `<span class=\"cal-ico cal-ico-emoji\" title=\"${t}\">\uD83C\uDDFA\uD83C\uDDF8</span>`;\n if (kind === \"jewishHoliday\") return `<span class=\"cal-ico cal-ico-emoji\" title=\"${t}\">\u2721\uFE0F</span>`;\n if (kind === \"birthday\") return `<span class=\"cal-ico cal-ico-emoji\" title=\"${t}\">\uD83C\uDF82</span>`;\n if (kind === \"otherHoliday\") return `<span class=\"cal-ico cal-ico-emoji\" title=\"${t}\">\u2726</span>`;\n const letter = escapeHtml((info.name.trim()[0] || \"?\").toUpperCase());\n const color = info.color || \"#7a7a7a\";\n return `<span class=\"cal-ico cal-ico-mono\" style=\"background:${escapeHtml(color)}\" title=\"${t}\">${letter}</span>`;\n}\n\n/** Resolve an event's `calendarId` to a CalInfo. Falls back to a\n * synthesised entry (pattern-based kind, generic monogram) when the id\n * isn't in the enumerated list \u2014 e.g. a stale row, or before\n * getCalendars() has resolved. */\nfunction calInfoFor(calendarId: string | undefined): CalInfo {\n const id = calendarId || \"primary\";\n const found = calById.get(id);\n if (found) return found;\n return { id, name: (id.split(\"@\")[0] || id), color: \"\", primary: id === \"primary\" };\n}\n\nfunction isCalHidden(calendarId: string | undefined): boolean {\n return hiddenCalendars.has(calInfoFor(calendarId).id);\n}\n\nasync function loadHiddenCalendars(): Promise<void> {\n try {\n const s: any = await getSettings();\n const arr = s?.calendar?.hiddenCalendars;\n hiddenCalendars = new Set(Array.isArray(arr) ? arr : []);\n } catch { hiddenCalendars = new Set(); }\n}\n\nasync function saveHiddenCalendars(): Promise<void> {\n try {\n const s: any = await getSettings();\n s.calendar = { ...(s.calendar || {}), hiddenCalendars: [...hiddenCalendars] };\n await saveSettings(s);\n } catch (e: any) { console.error(\"[cal] save hiddenCalendars failed:\", e); }\n}\n\n/** Pull the user's selected Google calendars and render the per-calendar\n * checkbox list. Each row toggles `hiddenCalendars` (a display filter \u2014\n * instant, no Google round-trip, no re-fetch). Called on init and on\n * manual refresh; cheap enough but not run on every nav click. */\nasync function renderCalendarList(): Promise<void> {\n const host = document.getElementById(\"cal-side-calendars\");\n let list: CalInfo[] = [];\n try {\n list = await getCalendars() as CalInfo[];\n } catch { /* offline / no calendar scope \u2014 leave the list empty */ }\n calendarList = list;\n calById.clear();\n for (const c of list) {\n calById.set(c.id, c);\n if (c.primary) calById.set(\"primary\", c);\n }\n if (host) {\n if (list.length === 0) {\n host.innerHTML = \"\";\n } else {\n // Personal first, then alphabetical.\n const sorted = [...list].sort((a, b) =>\n (a.primary ? 0 : 1) - (b.primary ? 0 : 1) || a.name.localeCompare(b.name));\n host.innerHTML = sorted.map(c => `<label class=\"cal-side-cal-row\" title=\"${escapeHtml(c.name)}\">\n <input type=\"checkbox\" class=\"cal-side-cal-check\" data-cal-id=\"${escapeHtml(c.id)}\" ${hiddenCalendars.has(c.id) ? \"\" : \"checked\"}>\n ${calIconHtml(c)}\n <span class=\"cal-side-cal-name\">${escapeHtml(c.name)}</span>\n </label>`).join(\"\");\n host.querySelectorAll<HTMLInputElement>(\".cal-side-cal-check\").forEach(cb => {\n cb.addEventListener(\"change\", async () => {\n const id = cb.dataset.calId || \"\";\n if (cb.checked) hiddenCalendars.delete(id);\n else hiddenCalendars.add(id);\n await saveHiddenCalendars();\n renderEvents(lastEvents); // re-filter instantly\n });\n });\n }\n }\n // Repaint events so rows pick up proper icons now the list is known.\n if (lastEvents.length > 0) renderEvents(lastEvents);\n}\n\n// Set of selected task UUIDs. Persists across renderTasks() calls so the\n// user keeps their selection when a re-render happens (e.g., after a\n// `tasksUpdated` event from the service). Clicking a row with no modifier\n// replaces the set; ctrl/cmd-click toggles a single row; shift-click is\n// not implemented yet (would need an \"anchor\" to compute the range).\nconst selectedTaskUuids = new Set<string>();\n\n/** Read-only snapshot of selected task UUIDs \u2014 used by app.ts to route the\n * global Delete key / trash-button click to tasks when any are selected. */\nexport function getSelectedTaskUuids(): string[] {\n return [...selectedTaskUuids];\n}\n\n/** Delete every selected task and clear the selection. Called from app.ts\n * when the user hits Delete / Ctrl+D / the trash button while tasks are\n * selected \u2014 same semantics as the per-row \u00D7 button, just batch. */\nexport async function deleteSelectedTasks(): Promise<void> {\n const uuids = [...selectedTaskUuids];\n if (uuids.length === 0) return;\n selectedTaskUuids.clear();\n for (const uuid of uuids) {\n try { await deleteTask(uuid); } catch { /* ignore individual failures */ }\n }\n await renderTasks();\n}\n\nfunction getHorizonDays(): number {\n try {\n const v = localStorage.getItem(HORIZON_DAYS_PREF);\n const n = v ? parseInt(v, 10) : NaN;\n if (Number.isFinite(n) && n > 0 && n <= 365) return n;\n } catch { /* */ }\n return HORIZON_DEFAULT_DAYS;\n}\n\nfunction getShowRecurring(): boolean {\n try { return localStorage.getItem(SHOW_RECURRING_PREF) !== \"false\"; } catch { return true; }\n}\n\n/** Fetch events from the local two-way cache; service returns local rows\n * immediately and kicks a background refresh from Google. Next render\n * (view-nav or user action) picks up the refreshed rows. No localStorage\n * \u2014 everything lives in the service-side DB so phone / desktop share\n * the same events. */\nasync function fetchUpcoming(from: Date): Promise<CalEvent[]> {\n const horizon = from.getTime() + getHorizonDays() * 86400_000;\n const rows = await getCalendarEvents(from.getTime(), horizon);\n const showRecurring = getShowRecurring();\n const filtered = showRecurring ? rows : rows.filter((r: any) => !r.recurringEventId);\n return filtered.map((r: any) => ({\n id: r.uuid,\n title: r.title,\n start: r.startMs,\n end: r.endMs,\n allDay: !!r.allDay,\n location: r.location,\n notes: r.notes,\n source: r.providerId ? \"google\" : \"local\",\n recurringEventId: r.recurringEventId,\n htmlLink: r.htmlLink,\n isHoliday: !!r.isHoliday,\n calendarId: r.calendarId,\n }));\n}\n\nfunction formatDayHeader(d: Date, today: Date, tomorrow: Date): string {\n const sameDay = (a: Date, b: Date) =>\n a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();\n if (sameDay(d, today)) return \"Today\";\n if (sameDay(d, tomorrow)) return \"Tomorrow\";\n return d.toLocaleDateString(undefined, { weekday: \"long\", month: \"long\", day: \"numeric\" });\n}\n\nfunction formatTime(e: CalEvent): string {\n if (e.allDay) return \"all day\";\n return new Date(e.start).toLocaleTimeString(undefined, { hour: \"2-digit\", minute: \"2-digit\", hour12: false });\n}\n\nfunction escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, c => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", \"\\\"\": \""\", \"'\": \"'\" }[c]!));\n}\n\nfunction renderHead(): void {\n const dateEl = document.getElementById(\"cal-side-date\");\n if (!dateEl) return;\n const d = new Date(viewYear, viewMonth, viewDay);\n dateEl.innerHTML = `<strong>${d.getDate()}</strong> ${d.toLocaleDateString(undefined, { weekday: \"short\" })} <span class=\"cal-side-date-month\">${d.toLocaleDateString(undefined, { month: \"short\", year: \"numeric\" })}</span>`;\n}\n\n/** Markup for one overdue-task row (pinned at the top of the calendar). */\nfunction overdueTaskRowHtml(t: { uuid: string; title: string; dueMs?: number }): string {\n let dueLabel = \"\";\n if (t.dueMs) {\n const d = new Date(t.dueMs);\n const sameYear = d.getFullYear() === new Date().getFullYear();\n dueLabel = sameYear ? `${d.getMonth() + 1}/${d.getDate()}` : d.toISOString().slice(0, 10);\n }\n return `<div class=\"cal-side-task cal-side-task-overdue\" data-uuid=\"${escapeHtml(t.uuid)}\">\n <input type=\"checkbox\" class=\"cal-side-overdue-check\" title=\"Mark done\">\n <span class=\"cal-side-task-title\">${escapeHtml(t.title)}</span>\n <span class=\"cal-side-task-due overdue\">${escapeHtml(dueLabel)}</span>\n </div>`;\n}\n\nfunction renderEvents(events: CalEvent[]): void {\n const body = document.getElementById(\"cal-side-body\");\n if (!body) return;\n // Drop events from calendars the user has hidden in mailx.\n events = events.filter(e => !isCalHidden(e.calendarId));\n const overdue = lastOverdueTasks;\n if (events.length === 0 && overdue.length === 0) {\n body.innerHTML = `<div class=\"cal-side-empty\">No upcoming events. Click + New event to add one.</div>`;\n return;\n }\n const today = new Date(); today.setHours(0, 0, 0, 0);\n const tomorrow = new Date(today.getTime() + 86400_000);\n\n // Daily-recurring detection: group expanded instances by recurringEventId,\n // pull out series that fire (roughly) every day at the same time of day,\n // and render them once at the top instead of cluttering each per-day\n // section. \"Daily\" = same recurringEventId + same H:MM + appears on at\n // least 80% of the days in the rendered horizon (allows for the\n // occasional skipped day without losing the grouping).\n const horizonDays = getHorizonDays();\n const dailyThreshold = Math.max(2, Math.floor(horizonDays * 0.8));\n const byRecurId = new Map<string, CalEvent[]>();\n for (const e of events) {\n if (!e.recurringEventId) continue;\n let arr = byRecurId.get(e.recurringEventId);\n if (!arr) { arr = []; byRecurId.set(e.recurringEventId, arr); }\n arr.push(e);\n }\n const dailyKeys = new Set<string>();\n const dailyHeads: CalEvent[] = [];\n for (const [recId, instances] of byRecurId) {\n if (instances.length < dailyThreshold) continue;\n // Same time-of-day across all instances?\n const hm = (e: CalEvent) => {\n if (e.allDay) return \"all\";\n const d = new Date(e.start);\n return `${d.getHours()}:${d.getMinutes()}`;\n };\n const firstHm = hm(instances[0]);\n const allSameTime = instances.every(e => hm(e) === firstHm);\n if (!allSameTime) continue;\n dailyKeys.add(recId);\n // Use the earliest instance as the \"head\" entry (its htmlLink is\n // identical across instances of the same series).\n dailyHeads.push(instances.reduce((a, b) => a.start < b.start ? a : b));\n }\n\n let html = \"\";\n\n // Overdue tasks pinned to the very top \u2014 a persistent \"behind on these\"\n // block. Marking one done (the checkbox) completes it in Google and it\n // drops off on the next render.\n if (overdue.length > 0) {\n html += `<div class=\"cal-side-day cal-side-day-overdue\">Overdue tasks</div>`;\n for (const t of overdue) html += overdueTaskRowHtml(t);\n }\n\n if (dailyHeads.length > 0) {\n html += `<div class=\"cal-side-day cal-side-day-daily\">Daily</div>`;\n for (const e of dailyHeads) {\n const link = e.htmlLink || \"\";\n html += `<div class=\"cal-side-event\" data-id=\"${e.id}\" data-link=\"${escapeHtml(link)}\" ${link ? 'title=\"Click to open in Google Calendar\"' : \"\"}>\n ${calIconHtml(calInfoFor(e.calendarId))}\n <span class=\"cal-side-event-time\">${escapeHtml(formatTime(e))}</span>\n <span class=\"cal-side-event-title\">${escapeHtml(e.title)}<span class=\"cal-side-event-recur\" title=\"Daily\">\u21BB</span></span>\n </div>`;\n }\n }\n\n let lastDayKey = \"\";\n const nowMs = Date.now();\n const holidayCutoff = nowMs + HOLIDAY_HORIZON_MS;\n for (const e of events) {\n // Filter out daily-grouped instances \u2014 they're shown once at top.\n if (e.recurringEventId && dailyKeys.has(e.recurringEventId)) continue;\n const info = calInfoFor(e.calendarId);\n const kind = calendarKind(info.id, info.primary);\n const isHolidayKind = kind === \"usHoliday\" || kind === \"jewishHoliday\" || kind === \"otherHoliday\";\n // Holiday calendars get the 2-week cap \u2014 they otherwise clutter a\n // long horizon. Birthdays and recurring events follow the user's\n // overall horizon.\n if (isHolidayKind && e.start > holidayCutoff) continue;\n const d = new Date(e.start);\n const dayKey = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;\n if (dayKey !== lastDayKey) {\n html += `<div class=\"cal-side-day\">${escapeHtml(formatDayHeader(d, today, tomorrow))}</div>`;\n lastDayKey = dayKey;\n }\n const recurMark = e.recurringEventId ? `<span class=\"cal-side-event-recur\" title=\"Recurring event\">\u21BB</span>` : \"\";\n const link = e.htmlLink || \"\";\n const recurAttr = e.recurringEventId ? ' data-recurring=\"1\"' : \"\";\n // Holiday + birthday calendars render as a single full-width row\n // (all-day, no useful clock time) led by the calendar's icon.\n // Regular events keep the icon/time/title columns and the\n // click-to-open-in-Google deep link.\n if (isHolidayKind || kind === \"birthday\") {\n html += `<div class=\"cal-side-event\" data-holiday=\"1\" data-holiday-kind=\"${kind}\" data-id=\"${e.id}\">\n <span class=\"cal-side-event-title cal-side-event-holiday-title\">${calIconHtml(info)} ${escapeHtml(e.title)}</span>\n </div>`;\n } else {\n const titleAttr = link ? 'title=\"Click to open in Google Calendar\"' : \"\";\n html += `<div class=\"cal-side-event\" data-id=\"${e.id}\"${recurAttr} data-link=\"${escapeHtml(link)}\" ${titleAttr}>\n ${calIconHtml(info)}\n <span class=\"cal-side-event-time\">${escapeHtml(formatTime(e))}</span>\n <span class=\"cal-side-event-title\">${escapeHtml(e.title)}${recurMark}</span>\n </div>`;\n }\n }\n body.innerHTML = html;\n\n // Overdue-task checkboxes \u2014 mark the task completed in Google (non-\n // destructive; it survives in the Completed section) and re-render.\n body.querySelectorAll<HTMLElement>(\".cal-side-task-overdue\").forEach(row => {\n const uuid = row.dataset.uuid!;\n row.querySelector<HTMLInputElement>(\".cal-side-overdue-check\")?.addEventListener(\"change\", async () => {\n await updateTask(uuid, { completedMs: Date.now() });\n lastOverdueTasks = lastOverdueTasks.filter(t => t.uuid !== uuid);\n renderEvents(lastEvents);\n renderTasks();\n });\n });\n\n // Click-to-open \u2014 interim per user 2026-04-23: route to Google Calendar's\n // web UI via openExternal until we build an in-app event editor.\n // Right-click gives a context menu with \"View in browser\" explicit.\n // Item 17: on Android, openCalendarEvent redirects to the native Calendar\n // app via ACTION_VIEW; desktop still falls through to the browser.\n const openInCalendar = (url: string): void => {\n const api = (window as any).mailxapi;\n if (api?.openCalendarEvent) { api.openCalendarEvent(url); return; }\n if (api?.openExternal) api.openExternal(url);\n else window.open(url, \"_blank\");\n };\n const openInBrowser = (url: string): void => {\n const api = (window as any).mailxapi;\n if (api?.openExternal) api.openExternal(url);\n else window.open(url, \"_blank\");\n };\n body.querySelectorAll<HTMLElement>(\".cal-side-event\").forEach(el => {\n el.addEventListener(\"click\", () => {\n const link = el.dataset.link;\n if (link) openInCalendar(link);\n });\n el.addEventListener(\"contextmenu\", (e) => {\n e.preventDefault();\n const link = el.dataset.link;\n const items = [];\n if (link) items.push({\n label: \"View in browser\",\n action: () => openInBrowser(link),\n });\n items.push({\n label: \"Open Google Calendar\",\n action: () => openInBrowser(\"https://calendar.google.com/\"),\n });\n if (items.length > 0) showContextMenu(e.clientX, e.clientY, items);\n });\n });\n}\n\nasync function renderTasks(prefetched?: any[]): Promise<void> {\n const cb = document.getElementById(\"cal-side-show-done\") as HTMLInputElement | null;\n const showDone = cb?.checked ?? false;\n // Reuse the list refresh() already fetched (avoids a duplicate getTasks\n // IPC + Google pull); fall back to fetching when called standalone.\n const tasks = prefetched ?? await getTasks(showDone);\n const host = document.getElementById(\"cal-side-tasks\");\n if (!host) return;\n if (tasks.length === 0) {\n host.innerHTML = `<div class=\"cal-side-empty\">No tasks.</div>`;\n return;\n }\n let html = \"<div class='cal-side-task-head'>Title</div>\";\n const startOfToday = new Date(); startOfToday.setHours(0, 0, 0, 0);\n for (const t of tasks) {\n const done = !!t.completedMs;\n // Due date column: short M/D if same year, else YYYY-MM-DD; overdue\n // (date < today, not completed) gets a distinct color via the\n // .overdue class. No due date \u2192 empty span keeps the grid aligned.\n let dueHtml = \"\";\n if (t.dueMs) {\n const d = new Date(t.dueMs);\n const sameYear = d.getFullYear() === startOfToday.getFullYear();\n const label = sameYear\n ? `${d.getMonth() + 1}/${d.getDate()}`\n : d.toISOString().slice(0, 10);\n const overdue = !done && d < startOfToday;\n dueHtml = `<span class=\"cal-side-task-due${overdue ? \" overdue\" : \"\"}\" title=\"${d.toLocaleDateString()}\">${label}</span>`;\n } else {\n dueHtml = `<span class=\"cal-side-task-due\"></span>`;\n }\n const sel = selectedTaskUuids.has(t.uuid) ? \" selected\" : \"\";\n html += `<div class=\"cal-side-task${sel}\" data-uuid=\"${t.uuid}\">\n <input type=\"checkbox\" ${done ? \"checked\" : \"\"} class=\"cal-side-task-check\">\n <span class=\"cal-side-task-title${done ? \" done\" : \"\"}\">${escapeHtml(t.title)}</span>\n ${dueHtml}\n <button class=\"cal-side-task-delete\" title=\"Delete task\" aria-label=\"Delete task\">\u00D7</button>\n </div>`;\n }\n // Drop selection entries for tasks that no longer exist (deleted /\n // synced-out from another device) so the set doesn't grow unbounded.\n const liveUuids = new Set(tasks.map(t => t.uuid));\n for (const uuid of selectedTaskUuids) {\n if (!liveUuids.has(uuid)) selectedTaskUuids.delete(uuid);\n }\n host.innerHTML = html;\n const openInBrowser = (url: string): void => {\n const api = (window as any).mailxapi;\n if (api?.openExternal) api.openExternal(url);\n else window.open(url, \"_blank\");\n };\n host.querySelectorAll<HTMLElement>(\".cal-side-task\").forEach(row => {\n const uuid = row.dataset.uuid!;\n row.querySelector<HTMLInputElement>(\".cal-side-task-check\")?.addEventListener(\"change\", async (e) => {\n const checked = (e.target as HTMLInputElement).checked;\n await updateTask(uuid, { completedMs: checked ? Date.now() : null });\n renderTasks();\n });\n row.querySelector<HTMLButtonElement>(\".cal-side-task-delete\")?.addEventListener(\"click\", async (e) => {\n e.stopPropagation(); // don't also trigger the row's selection click\n await deleteTask(uuid);\n selectedTaskUuids.delete(uuid);\n renderTasks();\n });\n // Click on the row body selects it. Plain click replaces the set;\n // ctrl/cmd toggles. Clicks that originated on the checkbox or the\n // \u00D7 button were stopped above, so they don't bubble to here.\n row.addEventListener(\"click\", (e) => {\n const t = e.target as HTMLElement;\n if (t.closest(\".cal-side-task-check\") || t.closest(\".cal-side-task-delete\")) return;\n if (e.ctrlKey || e.metaKey) {\n if (selectedTaskUuids.has(uuid)) selectedTaskUuids.delete(uuid);\n else selectedTaskUuids.add(uuid);\n } else {\n selectedTaskUuids.clear();\n selectedTaskUuids.add(uuid);\n }\n // Re-render to update selected styling. Cheap (DOM is small).\n renderTasks();\n });\n row.addEventListener(\"contextmenu\", (e) => {\n e.preventDefault();\n // Google Tasks doesn't have a per-task web URL; open the list view.\n showContextMenu(e.clientX, e.clientY, [\n { label: \"View in Google Tasks\", action: () => openInBrowser(\"https://tasks.google.com/\") },\n { label: \"\", action: () => {}, separator: true },\n { label: \"Delete task\", action: async () => { await deleteTask(uuid); renderTasks(); } },\n ]);\n });\n });\n}\n\nasync function refresh(): Promise<void> {\n renderHead();\n const from = new Date(viewYear, viewMonth, viewDay);\n // A throw here (IPC reject, daemon not ready) must NOT leave the\n // sidebar stuck on its initial \"Loading\u2026\" placeholder \u2014 that was the\n // \"calendar shows nothing\" symptom. Render an explicit state either\n // way: events on success, an error line on failure.\n // Tasks are fetched ONCE here and handed to renderTasks() so the events\n // pane and the task pane don't each fire their own getTasks() \u2014 that\n // doubled the Google Tasks API call rate and helped trip the 429 storm.\n let prefetchedTasks: any[] | undefined;\n try {\n const startOfToday = new Date(); startOfToday.setHours(0, 0, 0, 0);\n const showDone = (document.getElementById(\"cal-side-show-done\") as HTMLInputElement | null)?.checked ?? false;\n const [events, tasks] = await Promise.all([\n fetchUpcoming(from),\n getTasks(showDone).catch(() => [] as any[]),\n ]);\n lastEvents = events;\n prefetchedTasks = tasks as any[];\n // Overdue tasks (due before today, not completed) pin to the top of\n // the calendar list.\n lastOverdueTasks = prefetchedTasks\n .filter(t => t.dueMs && !t.completedMs && t.dueMs < startOfToday.getTime())\n .map(t => ({ uuid: t.uuid, title: t.title, dueMs: t.dueMs }));\n renderEvents(lastEvents);\n } catch (e: any) {\n const body = document.getElementById(\"cal-side-body\");\n if (body) body.innerHTML = `<div class=\"cal-side-empty cal-side-quota-error\">Couldn't load calendar: ${escapeHtml(e?.message || String(e))}</div>`;\n }\n renderTasks(prefetchedTasks);\n}\n\n/** Open the natural-language new-event modal. The user types a free-form\n * description (\"Lunch with John tomorrow at noon at Joe's\"); we send it\n * to aiTransform with action=extractEvent, get back structured fields\n * (title/start/end/location/notes), and open Google Calendar's\n * create-event URL with those fields pre-filled. The user reviews / edits\n * / saves in Google's UI \u2014 that path keeps mailx out of the\n * event-editor business and gets all the niceties (recurrence, guests,\n * conferencing) for free. If AI is disabled or extraction fails, the raw\n * text becomes the title and Google's own quick-add parser takes over. */\nfunction openNewEventDialog(): void {\n const backdrop = document.createElement(\"div\");\n backdrop.className = \"mailx-modal-backdrop\";\n backdrop.style.cssText = \"position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;\";\n\n const panel = document.createElement(\"div\");\n panel.style.cssText = \"background:var(--color-bg, #fff);color:var(--color-text, #000);border-radius:8px;box-shadow:0 8px 32px rgba(0,0,0,0.3);width:min(560px,90vw);max-height:80vh;display:flex;flex-direction:column;padding:18px;gap:10px;\";\n\n const heading = document.createElement(\"div\");\n heading.style.cssText = \"font-weight:600;font-size:1.05em;\";\n heading.textContent = \"New event\";\n\n const hint = document.createElement(\"div\");\n hint.style.cssText = \"font-size:0.9em;color:var(--color-text-muted);\";\n hint.textContent = \"Describe the event in plain English \u2014 AI extracts the details and opens Google Calendar to confirm.\";\n\n const ta = document.createElement(\"textarea\");\n ta.style.cssText = \"width:100%;min-height:120px;font:inherit;padding:8px;border-radius:4px;border:1px solid var(--color-border, #ccc);box-sizing:border-box;resize:vertical;\";\n ta.placeholder = \"Lunch with John tomorrow at noon at Joe's Pizza\";\n\n const status = document.createElement(\"div\");\n status.style.cssText = \"min-height:1.2em;font-size:0.85em;color:var(--color-text-muted);\";\n\n const btnRow = document.createElement(\"div\");\n btnRow.style.cssText = \"display:flex;gap:8px;justify-content:flex-end;\";\n\n const cancelBtn = document.createElement(\"button\");\n cancelBtn.textContent = \"Cancel\";\n cancelBtn.style.cssText = \"padding:6px 14px;\";\n\n const createBtn = document.createElement(\"button\");\n createBtn.textContent = \"Create\";\n createBtn.style.cssText = \"padding:6px 14px;font-weight:500;\";\n\n btnRow.append(cancelBtn, createBtn);\n panel.append(heading, hint, ta, status, btnRow);\n backdrop.append(panel);\n document.body.append(backdrop);\n setTimeout(() => ta.focus(), 0);\n\n const close = () => backdrop.remove();\n cancelBtn.addEventListener(\"click\", close);\n backdrop.addEventListener(\"click\", (e) => { if (e.target === backdrop) close(); });\n document.addEventListener(\"keydown\", function escClose(e) {\n if (e.key === \"Escape\") { close(); document.removeEventListener(\"keydown\", escClose); }\n });\n\n // Submit on Ctrl/Cmd+Enter as well as button click.\n ta.addEventListener(\"keydown\", (e) => {\n if ((e.ctrlKey || e.metaKey) && e.key === \"Enter\") { e.preventDefault(); createBtn.click(); }\n });\n\n createBtn.addEventListener(\"click\", async () => {\n const text = ta.value.trim();\n if (!text) { ta.focus(); return; }\n createBtn.disabled = true;\n cancelBtn.disabled = true;\n status.textContent = \"Parsing\u2026\";\n\n let event: any = null;\n try {\n const { aiTransform } = await import(\"../lib/api-client.js\");\n const r = await aiTransform({\n action: \"extractEvent\",\n text,\n nowISO: new Date().toISOString(),\n });\n event = r?.event || null;\n if (!event && r?.reason) status.textContent = `AI: ${r.reason} \u2014 using raw text as title.`;\n } catch (e: any) {\n status.textContent = `AI call failed: ${e.message} \u2014 using raw text as title.`;\n }\n\n // Build Google Calendar's create-event URL. Always opens in browser\n // for the user to confirm/edit/save. We never write to the local\n // store from this path \u2014 the round-trip back via Google Calendar\n // sync keeps state consistent and avoids duplicate events.\n const url = buildGoogleEventUrl(event, text);\n const api = (window as any).mailxapi;\n if (api?.openExternal) api.openExternal(url);\n else window.open(url, \"_blank\");\n close();\n });\n}\n\n/** Compose Google Calendar's \"render?action=TEMPLATE\" URL with extracted\n * fields. When `event` is null we still produce a useful URL \u2014 the raw\n * description goes into `text=` (becomes the title) and Google's UI lets\n * the user fill in dates manually. Times are emitted in compact form\n * without `Z` so Google interprets them in the user's calendar timezone. */\nfunction buildGoogleEventUrl(event: any, fallbackText: string): string {\n const base = \"https://calendar.google.com/calendar/render?action=TEMPLATE\";\n const params: string[] = [];\n const fmt = (iso: string): string => {\n // ISO \"2026-05-06T12:00:00\" \u2192 \"20260506T120000\". Google accepts\n // local-naive times in this form.\n const m = iso.match(/(\\d{4})-(\\d{2})-(\\d{2})(?:T(\\d{2}):(\\d{2})(?::(\\d{2}))?)?/);\n if (!m) return \"\";\n const [, y, mo, d, hh, mm, ss] = m;\n const date = `${y}${mo}${d}`;\n if (!hh) return date; // all-day form: dates=YYYYMMDD/YYYYMMDD\n return `${date}T${hh}${mm}${ss || \"00\"}`;\n };\n\n if (event?.title) params.push(`text=${encodeURIComponent(event.title)}`);\n else params.push(`text=${encodeURIComponent(fallbackText.slice(0, 200))}`);\n\n if (event?.startISO) {\n const startFmt = fmt(event.startISO);\n // For all-day events Google wants `dates=YYYYMMDD/YYYYMMDD` where\n // the end date is exclusive (next day). For timed events it's\n // `YYYYMMDDTHHMMSS/YYYYMMDDTHHMMSS`. Default end = +1h timed,\n // +1 day all-day.\n if (event.allDay) {\n const startDate = new Date(event.startISO + (event.startISO.includes(\"T\") ? \"\" : \"T00:00:00\"));\n const endDate = new Date(startDate.getTime() + 86400_000);\n const endFmt = `${endDate.getFullYear()}${String(endDate.getMonth() + 1).padStart(2, \"0\")}${String(endDate.getDate()).padStart(2, \"0\")}`;\n params.push(`dates=${startFmt}/${endFmt}`);\n } else {\n const endFmt = event.endISO\n ? fmt(event.endISO)\n : fmt(new Date(new Date(event.startISO).getTime() + 3600_000).toISOString().slice(0, 19));\n params.push(`dates=${startFmt}/${endFmt}`);\n }\n }\n if (event?.location) params.push(`location=${encodeURIComponent(event.location)}`);\n if (event?.notes) params.push(`details=${encodeURIComponent(event.notes)}`);\n return `${base}&${params.join(\"&\")}`;\n}\n\n/** Show the sidebar (called from the View menu toggle). Idempotent. */\nexport async function showCalendarSidebar(): Promise<void> {\n const el = document.getElementById(\"calendar-sidebar\");\n if (!el) return;\n el.hidden = false;\n document.body.classList.add(\"calendar-sidebar-on\");\n try { localStorage.setItem(SIDEBAR_PREF, \"true\"); } catch { /* */ }\n await loadHiddenCalendars();\n void renderCalendarList(); // async \u2014 repaints events when it resolves\n await refresh();\n}\n\nexport function hideCalendarSidebar(): void {\n const el = document.getElementById(\"calendar-sidebar\");\n if (!el) return;\n el.hidden = true;\n document.body.classList.remove(\"calendar-sidebar-on\");\n try { localStorage.setItem(SIDEBAR_PREF, \"false\"); } catch { /* */ }\n}\n\nexport function isCalendarSidebarOn(): boolean {\n // Default is ON \u2014 user-reported 2026-04-23 that the sidebar should be\n // visible by default, not hidden. An explicit \"false\" in localStorage\n // still hides it (the user's stored preference wins).\n try {\n const v = localStorage.getItem(SIDEBAR_PREF);\n return v === null ? true : v !== \"false\";\n } catch { return true; }\n}\n\n/** Wire one-time event handlers + restore from localStorage. Safe to call\n * multiple times \u2014 handlers are idempotent because the elements are stable. */\nexport function initCalendarSidebar(): void {\n const wireOnce = (id: string, fn: () => void) => {\n const el = document.getElementById(id);\n if (!el || (el as any).__wired) return;\n (el as any).__wired = true;\n el.addEventListener(\"click\", fn);\n };\n wireOnce(\"cal-side-prev\", () => { const d = new Date(viewYear, viewMonth, viewDay - 1); viewYear = d.getFullYear(); viewMonth = d.getMonth(); viewDay = d.getDate(); refresh(); });\n wireOnce(\"cal-side-next\", () => { const d = new Date(viewYear, viewMonth, viewDay + 1); viewYear = d.getFullYear(); viewMonth = d.getMonth(); viewDay = d.getDate(); refresh(); });\n wireOnce(\"cal-side-today\", () => { const t = new Date(); viewYear = t.getFullYear(); viewMonth = t.getMonth(); viewDay = t.getDate(); refresh(); });\n wireOnce(\"cal-side-new\", () => { openNewEventDialog(); });\n wireOnce(\"cal-side-new-task\", async () => {\n const title = prompt(\"Task title:\");\n if (!title) return;\n await createTask({ title });\n renderTasks();\n });\n // Manual refresh \u2014 getTasks on the service side already fires a Google\n // pull under the hood on every call, but the visible feedback (spinning\n // glyph for ~600 ms, disabled while in flight) makes it explicit that\n // the user asked for a fresh pull, not a cached redraw.\n wireOnce(\"cal-side-refresh-tasks\", async () => {\n const btn = document.getElementById(\"cal-side-refresh-tasks\") as HTMLButtonElement | null;\n if (btn?.classList.contains(\"cal-side-refreshing\")) return;\n btn?.classList.add(\"cal-side-refreshing\");\n (btn as HTMLButtonElement).disabled = true;\n try {\n // renderTasks() calls getTasks() on the service which triggers\n // the async Google pull; the subsequent tasksUpdated event\n // re-renders with the merged result.\n await renderTasks();\n } finally {\n setTimeout(() => {\n btn?.classList.remove(\"cal-side-refreshing\");\n if (btn) btn.disabled = false;\n }, 600);\n }\n });\n // Manual events refresh \u2014 same pattern as tasks. fetchUpcoming hits the\n // service which kicks a Google pull under the hood.\n wireOnce(\"cal-side-refresh-events\", async () => {\n const btn = document.getElementById(\"cal-side-refresh-events\") as HTMLButtonElement | null;\n if (btn?.classList.contains(\"cal-side-refreshing\")) return;\n btn?.classList.add(\"cal-side-refreshing\");\n if (btn) btn.disabled = true;\n try {\n // Also re-enumerate calendars \u2014 the user may have selected /\n // deselected one in Google since the sidebar opened.\n await Promise.all([renderCalendarList(), refresh()]);\n } finally {\n setTimeout(() => {\n btn?.classList.remove(\"cal-side-refreshing\");\n if (btn) btn.disabled = false;\n }, 600);\n }\n });\n const showDoneCb = document.getElementById(\"cal-side-show-done\") as HTMLInputElement | null;\n if (showDoneCb && !(showDoneCb as any).__wired) {\n (showDoneCb as any).__wired = true;\n // Sticky: restore prior state and persist on change. Default off.\n try { showDoneCb.checked = localStorage.getItem(SHOW_DONE_PREF) === \"true\"; } catch { /* */ }\n showDoneCb.addEventListener(\"change\", () => {\n try { localStorage.setItem(SHOW_DONE_PREF, String(showDoneCb.checked)); } catch { /* */ }\n renderTasks();\n });\n }\n // Recurring-events filter toggle \u2014 hides expanded recurring-series\n // instances when unchecked. Default on so new users see everything.\n const recurCb = document.getElementById(\"cal-side-show-recurring\") as HTMLInputElement | null;\n if (recurCb && !(recurCb as any).__wired) {\n (recurCb as any).__wired = true;\n recurCb.checked = getShowRecurring();\n recurCb.addEventListener(\"change\", () => {\n try { localStorage.setItem(SHOW_RECURRING_PREF, String(recurCb.checked)); } catch { /* */ }\n refresh();\n });\n }\n // Per-calendar checkboxes are generated dynamically from the user's\n // selected Google calendars \u2014 see renderCalendarList(), driven from\n // showCalendarSidebar() + the manual refresh button. No hard-coded\n // holiday toggles any more.\n // Horizon input \u2014 how many days ahead to list events. Bounded 1..365.\n const horizonInput = document.getElementById(\"cal-side-horizon\") as HTMLInputElement | null;\n if (horizonInput && !(horizonInput as any).__wired) {\n (horizonInput as any).__wired = true;\n horizonInput.value = String(getHorizonDays());\n const commit = () => {\n const n = parseInt(horizonInput.value, 10);\n const clamped = Number.isFinite(n) && n > 0 ? Math.min(365, Math.max(1, n)) : HORIZON_DEFAULT_DAYS;\n horizonInput.value = String(clamped);\n try { localStorage.setItem(HORIZON_DAYS_PREF, String(clamped)); } catch { /* */ }\n refresh();\n };\n horizonInput.addEventListener(\"change\", commit);\n horizonInput.addEventListener(\"blur\", commit);\n }\n // Subscribe to service events \u2014 when a background Google pull finishes\n // and upserted/reconciled rows, re-render without waiting for a nav\n // click. Uses the mailxapi.onEvent bus. Idempotent flag keeps multiple\n // initCalendarSidebar calls from dup-subscribing.\n if (!(window as any).__mailxCalEventsWired) {\n const api = (window as any).mailxapi;\n if (api?.onEvent) {\n (window as any).__mailxCalEventsWired = true;\n api.onEvent((event: any) => {\n if (event?.type === \"calendarUpdated\") refresh();\n else if (event?.type === \"tasksUpdated\") renderTasks();\n else if (event?.type === \"quotaError\") {\n // Surface a non-clickable banner \u2014 daily quota will reset\n // on its own, no user action helps. Idempotent so the\n // banner doesn't flash on repeat poll attempts.\n const host = event.feature === \"tasks\"\n ? document.getElementById(\"cal-side-tasks\")\n : document.getElementById(\"cal-side-body\");\n if (host && !host.querySelector(\".cal-side-quota-error\")) {\n const msg = event.message || `Google ${event.feature} quota exceeded \u2014 try again later.`;\n host.innerHTML = `<div class=\"cal-side-empty cal-side-quota-error\">${escapeHtml(msg)}</div>`;\n }\n }\n else if (event?.type === \"authScopeError\") {\n // Surface a visible hint right in the affected pane so the\n // user doesn't stare at an empty list wondering why. Only\n // writes to the matching pane; other panes keep rendering.\n //\n // Idempotent: if the banner is already shown for this\n // feature (class `.cal-side-auth-error` present), don't\n // re-write the DOM \u2014 stops the \"flashing on and off\n // continually\" effect when the service re-emits on every\n // 5-min poll or sidebar-nav click.\n const host = event.feature === \"tasks\"\n ? document.getElementById(\"cal-side-tasks\")\n : document.getElementById(\"cal-side-body\");\n if (host && !host.querySelector(\".cal-side-auth-error\")) {\n const msg = event.message || \"Google access needs re-consent.\";\n host.innerHTML = `<div class=\"cal-side-empty cal-side-auth-error\">\n <div style=\"margin-bottom:0.6em\">${escapeHtml(msg)}</div>\n <button type=\"button\" class=\"cal-side-reauth-btn\" style=\"padding:0.3em 0.8em;border-radius:4px;border:1px solid currentColor;background:transparent;color:inherit;cursor:pointer;font-size:0.9em\">Re-authenticate Now</button>\n </div>`;\n const btn = host.querySelector<HTMLButtonElement>(\".cal-side-reauth-btn\");\n btn?.addEventListener(\"click\", async () => {\n btn.disabled = true;\n btn.textContent = \"Opening browser\u2026\";\n try {\n await reauthGoogleScopes();\n btn.textContent = \"Consent opened \u2014 complete it in the browser\";\n } catch (err: any) {\n btn.disabled = false;\n btn.textContent = `Failed: ${err?.message || err}. Click to retry.`;\n }\n });\n }\n }\n });\n }\n }\n}\n", "/**\n * Address book modal \u2014 list / search / edit / delete contacts.\n * Sources: 'sent' (added on outgoing), 'received' (seeded from message senders),\n * 'manual' (added directly here), 'google' (Google Contacts sync).\n */\nimport { listContacts, upsertContact, deleteContact } from \"../lib/api-client.js\";\n\ninterface Contact {\n name: string;\n email: string;\n source: string;\n useCount: number;\n lastUsed: number;\n}\n\nlet isOpen = false;\n\nexport async function openAddressBook(): Promise<void> {\n if (isOpen) return;\n isOpen = true;\n\n const backdrop = document.createElement(\"div\");\n backdrop.className = \"mailx-modal-backdrop\";\n const panel = document.createElement(\"div\");\n panel.className = \"mailx-modal mailx-modal-wide\";\n panel.innerHTML = `\n <div class=\"mailx-modal-title\">\n <span class=\"mailx-modal-title-text\">Address Book</span>\n <button type=\"button\" class=\"mailx-modal-close\" id=\"ab-close\" title=\"Close (Esc)\" aria-label=\"Close\">×</button>\n </div>\n <div class=\"ab-toolbar\">\n <input type=\"search\" id=\"ab-search\" class=\"mailx-modal-input\" placeholder=\"Search name or email\u2026\" autocomplete=\"off\">\n <button type=\"button\" class=\"mailx-modal-btn mailx-modal-btn-primary\" id=\"ab-new\">+ New contact</button>\n </div>\n <div class=\"ab-count\" id=\"ab-count\"></div>\n <div class=\"ab-list\" id=\"ab-list\">Loading\u2026</div>\n <div class=\"mailx-modal-buttons\">\n <span class=\"mailx-modal-spacer\"></span>\n <button type=\"button\" class=\"mailx-modal-btn\" data-action=\"close\">Close</button>\n </div>`;\n backdrop.appendChild(panel);\n document.body.appendChild(backdrop);\n\n const searchInput = panel.querySelector<HTMLInputElement>(\"#ab-search\")!;\n const listEl = panel.querySelector<HTMLElement>(\"#ab-list\")!;\n const countEl = panel.querySelector<HTMLElement>(\"#ab-count\")!;\n\n let editingEmail: string | null = null;\n\n const render = (items: Contact[], total: number) => {\n countEl.textContent = total === items.length\n ? `${total} contact${total === 1 ? \"\" : \"s\"}`\n : `Showing ${items.length} of ${total}`;\n if (items.length === 0) {\n listEl.innerHTML = `<div class=\"ab-empty\">No contacts match.</div>`;\n return;\n }\n const fmtDate = (ms: number) => {\n if (!ms) return \"\";\n const d = new Date(ms);\n const now = new Date();\n return d.getFullYear() === now.getFullYear()\n ? d.toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" })\n : d.toLocaleDateString();\n };\n listEl.innerHTML = `\n <div class=\"ab-row ab-header\">\n <span class=\"ab-name\">Name</span>\n <span class=\"ab-email\">Email</span>\n <span class=\"ab-source\">Source</span>\n <span class=\"ab-count-cell\" title=\"Times sent to\">\u00D7</span>\n <span class=\"ab-last\">Last</span>\n <span class=\"ab-actions\"></span>\n </div>` + items.map(c => `\n <div class=\"ab-row\" data-email=\"${escapeAttr(c.email)}\">\n <span class=\"ab-name\">${escapeHtml(c.name || \"\")}</span>\n <span class=\"ab-email\">${escapeHtml(c.email)}</span>\n <span class=\"ab-source\">${escapeHtml(c.source)}</span>\n <span class=\"ab-count-cell\">${c.useCount || 0}</span>\n <span class=\"ab-last\">${fmtDate(c.lastUsed)}</span>\n <span class=\"ab-actions\">\n <button type=\"button\" class=\"ab-edit\" title=\"Edit name\">\u270E</button>\n <button type=\"button\" class=\"ab-del\" title=\"Delete\">\uD83D\uDDD1</button>\n <button type=\"button\" class=\"ab-mail\" title=\"Compose to\">\u2709</button>\n </span>\n </div>`).join(\"\");\n listEl.querySelectorAll<HTMLElement>(\".ab-row[data-email]\").forEach(row => {\n const email = row.dataset.email!;\n const c = items.find(x => x.email === email)!;\n row.querySelector(\".ab-edit\")?.addEventListener(\"click\", () => beginEdit(row, c));\n row.querySelector(\".ab-del\")?.addEventListener(\"click\", () => doDelete(c));\n row.querySelector(\".ab-mail\")?.addEventListener(\"click\", () => composeTo(c));\n });\n };\n\n const beginEdit = (row: HTMLElement, c: Contact) => {\n if (editingEmail === c.email) return;\n editingEmail = c.email;\n const nameSpan = row.querySelector<HTMLElement>(\".ab-name\")!;\n const original = c.name || \"\";\n nameSpan.innerHTML = `<input type=\"text\" value=\"${escapeAttr(original)}\" class=\"ab-name-input\">`;\n const inp = nameSpan.querySelector<HTMLInputElement>(\"input\")!;\n inp.focus();\n inp.select();\n const commit = async () => {\n const newName = inp.value.trim();\n if (newName !== original) {\n try {\n await upsertContact(newName, c.email);\n c.name = newName;\n } catch (e: any) {\n alert(`Update failed: ${e?.message || e}`);\n }\n }\n nameSpan.textContent = c.name || \"\";\n editingEmail = null;\n };\n inp.addEventListener(\"blur\", commit);\n inp.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") { e.preventDefault(); inp.blur(); }\n else if (e.key === \"Escape\") { e.preventDefault(); inp.value = original; inp.blur(); }\n });\n };\n\n const doDelete = async (c: Contact) => {\n if (!confirm(`Delete contact \"${c.name || c.email}\"?`)) return;\n try {\n await deleteContact(c.email);\n await reload();\n } catch (e: any) {\n alert(`Delete failed: ${e?.message || e}`);\n }\n };\n\n const composeTo = (c: Contact) => {\n const init: {\n mode: string;\n to: { name: string; address: string }[];\n cc: { name: string; address: string }[];\n bcc: { name: string; address: string }[];\n subject: string;\n bodyHtml: string;\n inReplyTo: string;\n references: string[];\n accounts: { id: string; name: string; email: string }[];\n } = {\n mode: \"new\",\n to: [{ name: c.name || \"\", address: c.email }],\n cc: [], bcc: [], subject: \"\", bodyHtml: \"\",\n inReplyTo: \"\", references: [], accounts: [],\n };\n try { sessionStorage.setItem(\"composeInit\", JSON.stringify(init)); } catch { /* */ }\n document.dispatchEvent(new CustomEvent(\"mailx-compose\", { detail: { mode: \"new\" } }));\n close();\n };\n\n let reloadDebounce: number | undefined;\n const reload = async () => {\n try {\n const r = await listContacts(searchInput.value, 1, 200);\n render(r.items, r.total);\n } catch (e: any) {\n listEl.innerHTML = `<div class=\"ab-empty\">Load failed: ${escapeHtml(e?.message || String(e))}</div>`;\n }\n };\n const scheduleReload = () => {\n if (reloadDebounce) window.clearTimeout(reloadDebounce);\n reloadDebounce = window.setTimeout(reload, 200);\n };\n\n panel.querySelector<HTMLButtonElement>(\"#ab-new\")?.addEventListener(\"click\", async () => {\n const email = prompt(\"Email address:\");\n if (!email) return;\n const name = prompt(\"Display name (optional):\") || \"\";\n try {\n await upsertContact(name, email.trim());\n await reload();\n } catch (e: any) {\n alert(`Add failed: ${e?.message || e}`);\n }\n });\n searchInput.addEventListener(\"input\", scheduleReload);\n\n const close = () => {\n if (reloadDebounce) window.clearTimeout(reloadDebounce);\n backdrop.remove();\n document.removeEventListener(\"keydown\", onKey, true);\n isOpen = false;\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") { e.stopPropagation(); e.preventDefault(); close(); }\n };\n document.addEventListener(\"keydown\", onKey, true);\n panel.querySelector<HTMLButtonElement>(\"#ab-close\")!.addEventListener(\"click\", close);\n panel.querySelector<HTMLButtonElement>('[data-action=\"close\"]')!.addEventListener(\"click\", close);\n backdrop.addEventListener(\"mousedown\", (e) => { if (e.target === backdrop) close(); });\n\n await reload();\n}\n\nfunction escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, c => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", \"\\\"\": \""\", \"'\": \"'\" }[c]!));\n}\nfunction escapeAttr(s: string): string { return escapeHtml(s); }\n", "/**\n * Alarm subsystem \u2014 Thunderbird/Outlook-style reminder popups.\n *\n * Design decisions (2026-04-24):\n * - One shared subsystem for calendar events + tasks (+ future mail reminders).\n * - Popup shows item title, scheduled time, Snooze (5 / 15 / 30 min / 1 hr /\n * custom), Dismiss. Snooze delays the alarm by the chosen interval; Dismiss\n * suppresses it permanently.\n * - Dismissed / snoozed state lives in localStorage (per-device). Google\n * Calendar's own reminders aren't mutated by this \u2014 mailx's popup is a\n * local convenience, not a reminder-authority replacement.\n * - Default lead time: 10 min before calendar event start; at task due time\n * for tasks. Per-event overrides from Google Calendar's `reminders.overrides`\n * are a follow-up (we don't currently fetch that field).\n *\n * Check cadence: every 30 s while the tab/window is focused. No check when\n * hidden \u2014 alarm won't fire until user returns, which matches Thunderbird's\n * behavior (focus-gated). OS-level notifications are a separate follow-up.\n */\n\nimport { getCalendarEvents, getTasks, showReminderPopup, deleteCalendarEvent, logClientEvent } from \"../lib/api-client.js\";\n\n/** Alarm diagnostics. A plain console.log from the WebView never reaches\n * the daemon log file, so snooze/dismiss/fire decisions were invisible\n * after the fact \u2014 \"is snooze working?\" couldn't be answered from the\n * log. alog() mirrors the line to the daemon log via logClientEvent. */\nfunction alog(tag: string, data?: any): void {\n console.log(`[alarm] ${tag}`, data ?? \"\");\n try { logClientEvent(`[alarm] ${tag}`, data); } catch { /* never break the alarm path */ }\n}\n\nconst DISMISSED_KEY = \"mailx-alarm-dismissed\"; // { uuid: true }\nconst SNOOZED_KEY = \"mailx-alarm-snoozed\"; // { uuid: epoch-ms-end }\nconst LOOKBACK_MS = 60 * 60 * 1000; // fire past-due alarms from the last 60 min\nconst LOOKAHEAD_MS = 2 * 60 * 60 * 1000; // poll window: upcoming 2 hr\nconst POLL_INTERVAL_MS = 30_000;\n\ninterface AlarmItem {\n uuid: string;\n kind: \"calendar\" | \"task\";\n title: string;\n /** When the alarm should fire (epoch ms). */\n alarmMs: number;\n /** When the source event actually starts / is due (epoch ms) \u2014 shown in popup. */\n whenMs: number;\n /** Optional web link for \"view in browser\". */\n htmlLink?: string;\n}\n\nfunction loadDismissed(): Record<string, boolean> {\n try { return JSON.parse(localStorage.getItem(DISMISSED_KEY) || \"{}\"); } catch { return {}; }\n}\nfunction saveDismissed(map: Record<string, boolean>): void {\n try { localStorage.setItem(DISMISSED_KEY, JSON.stringify(map)); } catch { /* private mode */ }\n}\nfunction loadSnoozed(): Record<string, number> {\n try { return JSON.parse(localStorage.getItem(SNOOZED_KEY) || \"{}\"); } catch { return {}; }\n}\nfunction saveSnoozed(map: Record<string, number>): void {\n try { localStorage.setItem(SNOOZED_KEY, JSON.stringify(map)); } catch { /* */ }\n}\n\n/** Prune expired entries so the maps don't grow forever. */\nfunction pruneState(now: number): void {\n const snoozed = loadSnoozed();\n let changed = false;\n for (const [uuid, until] of Object.entries(snoozed)) {\n // Snoozed entries older than 7 days have been fired already (or the\n // event is long past); drop them.\n if (until < now - 7 * 86400_000) { delete snoozed[uuid]; changed = true; }\n }\n if (changed) saveSnoozed(snoozed);\n // Dismissed is sparse \u2014 don't bother pruning aggressively.\n}\n\n/** Per-occurrence key for dismissed/snoozed/fired tracking. Recurring\n * events all share `ev.uuid`, so dismissing one occurrence used to\n * dismiss every future one. Including startMs makes today's 9am\n * meeting distinct from yesterday's. Tasks also use this \u2014 duplicate\n * taskId+dueMs is fine since completed tasks drop out of getTasks. */\nfunction occKey(uuid: string, ms: number): string {\n return `${uuid}:${ms}`;\n}\n\nasync function collectDueAlarms(now: number): Promise<AlarmItem[]> {\n const dismissed = loadDismissed();\n const snoozed = loadSnoozed();\n const items: AlarmItem[] = [];\n\n try {\n const events = await getCalendarEvents(now - LOOKBACK_MS, now + LOOKAHEAD_MS);\n for (const ev of events) {\n // Prefer providerId (Google Calendar event id) as the dedup\n // root. It's stable across local resyncs \u2014 if calendar sync\n // purges + reinserts a row, mailx-uuid changes but providerId\n // doesn't. Using uuid here was the cause of \"popup keeps\n // reappearing even when I don't react\" (Bob 2026-05-14): a\n // resync mid-popup-display assigned a new uuid \u2192 openPopups /\n // firedThisSession / dismissed all keyed on the old one \u2192\n // every poll spawned a NEW popup for what was logically the\n // same event-occurrence. uuid is the fallback for local-only\n // rows that never got a providerId.\n const stableId = ev.providerId || ev.uuid;\n if (!stableId) continue;\n const startMs = ev.startMs || 0;\n if (!startMs) continue;\n // Per-event popup reminder offsets. Bob 2026-05-13: \"alarm\n // should popup for events that have reminders set\" \u2014 i.e.\n // ONLY fire when the event (or its calendar default) actually\n // configured a popup reminder. The upstream filter in\n // google-sync.ts keeps `method === \"popup\"` only (Google also\n // supports email/sms; those aren't ours to fire). An empty\n // reminderMinutes here means \"no popup reminders\" \u2014 skip the\n // event entirely instead of fabricating a 10-min default that\n // the user never asked for.\n if (!Array.isArray(ev.reminderMinutes) || ev.reminderMinutes.length === 0) continue;\n const offsets: number[] = ev.reminderMinutes.map((m: number) => m * 60_000);\n // Per event-occurrence, emit at most ONE alarm \u2014 the one whose\n // alarm time is the latest among those still eligible (i.e.,\n // closest to \"now\" but already in the past). If Bob set\n // reminders at -60/-30/-15/-5/0 min and mailx wakes from\n // hidden-tab / reboot with several inside the 60-min lookback,\n // we'd otherwise stack one popup per offset. Earlier offsets\n // get auto-dismissed so they can't re-fire later as stragglers.\n const occBaseKey = occKey(stableId, startMs);\n let pick: { offsetMs: number; effective: number; key: string } | null = null;\n const eligibleOffsets: number[] = [];\n for (const offsetMs of offsets) {\n const key = `${occBaseKey}@${offsetMs}`;\n if (dismissed[key]) continue;\n const alarm = startMs - offsetMs;\n const effective = snoozed[key] || alarm;\n if (effective <= now && effective > now - LOOKBACK_MS) {\n eligibleOffsets.push(offsetMs);\n if (!pick || effective > pick.effective) {\n pick = { offsetMs, effective, key };\n }\n }\n }\n if (pick) {\n // Diagnostic for \"dismissed reminder reappears after restart\"\n // (Bob 2026-05-16): log the exact fire key so a re-fire can\n // be compared against the `[alarm] dismiss saved` line from\n // before the restart \u2014 a mismatch pinpoints a key shift\n // (stableId / startMs instability across resync).\n alog(\"fire decision\", { key: pick.key, title: ev.title || \"\", startMs, effective: pick.effective });\n items.push({\n uuid: pick.key, kind: \"calendar\",\n title: ev.title || \"(no title)\",\n alarmMs: pick.effective, whenMs: startMs,\n htmlLink: ev.htmlLink,\n });\n // Suppress the earlier offsets so they don't fire as\n // stragglers next poll. Persist immediately so a refresh\n // doesn't lose the suppression.\n let touched = false;\n for (const offsetMs of eligibleOffsets) {\n if (offsetMs === pick.offsetMs) continue;\n const k = `${occBaseKey}@${offsetMs}`;\n if (!dismissed[k]) { dismissed[k] = true; touched = true; }\n }\n if (touched) saveDismissed(dismissed);\n }\n }\n } catch { /* sidebar will surface API errors; alarm path stays quiet */ }\n\n try {\n const tasks = await getTasks(false);\n for (const t of tasks) {\n if (!t.uuid || !t.dueMs) continue;\n const key = occKey(t.uuid, t.dueMs);\n if (dismissed[key]) continue;\n const effective = snoozed[key] || t.dueMs;\n if (effective <= now && effective > now - LOOKBACK_MS) {\n items.push({\n uuid: key, kind: \"task\",\n title: t.title || \"(no title)\",\n alarmMs: effective, whenMs: t.dueMs,\n });\n }\n }\n } catch { /* */ }\n\n return items;\n}\n\nfunction formatWhen(ms: number): string {\n if (!ms) return \"\";\n const d = new Date(ms);\n return d.toLocaleString(undefined, { weekday: \"short\", month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\" });\n}\n\nlet firedThisSession = new Set<string>();\n/** uuids whose popup is currently visible on screen \u2014 set when the\n * popup opens, cleared when the user resolves it (button or close).\n * pollAlarms skips uuids in here so a tick that lands while a popup is\n * still up doesn't open a second window for the same event. Distinct\n * from `firedThisSession` (which is \"shown at any point this session\"\n * and gets cleared by snooze) \u2014 `openPopups` is \"right now on screen\". */\nlet openPopups = new Set<string>();\n\nfunction escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, c => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", \"\\\"\": \""\", \"'\": \"'\" }[c]!));\n}\n\n/** Render the popup using an inline DOM overlay \u2014 used when no msger\n * host is available (browser mode, Android shells without\n * showReminderPopup wired). Returns the same shape as the msger path\n * so the caller can switch on `r.button` uniformly. */\nfunction showInWebViewPopup(opts: { title: string; html: string; buttons: string[] }): Promise<{ button: string }> {\n return new Promise(resolve => {\n const overlay = document.createElement(\"div\");\n overlay.className = \"alarm-overlay\";\n const panel = document.createElement(\"div\");\n panel.className = \"alarm-panel\";\n const buttonsHtml = opts.buttons.map(b =>\n `<button type=\"button\" class=\"alarm-btn${b === \"Open\" || b === \"Dismiss\" ? \" alarm-btn-primary\" : \"\"}\" data-button=\"${escapeHtml(b)}\">${escapeHtml(b)}</button>`\n ).join(\"\");\n panel.innerHTML = `\n <div class=\"alarm-head\">\n <span class=\"alarm-icon\">\u23F0</span>\n <span class=\"alarm-title\">${escapeHtml(opts.title)}</span>\n <button type=\"button\" class=\"alarm-close\" data-button=\"\" aria-label=\"Close\">×</button>\n </div>\n <div class=\"alarm-body\">${opts.html}</div>\n <div class=\"alarm-foot\">${buttonsHtml}</div>\n `;\n overlay.appendChild(panel);\n document.body.appendChild(overlay);\n\n const msgapi: any = (window as any).msgapi;\n try { msgapi?.setAlwaysOnTop?.(true); } catch { /* */ }\n\n const cleanup = (button: string): void => {\n overlay.remove();\n try { msgapi?.setAlwaysOnTop?.(false); } catch { /* */ }\n document.removeEventListener(\"keydown\", onKey, true);\n resolve({ button });\n };\n const onKey = (e: KeyboardEvent): void => {\n if (e.key === \"Escape\") { e.stopPropagation(); cleanup(\"\"); }\n };\n document.addEventListener(\"keydown\", onKey, true);\n panel.querySelectorAll<HTMLButtonElement>(\"[data-button]\").forEach(btn => {\n btn.addEventListener(\"click\", () => cleanup(btn.dataset.button || \"\"));\n });\n });\n}\n\nasync function firePopupForItem(item: AlarmItem): Promise<void> {\n // Already-showing guard. The popup chain is fire-and-forget so\n // without an explicit registry there's nothing stopping a second\n // window for the same uuid (e.g., a 2-min Open snooze expires while\n // the user is still looking at the first popup). `openPopups` tracks\n // \"currently on screen\" \u2014 pollAlarms also checks it.\n if (openPopups.has(item.uuid)) return;\n openPopups.add(item.uuid);\n\n // Buttons map to msger's `buttons` array for the keyboard-default\n // detection in template mode; in rawHtml we render our own controls,\n // but the array still flows back as the default-button fallback if\n // the user closes the window without picking. Order = priority.\n const buttons: string[] = [\"snooze\", \"Dismiss\"];\n if (item.htmlLink) buttons.push(\"Open\");\n if (item.kind === \"calendar\") buttons.push(\"Delete\");\n\n // rawHtml mode. msger's design intent is that we deliver any HTML\n // form and the parent process receives whatever `window.ipc.postMessage`\n // serializes \u2014 `MessageBoxResult` includes a `form: serde_json::Value`\n // field for exactly this. We mirror Thunderbird's snooze popup: a\n // row of preset chips PLUS a custom \"N units\" form. Snooze submits\n // `{button:\"snooze\", form:{minutes:N}}` so the daemon can handle any\n // duration; Dismiss/Open/Delete still discriminate by button label.\n const icon = item.kind === \"calendar\" ? \"\uD83D\uDCC5\" : \"\u2611\";\n const kindLabel = item.kind === \"calendar\" ? \"Calendar event\" : \"Task due\";\n // Presets in minutes. Tuned to TBird's defaults \u2014 covers the common\n // \"give me 15 more minutes\" through \"remind me tomorrow morning.\"\n const presets: Array<{ label: string; minutes: number }> = [\n { label: \"5m\", minutes: 5 },\n { label: \"15m\", minutes: 15 },\n { label: \"30m\", minutes: 30 },\n { label: \"1h\", minutes: 60 },\n { label: \"2h\", minutes: 120 },\n { label: \"1d\", minutes: 60 * 24 },\n ];\n const presetChips = presets.map(p =>\n `<button type=\"button\" class=\"chip\" data-snooze-min=\"${p.minutes}\">${p.label}</button>`\n ).join(\"\");\n const actionBtns: string[] = [\"Dismiss\"];\n if (item.htmlLink) actionBtns.push(\"Open\");\n if (item.kind === \"calendar\") actionBtns.push(\"Delete\");\n const actionHtml = actionBtns.map(b =>\n `<button type=\"button\" class=\"action\" data-btn=\"${escapeHtml(b)}\">${escapeHtml(b)}</button>`\n ).join(\"\");\n\n const html = `<!DOCTYPE html>\n<html><head><meta charset=\"utf-8\"><style>\n html, body { height: 100%; }\n body { font: 13px system-ui, sans-serif; margin: 0; padding: 14px 16px; background: #fff; color: #222; display:flex; flex-direction:column; box-sizing:border-box; gap: 8px; }\n .icon { display: inline-block; width: 20px; text-align: center; margin-right: 4px; }\n .title { font-size: 1.1em; font-weight: 600; }\n .when { color: #555; font-size: 0.95em; }\n .kind { color: #888; font-size: 0.85em; }\n .spacer { flex: 1; }\n .row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }\n .row-label { color: #555; font-size: 0.85em; margin-right: 2px; }\n button { font: 13px system-ui, sans-serif; cursor: pointer; }\n .chip { padding: 3px 9px; border: 1px solid #ccc; border-radius: 12px; background: #f7f7f7; color: #222; }\n .chip:hover { background: #e8f0fe; border-color: #0066cc; color: #0066cc; }\n .custom { display: flex; align-items: center; gap: 4px; }\n .custom input[type=number] { width: 48px; padding: 2px 4px; border: 1px solid #ccc; border-radius: 3px; font: inherit; }\n .custom select { padding: 2px 4px; border: 1px solid #ccc; border-radius: 3px; font: inherit; background: #fff; }\n .custom button.ok { padding: 2px 8px; border: 1px solid #0066cc; border-radius: 3px; background: #0066cc; color: #fff; }\n .custom button.ok:hover { background: #0052a3; }\n .actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 4px; }\n .actions button.action { padding: 5px 14px; border: none; border-radius: 4px; background: #0066cc; color: #fff; min-width: 70px; }\n .actions button.action:hover { background: #0052a3; }\n .actions button.action[data-btn=\"Delete\"] { background: #b00; }\n .actions button.action[data-btn=\"Delete\"]:hover { background: #800; }\n</style></head><body>\n <div class=\"title\"><span class=\"icon\">${icon}</span>${escapeHtml(item.title)}</div>\n <div class=\"when\">${escapeHtml(formatWhen(item.whenMs))}</div>\n <div class=\"kind\">${kindLabel}</div>\n <div class=\"row\">\n <span class=\"row-label\">Snooze:</span>\n ${presetChips}\n </div>\n <div class=\"row custom\">\n <span class=\"row-label\">Custom:</span>\n <input type=\"number\" id=\"customN\" value=\"5\" min=\"1\" max=\"999\">\n <select id=\"customUnit\">\n <option value=\"1\">minutes</option>\n <option value=\"60\">hours</option>\n <option value=\"1440\">days</option>\n </select>\n <button type=\"button\" class=\"ok\" id=\"customGo\">Snooze</button>\n </div>\n <div class=\"spacer\"></div>\n <div class=\"actions\">${actionHtml}</div>\n<script>\n function send(payload) {\n try { window.ipc.postMessage(JSON.stringify(payload)); }\n catch (e) { try { window.close(); } catch (_) {} }\n }\n // Preset chips \u2192 snooze N minutes\n document.querySelectorAll(\"button[data-snooze-min]\").forEach(function(b) {\n b.addEventListener(\"click\", function() {\n var m = parseInt(b.getAttribute(\"data-snooze-min\"), 10) || 15;\n send({ button: \"snooze\", form: { minutes: m } });\n });\n });\n // Custom snooze form \u2192 snooze N * unit-multiplier minutes\n var customGo = document.getElementById(\"customGo\");\n var customN = document.getElementById(\"customN\");\n var customUnit = document.getElementById(\"customUnit\");\n function submitCustom() {\n var n = parseInt(customN.value, 10);\n if (!isFinite(n) || n < 1) n = 5;\n var mult = parseInt(customUnit.value, 10) || 1;\n send({ button: \"snooze\", form: { minutes: n * mult } });\n }\n customGo.addEventListener(\"click\", submitCustom);\n customN.addEventListener(\"keydown\", function(e) { if (e.key === \"Enter\") submitCustom(); });\n // Action buttons (Dismiss/Open/Delete) \u2192 discriminate by label.\n document.querySelectorAll(\"button[data-btn]\").forEach(function(b) {\n b.addEventListener(\"click\", function() {\n send({ button: b.getAttribute(\"data-btn\") });\n });\n });\n</script>\n</body></html>`;\n\n const title = item.kind === \"calendar\" ? \"Calendar reminder\" : \"Task reminder\";\n const hasHost = !!(window as any).mailxapi?.showReminderPopup;\n // Result now carries an optional `form` object \u2014 see msger's\n // MessageBoxResult.form (serde_json::Value passthrough). Snooze\n // chips/inputs send `{ button: \"snooze\", form: { minutes: N } }`.\n let r: { button: string; form?: { minutes?: number } };\n try {\n if (hasHost) {\n r = await showReminderPopup({\n title, html, buttons,\n // Sized for the new snooze form (chips row + custom input\n // row + action buttons) plus title/when/kind. Width fits\n // \"5m 15m 30m 1h 2h 1d\" on one line in Segoe UI.\n size: { width: 560, height: 300 },\n });\n } else {\n r = await showInWebViewPopup({ title, html, buttons });\n }\n } finally {\n openPopups.delete(item.uuid);\n }\n\n const api = (window as any).mailxapi;\n const openLink = (): void => {\n if (!item.htmlLink) return;\n if (api?.openExternal) api.openExternal(item.htmlLink);\n else window.open(item.htmlLink, \"_blank\");\n };\n switch (r.button) {\n case \"Open\":\n openLink();\n // After opening for edit, treat the event as a brand-new\n // entry: clear all local alarm state so the next poll tick\n // re-fetches the (possibly edited) event and decides anew\n // whether to alarm. Edits can come from any client (web,\n // phone, another mailx instance, a co-organizer) \u2014 we just\n // re-evaluate against the synced data. Pushed start out \u2192\n // alarm waits for the new lead time; pulled in \u2192 fires again.\n // No carryover.\n //\n // Brief 2-min snooze gives the calendar sync window to pick\n // up edits before the next alarm pass \u2014 without it, the\n // next 30-s poll reads the still-stale local cache and\n // re-fires immediately. After 2 min, re-evaluation is\n // against (hopefully) fresh data.\n { const dm = loadDismissed(); delete dm[item.uuid]; saveDismissed(dm); }\n firedThisSession.delete(item.uuid);\n snoozeItem(item, 2);\n break;\n case \"snooze\": {\n // Generic snooze, duration in `form.minutes`. Falls back to\n // 15 min if the form is malformed or missing the value (the\n // chip / custom-form JS always populates it, so this is just\n // defense-in-depth).\n const m = Math.max(1, Math.floor(r.form?.minutes ?? 15));\n snoozeItem(item, m);\n break;\n }\n // Legacy literal labels \u2014 retained so a pre-upgrade popup whose\n // bundle still says \"Snooze 15m\" / \"Snooze 1h\" continues to work\n // through a daemon-side rebuild without a UI restart.\n case \"Snooze 15m\": snoozeItem(item, 15); break;\n case \"Snooze 1h\": snoozeItem(item, 60); break;\n case \"Dismiss\":\n case \"dismissed\": // msger reports window-close as r.dismissed=true \u2192 daemon\n // wrapper lowercases to \"dismissed\". Treat the same as\n // explicit Dismiss-button: the popup HAD a clear button\n // for that action, so any close that lands here is the\n // user's \"I'm done with this reminder\" \u2014 not a 15-min\n // snooze. Bob 2026-05-14: \"I keep dismissing the\n // reminder but it keeps coming back\" \u2014 the lowercase\n // path was hitting the snooze-15-min default, so the\n // popup re-fired every 15 min indefinitely.\n case \"closed\": // user closed via X / Esc with no labeled button \u2014\n // popup HAS Dismiss right there; the user clearly\n // didn't want this. Same treatment.\n { const m = loadDismissed(); m[item.uuid] = true; saveDismissed(m);\n alog(\"dismiss saved\", { key: item.uuid, via: r.button }); }\n break;\n case \"Delete\":\n // Remove the event server-side. Local row gets the pending-delete\n // mark inside the service; reconcile picks it up. Also dismiss\n // the alarm so it can't re-fire on a stale local row before the\n // delete propagates.\n { const m = loadDismissed(); m[item.uuid] = true; saveDismissed(m); }\n firedThisSession.delete(item.uuid);\n deleteCalendarEvent(item.uuid).catch((e: any) =>\n console.error(` [alarm] delete failed for ${item.uuid}: ${e?.message || e}`));\n break;\n default:\n // Unrecognized button label \u2014 shouldn't happen with the\n // current popup HTML. Log it so we can diagnose, then\n // dismiss permanently (the popup HAD explicit options; if\n // the user picked something we can't decode, don't keep\n // pestering them every 30 s).\n alog(\"unknown popup result\", { button: r.button, form: (r as any).form });\n { const m = loadDismissed(); m[item.uuid] = true; saveDismissed(m); }\n break;\n }\n}\n\nfunction snoozeItem(item: AlarmItem, minutes: number): void {\n const now = Date.now();\n const map = loadSnoozed();\n map[item.uuid] = now + minutes * 60_000;\n saveSnoozed(map);\n firedThisSession.delete(item.uuid);\n // Diagnostic \u2014 `key` here is the SAME per-occurrence key collectDueAlarms\n // reads back as `snoozed[key]`, so this line + the next \"fire decision\"\n // line confirm a snooze actually took (and for how long).\n alog(\"snooze saved\", { key: item.uuid, minutes, untilMs: map[item.uuid] });\n}\n\nasync function pollAlarms(): Promise<void> {\n if (document.hidden) return; // don't tick while tab hidden\n const now = Date.now();\n pruneState(now);\n const due = await collectDueAlarms(now);\n // Two filters: skip uuids already fired this session (Snooze + an\n // expired snooze re-allows them; Dismiss permanently suppresses via\n // localStorage), and skip uuids whose popup is currently visible\n // (race guard for fire-and-forget OS popups).\n const fresh = due.filter(d => !firedThisSession.has(d.uuid) && !openPopups.has(d.uuid));\n if (fresh.length === 0) return;\n for (const d of fresh) firedThisSession.add(d.uuid);\n\n // One unified path. firePopupForItem picks render target (msger OS\n // window vs in-WebView DOM overlay) internally; same buttons,\n // same post-click logic, same dedup. Fire-and-forget per item \u2014\n // errors logged but never block the poll loop.\n for (const item of fresh) {\n firePopupForItem(item).catch(e => console.error(` [alarm] popup failed: ${e?.message || e}`));\n }\n}\n\n/** Start the alarm poller. Called from app.ts on startup. */\nexport function startAlarmPoller(): void {\n if ((window as any).__mailxAlarmPollerRunning) return;\n (window as any).__mailxAlarmPollerRunning = true;\n // First check after 5 s so startup isn't jittered by a modal.\n setTimeout(() => { pollAlarms().catch(() => { /* */ }); }, 5000);\n setInterval(() => { pollAlarms().catch(() => { /* */ }); }, POLL_INTERVAL_MS);\n // Re-check on visibility change \u2014 if the user comes back to the tab\n // after an hour away, they should see anything they missed immediately.\n document.addEventListener(\"visibilitychange\", () => {\n if (!document.hidden) pollAlarms().catch(() => { /* */ });\n });\n}\n", "/**\n * Search help \u2014 part of the app, rendered as HTML.\n *\n * Help for a real app feature (as opposed to the JSONC config-file editors)\n * ships *inside the client*, not as a `.md` in the docs/ deploy set. The\n * `docs/*.md` files exist only as a stand-in for proper settings UI; feature\n * help like this is application content and must render as HTML.\n *\n * This module is dynamically imported the first time the search `?` button is\n * pressed, so the markup stays out of the cold-start bundle path.\n */\n\nexport const SEARCH_HELP_HTML = `\n<p>Search runs in one of three modes depending on the scope you pick. The mode\ndecides which operators work.</p>\n<ul>\n <li><strong>All folders</strong> (default) \u2014 searches the <em>local cache</em> with SQLite FTS5.</li>\n <li><strong>This folder</strong> \u2014 instant client-side filter on the visible rows; full FTS5 search on Enter.</li>\n <li><strong>Server</strong> (checkbox) \u2014 sends the query to the mail server (IMAP SEARCH; not yet wired for Gmail accounts).</li>\n</ul>\n\n<h3>Qualifiers \u2014 work in every mode</h3>\n<table>\n <tr><th>Form</th><th>Effect</th></tr>\n <tr><td><code>from:bob</code></td><td>Sender contains</td></tr>\n <tr><td><code>to:eleanor</code></td><td>Recipient contains</td></tr>\n <tr><td><code>subject:lunch</code></td><td>Subject contains</td></tr>\n <tr><td><code>date:2026-05-01</code></td><td>On that date \u2014 also <code>date:>1w</code>, <code>date:<=2026-01-15</code></td></tr>\n <tr><td><code>after:1w</code> / <code>before:1m</code></td><td>Newer / older than \u2014 <code>d</code>, <code>w</code>, <code>m</code>, <code>y</code>, a date, <code>today</code>, <code>yesterday</code></td></tr>\n <tr><td><code>has:attachment</code></td><td>Has an attachment</td></tr>\n <tr><td><code>is:unread</code></td><td>Also <code>is:flagged</code>, <code>is:read</code>, <code>is:answered</code>, <code>is:draft</code></td></tr>\n <tr><td><code>folder:sent</code></td><td>Restrict to folders whose name contains the term</td></tr>\n <tr><td><code>/regex/</code></td><td>Client-side regex over the currently-visible rows. Local only \u2014 never sent to the server.</td></tr>\n</table>\n<p>Any remaining unqualified text is the free-text term.</p>\n\n<h3>Local search (FTS5) \u2014 default</h3>\n<p>Full-text index over envelopes plus locally-cached bodies. Fast and indexed.</p>\n<table>\n <tr><th>Operator</th><th>Example</th><th>Meaning</th></tr>\n <tr><td>implicit AND</td><td><code>bob lunch</code></td><td>Both terms must appear</td></tr>\n <tr><td><code>OR</code></td><td><code>bob OR eleanor</code></td><td>Either term</td></tr>\n <tr><td><code>NOT</code></td><td><code>bob NOT spam</code></td><td>First without the second</td></tr>\n <tr><td><code>\"phrase\"</code></td><td><code>\"happy birthday\"</code></td><td>Exact phrase</td></tr>\n <tr><td><code>term*</code></td><td><code>lunch*</code></td><td>Prefix</td></tr>\n <tr><td><code>NEAR(a b, 5)</code></td><td><code>NEAR(bob lunch, 5)</code></td><td>Both within 5 tokens</td></tr>\n</table>\n<p>Indexed: subject, from, to, cc, a body snippet, and the full body <em>if</em> it\nhas been downloaded locally (the blue dot in the list). Bodies not yet\nprefetched aren't searchable until they are.</p>\n\n<h3>This-folder filter</h3>\n<p>With scope set to <strong>This folder</strong>, typing filters the rendered rows\ninstantly \u2014 no server hit, no FTS5, just substring match. Press <kbd>Enter</kbd>\nto escalate to a full FTS5 search of the folder.</p>\n\n<h3>Server search</h3>\n<p>Tick <strong>Server</strong> and the query goes to the mail server. On Dovecot\n(IMAP SEARCH) your <code>from:</code> / <code>to:</code> / <code>subject:</code>\nqualifiers map to IMAP keys; remaining text becomes a body search. IMAP has no\nliteral <code>AND</code> keyword \u2014 keys are implicitly ANDed. Server search on\nGmail accounts is not yet wired and currently returns local results only.</p>\n\n<h3>Case sensitivity</h3>\n<p>All three modes \u2014 local FTS5, IMAP server SEARCH, and <code>/regex/</code> \u2014\nare <strong>case-insensitive</strong>. <code>Lunch</code>, <code>lunch</code>, and\n<code>LUNCH</code> match the same messages. There is no case-sensitive mode.</p>\n\n<h3>Limitations</h3>\n<ul>\n <li>No regex on the server side, any provider \u2014 <code>/pattern/</code> only filters visible local rows.</li>\n <li>Server search results are stored locally (envelope-only) on first hit, so a server search also backfills those envelopes.</li>\n <li>Body search on bodies you haven't prefetched only works server-side; the local index can't search what isn't downloaded.</li>\n</ul>\n`;\n", "/**\n * Folder tree component -- renders account folders with hierarchy,\n * expand/collapse, and optional unified inbox.\n */\n\nimport { getAccounts, getFolders, moveMessage, moveMessages, markFolderRead, createFolder, renameFolder, deleteFolder, moveFolderToTrash, emptyFolder, setupAccount, getDeviceAccounts, getVersion, syncAccount } from \"../lib/api-client.js\";\nimport { showContextMenu, type MenuItem } from \"./context-menu.js\";\nimport { openTab } from \"./tabs.js\";\n\ntype FolderSelectHandler = (accountId: string, folderId: number, folderName: string, specialUse: string) => void;\n// Unified inbox uses folderId = -1 as a sentinel\ntype UnifiedHandler = () => void;\n\nlet onFolderSelect: FolderSelectHandler;\nlet onUnifiedInbox: UnifiedHandler | null = null;\nlet selectedElement: HTMLElement;\nlet selectedAccountId: string | null = null;\nlet selectedFolderId: number | null = null;\nlet isFirstLoad = true; // only auto-select on first load\nlet hasAutoSelected = false; // track whether we've ever managed to auto-select\n\n// Debounce timer for refreshFolderTree\nlet refreshDebounceTimer: ReturnType<typeof setTimeout> | null = null;\n\n// Spring-loaded folder hover-expand delay (Q117). Matches Outlook/Finder\n// feel \u2014 short enough to feel responsive, long enough that grazing past a\n// collapsed folder doesn't expand it accidentally.\nconst DRAG_HOVER_EXPAND_MS = 600;\n\n// Track folders that were auto-expanded during the current drag so they can\n// be collapsed back on dragend if the user didn't drop on (or inside) them.\n// Keyed by expandKey; value is true if it was previously collapsed.\nconst dragAutoExpanded = new Set<string>();\nlet dragInFlight = false;\n\n// Persist expand/collapse state in localStorage\nconst expandState: Record<string, boolean> = JSON.parse(localStorage.getItem(\"mailx-folders-expanded\") || \"{}\");\n\nfunction saveExpandState(): void {\n localStorage.setItem(\"mailx-folders-expanded\", JSON.stringify(expandState));\n}\n\n// Last-sync tracking: populated by folderSynced events. Memory-only; fresh\n// restarts start with an empty map and fill in as syncs happen.\nconst folderLastSync = new Map<string, number>(); // key = `${accountId}:${folderId}`\n\nfunction syncKey(accountId: string, folderId: number): string {\n return `${accountId}:${folderId}`;\n}\n\nexport function setFolderSynced(accountId: string, folderId: number, syncedAt: number): void {\n folderLastSync.set(syncKey(accountId, folderId), syncedAt);\n // Update the row in place if rendered \u2014 avoids a full re-render.\n const el = document.querySelector<HTMLElement>(`.ft-folder[data-account-id=\"${CSS.escape(accountId)}\"][data-folder-id=\"${folderId}\"]`);\n if (el) applyFreshness(el, syncedAt);\n}\n\nexport function getFolderSynced(accountId: string, folderId: number): number | undefined {\n return folderLastSync.get(syncKey(accountId, folderId));\n}\n\nfunction formatAge(ms: number): string {\n const secs = Math.round(ms / 1000);\n if (secs < 60) return `${secs}s ago`;\n const mins = Math.round(secs / 60);\n if (mins < 60) return `${mins}m ago`;\n const hours = Math.round(mins / 60);\n if (hours < 24) return `${hours}h ago`;\n return `${Math.round(hours / 24)}d ago`;\n}\n\nfunction freshnessClass(ageMs: number): string {\n if (ageMs < 5 * 60_000) return \"fresh\"; // green\n if (ageMs < 30 * 60_000) return \"stale-soft\"; // yellow\n return \"stale\"; // red\n}\n\nfunction applyFreshness(el: HTMLElement, syncedAt: number): void {\n const age = Date.now() - syncedAt;\n el.classList.remove(\"fresh\", \"stale-soft\", \"stale\");\n el.classList.add(freshnessClass(age));\n el.title = `Last synced: ${formatAge(age)} (${new Date(syncedAt).toLocaleTimeString()})`;\n}\n\ninterface FolderNode {\n id: number;\n accountId: string;\n path: string;\n name: string;\n specialUse: string;\n delimiter: string;\n unreadCount: number;\n totalCount: number;\n children: FolderNode[];\n}\n\n/** Build a tree from flat folder list using delimiter */\nfunction buildTree(folders: any[], delimiter: string, accountId: string): FolderNode[] {\n const root: FolderNode[] = [];\n const byPath: Record<string, FolderNode> = {};\n\n // Sort by path so parents come before children\n const sorted = [...folders].sort((a, b) => a.path.localeCompare(b.path));\n\n for (const f of sorted) {\n const node: FolderNode = {\n id: f.id,\n accountId: f.accountId,\n path: f.path,\n name: f.name,\n specialUse: f.specialUse,\n delimiter: f.delimiter || delimiter,\n unreadCount: f.unreadCount || 0,\n totalCount: f.totalCount || 0,\n children: [],\n };\n byPath[f.path] = node;\n\n // Find parent by stripping the last segment\n const lastDelim = f.path.lastIndexOf(delimiter);\n if (lastDelim > 0) {\n const parentPath = f.path.substring(0, lastDelim);\n let parent = byPath[parentPath];\n if (!parent) {\n // Create virtual parent for non-selectable folders (e.g., \"Added2\")\n const parentName = parentPath.split(delimiter).pop() || parentPath;\n parent = {\n id: -1, // virtual, not selectable\n accountId,\n path: parentPath,\n name: parentName,\n specialUse: \"\",\n delimiter,\n unreadCount: 0,\n totalCount: 0,\n children: [],\n };\n byPath[parentPath] = parent;\n // Insert the virtual parent into the tree\n const grandParentDelim = parentPath.lastIndexOf(delimiter);\n if (grandParentDelim > 0) {\n const grandParent = byPath[parentPath.substring(0, grandParentDelim)];\n if (grandParent) {\n grandParent.children.push(parent);\n } else {\n root.push(parent);\n }\n } else {\n root.push(parent);\n }\n }\n parent.children.push(node);\n } else {\n root.push(node);\n }\n }\n\n // Aggregate counts from children to parents (so collapsed parents show totals)\n function aggregateCounts(nodes: FolderNode[]): { unread: number; total: number } {\n let unread = 0, total = 0;\n for (const n of nodes) {\n const child = aggregateCounts(n.children);\n n.unreadCount += child.unread;\n n.totalCount += child.total;\n unread += n.unreadCount;\n total += n.totalCount;\n }\n return { unread, total };\n }\n aggregateCounts(root);\n\n return root;\n}\n\n/** Sort: INBOX first, then special folders, then alphabetical */\nfunction sortFolders(nodes: FolderNode[]): void {\n const specialOrder: Record<string, number> = { inbox: 0, sent: 1, outbox: 2, drafts: 3, trash: 4, junk: 5, archive: 6 };\n const nameOrder: Record<string, number> = { inbox: 0, sent: 1, \"sent items\": 1, outbox: 2, drafts: 3, trash: 4, \"deleted items\": 4, junk: 5, \"junk email\": 5, \"junk e-mail\": 5, spam: 5, archive: 6 };\n nodes.sort((a, b) => {\n const aOrder = specialOrder[a.specialUse] ?? nameOrder[a.name.toLowerCase()] ?? 99;\n const bOrder = specialOrder[b.specialUse] ?? nameOrder[b.name.toLowerCase()] ?? 99;\n if (aOrder !== bOrder) return aOrder - bOrder;\n return a.name.localeCompare(b.name);\n });\n for (const n of nodes) {\n if (n.children.length > 0) sortFolders(n.children);\n }\n}\n\n/** Render a folder node and its children recursively */\nfunction renderNode(node: FolderNode, container: HTMLElement, depth: number): void {\n const hasChildren = node.children.length > 0;\n const expandKey = `${node.accountId}:${node.path}`;\n const isExpanded = expandState[expandKey] === true; // default collapsed\n\n const folderEl = document.createElement(\"div\");\n folderEl.className = \"ft-folder\";\n folderEl.dataset.accountId = node.accountId;\n folderEl.dataset.folderId = String(node.id);\n folderEl.dataset.folderPath = node.path;\n folderEl.dataset.specialUse = node.specialUse || \"\";\n folderEl.style.paddingLeft = `${depth * 16 + 8}px`;\n\n // Expand/collapse toggle\n const toggle = document.createElement(\"span\");\n toggle.className = \"ft-toggle\";\n if (hasChildren) {\n toggle.textContent = isExpanded ? \"\u25BE\" : \"\u25B8\";\n toggle.addEventListener(\"click\", (e) => {\n e.stopPropagation();\n expandState[expandKey] = !isExpanded;\n saveExpandState();\n // Re-render the tree\n const treeContainer = document.getElementById(\"folder-tree\");\n if (treeContainer) loadFolderTree(treeContainer);\n });\n } else {\n toggle.textContent = \" \";\n }\n folderEl.appendChild(toggle);\n\n const freshnessDot = document.createElement(\"span\");\n freshnessDot.className = \"ft-freshness\";\n freshnessDot.setAttribute(\"aria-hidden\", \"true\");\n folderEl.appendChild(freshnessDot);\n\n const nameSpan = document.createElement(\"span\");\n nameSpan.className = \"ft-folder-name\";\n nameSpan.textContent = node.name;\n folderEl.appendChild(nameSpan);\n\n const syncedAt = getFolderSynced(node.accountId, node.id);\n if (syncedAt) applyFreshness(folderEl, syncedAt);\n\n const isOutbox = node.specialUse === \"outbox\" || node.path.toLowerCase() === \"outbox\";\n if (isOutbox && node.totalCount > 0) {\n // Outbox: show total (pending) count with warning style\n const badge = document.createElement(\"span\");\n badge.className = \"ft-badge ft-badge-outbox\";\n badge.textContent = String(node.totalCount);\n badge.title = `${node.totalCount} pending`;\n folderEl.appendChild(badge);\n } else if (node.unreadCount > 0) {\n const badge = document.createElement(\"span\");\n badge.className = \"ft-badge\";\n badge.textContent = String(node.unreadCount);\n badge.title = `${node.unreadCount} unread`;\n folderEl.appendChild(badge);\n }\n // Total count (shown when View > Folder counts is checked)\n if (node.totalCount > 0) {\n const total = document.createElement(\"span\");\n total.className = \"ft-total-count\";\n total.textContent = String(node.totalCount);\n total.title = `${node.totalCount} total messages`;\n folderEl.appendChild(total);\n }\n\n folderEl.addEventListener(\"click\", () => {\n if (node.id === -1) {\n // Virtual parent \u2014 toggle expand instead of selecting\n expandState[expandKey] = !isExpanded;\n saveExpandState();\n const treeContainer = document.getElementById(\"folder-tree\");\n if (treeContainer) loadFolderTree(treeContainer);\n return;\n }\n if (selectedElement) selectedElement.classList.remove(\"selected\");\n folderEl.classList.add(\"selected\");\n selectedElement = folderEl;\n selectedAccountId = node.accountId;\n selectedFolderId = node.id;\n onFolderSelect(node.accountId, node.id, node.name, node.specialUse || node.path.toLowerCase());\n });\n\n // \u2500\u2500 Right-click context menu \u2500\u2500\n folderEl.addEventListener(\"contextmenu\", (e) => {\n e.preventDefault();\n e.stopPropagation();\n const isTrash = node.specialUse === \"trash\" || node.path.toLowerCase().includes(\"trash\");\n const isJunk = node.specialUse === \"junk\" || node.path.toLowerCase().includes(\"spam\") || node.path.toLowerCase().includes(\"junk\");\n\n const items: MenuItem[] = [\n { label: \"Open in new tab\", action: () => {\n openTab(\n { kind: \"folder\", accountId: node.accountId, folderId: node.id, specialUse: node.specialUse || \"\" },\n node.name,\n true,\n );\n }},\n { label: \"\", action: () => {}, separator: true },\n { label: \"Mark all read\", action: async () => {\n try {\n await markFolderRead(node.accountId, node.id);\n const treeContainer = document.getElementById(\"folder-tree\");\n if (treeContainer) loadFolderTree(treeContainer);\n } catch { /* ignore */ }\n }},\n { label: \"\", action: () => {}, separator: true },\n { label: \"New subfolder...\", action: async () => {\n const name = prompt(\"New folder name:\");\n if (!name) return;\n try {\n await createFolder(node.accountId, node.path, name);\n // Auto-expand the parent so the just-created child is\n // visible immediately. Without this, a collapsed parent\n // hides the new folder until the user clicks the\n // disclosure triangle \u2014 looked like CREATE silently\n // failed, which it hadn't.\n expandState[`${node.accountId}:${node.path}`] = true;\n saveExpandState();\n const treeContainer = document.getElementById(\"folder-tree\");\n if (treeContainer) loadFolderTree(treeContainer);\n } catch (err: any) { alert(`Failed: ${err.message}`); }\n }},\n { label: \"Rename...\", action: async () => {\n const newName = prompt(\"Rename folder:\", node.name);\n if (!newName || newName === node.name) return;\n try {\n await renameFolder(node.accountId, node.id, newName);\n const treeContainer = document.getElementById(\"folder-tree\");\n if (treeContainer) loadFolderTree(treeContainer);\n } catch (err: any) { alert(`Failed: ${err.message}`); }\n }, disabled: !!node.specialUse },\n // Two delete entries. Move-to-Trash is the default; permanent\n // delete is a second, separated item with a tooltip so it's\n // visible from the menu rather than requiring a discovery\n // shortcut. The IMAP RENAME under Trash brings messages +\n // subfolders along; the server-side fallback (when Trash is\n // \\Noinferiors) moves messages to Trash root and then deletes\n // the empty folder. Permanent skips Trash entirely.\n { label: \"Move folder to Trash\", action: async () => {\n if (!confirm(`Move folder \"${node.name}\" to Trash? It can be restored by dragging back out of Trash.`)) return;\n try {\n await moveFolderToTrash(node.accountId, node.id);\n const treeContainer = document.getElementById(\"folder-tree\");\n if (treeContainer) loadFolderTree(treeContainer);\n } catch (err: any) { alert(`Failed: ${err.message}`); }\n }, disabled: !!node.specialUse,\n tooltip: \"Renames the folder into Trash (date-suffixed if needed); use Delete permanently below to skip Trash.\" },\n { label: \"Delete folder permanently\", action: async () => {\n if (!confirm(`Permanently delete folder \"${node.name}\" and ALL its messages? This cannot be undone.`)) return;\n try {\n await deleteFolder(node.accountId, node.id);\n const treeContainer = document.getElementById(\"folder-tree\");\n if (treeContainer) loadFolderTree(treeContainer);\n } catch (err: any) { alert(`Failed: ${err.message}`); }\n }, disabled: !!node.specialUse,\n tooltip: \"Skips Trash. Same as Shift+Delete on a regular file. No undo.\" },\n { label: \"\", action: () => {}, separator: true },\n // Q57: copy IMAP path so user can paste into accounts.jsonc as\n // a spam/sent/drafts/trash hint without retyping case-sensitively.\n { label: \"Copy folder path\", action: async () => {\n try {\n await navigator.clipboard.writeText(node.path);\n const status = document.getElementById(\"status-sync\");\n if (status) status.textContent = `Copied: ${node.path}`;\n } catch {\n prompt(\"Folder path:\", node.path);\n }\n }},\n ];\n\n if (isTrash || isJunk) {\n items.push({ label: \"\", action: () => {}, separator: true });\n items.push({ label: `Empty ${node.name}`, action: async () => {\n if (!confirm(`Permanently delete all messages in \"${node.name}\"?`)) return;\n try {\n await emptyFolder(node.accountId, node.id);\n const { setMessages } = await import(\"../lib/message-state.js\");\n setMessages([]); // Folder emptied \u2014 clear list and viewer\n const treeContainer = document.getElementById(\"folder-tree\");\n if (treeContainer) loadFolderTree(treeContainer);\n } catch (err: any) { alert(`Failed: ${err.message}`); }\n }});\n }\n\n showContextMenu(e.clientX, e.clientY, items);\n });\n\n // \u2500\u2500 Drop target for message drag-and-drop \u2500\u2500\n if (node.id !== -1) {\n let dragExpandTimer: ReturnType<typeof setTimeout> | null = null;\n folderEl.addEventListener(\"dragover\", (e) => {\n e.preventDefault();\n e.dataTransfer!.dropEffect = e.ctrlKey ? \"copy\" : \"move\";\n folderEl.classList.add(\"drop-target\");\n });\n folderEl.addEventListener(\"dragenter\", () => {\n if (hasChildren && !isExpanded && !dragExpandTimer) {\n dragExpandTimer = setTimeout(() => {\n dragExpandTimer = null;\n expandState[expandKey] = true;\n // In-drag expand is transient: don't persist it to\n // localStorage so closing the tree returns to user's\n // explicit collapsed state. saveExpandState() only runs\n // for explicit toggles below.\n dragAutoExpanded.add(expandKey);\n const treeContainer = document.getElementById(\"folder-tree\");\n if (treeContainer) loadFolderTree(treeContainer);\n }, DRAG_HOVER_EXPAND_MS);\n }\n });\n folderEl.addEventListener(\"dragleave\", () => {\n folderEl.classList.remove(\"drop-target\");\n if (dragExpandTimer) { clearTimeout(dragExpandTimer); dragExpandTimer = null; }\n });\n folderEl.addEventListener(\"drop\", async (e) => {\n e.preventDefault();\n folderEl.classList.remove(\"drop-target\");\n if (dragExpandTimer) { clearTimeout(dragExpandTimer); dragExpandTimer = null; }\n\n // Multi-message or single-message drop\n const multiData = e.dataTransfer!.getData(\"application/x-mailx-messages\");\n const singleData = e.dataTransfer!.getData(\"application/x-mailx-message\");\n const messages: { accountId: string; uid: number; folderId: number }[] =\n multiData ? JSON.parse(multiData) : singleData ? [JSON.parse(singleData)] : [];\n\n // Filter: not already in target folder\n const toMove = messages.filter(m => m.folderId !== node.id || m.accountId !== node.accountId);\n if (toMove.length === 0) return;\n\n const statusEl = document.getElementById(\"status-sync\");\n const crossAccount = toMove.some(m => m.accountId !== node.accountId);\n // Local-first: optimistic remove + Ctrl+Z entry FIRST, then\n // fire IPC without awaiting. Old order awaited the daemon\n // round-trip before removing rows; if simpleParser had the\n // event loop saturated the move \"stuck\" on the drop target\n // for the full mailxapi 120s timeout before the rows finally\n // disappeared. Service-side moveMessages is itself local-first\n // (sync DB commit + queued IMAP), so the IPC ack adds nothing.\n const moved = toMove.length;\n if (statusEl) statusEl.textContent = `Moved ${moved} message${moved > 1 ? \"s\" : \"\"} to ${node.name} \u2014 Ctrl+Z to undo`;\n const { removeMessagesAndReconcile } = await import(\"./message-list.js\");\n removeMessagesAndReconcile(toMove);\n const treeContainer = document.getElementById(\"folder-tree\");\n if (treeContainer) loadFolderTree(treeContainer);\n document.dispatchEvent(new CustomEvent(\"mailx-moved\", {\n detail: {\n messages: toMove.map(m => ({ accountId: m.accountId, uid: m.uid, sourceFolderId: m.folderId })),\n targetAccountId: node.accountId,\n targetFolderId: node.id,\n },\n }));\n // Fire the IPC(s); surface only real service-side failures.\n const onErr = (err: any) => {\n console.error(`Move failed: ${err?.message || err}`);\n if (statusEl) statusEl.textContent = `Move sync issue: ${err?.message || err}`;\n };\n if (crossAccount) {\n for (const msg of toMove) {\n const targetAccountId = msg.accountId !== node.accountId ? node.accountId : undefined;\n moveMessage(msg.accountId, msg.uid, node.id, targetAccountId).catch(onErr);\n }\n } else {\n const accountId = toMove[0].accountId;\n const uids = toMove.map(m => m.uid);\n moveMessages(accountId, uids, node.id).catch(onErr);\n }\n });\n }\n\n container.appendChild(folderEl);\n\n // Render children if expanded\n if (hasChildren && isExpanded) {\n for (const child of node.children) {\n renderNode(child, container, depth + 1);\n }\n }\n}\n\nexport function initFolderTree(container: HTMLElement, handler: FolderSelectHandler, unifiedHandler?: UnifiedHandler): void {\n onFolderSelect = handler;\n onUnifiedInbox = unifiedHandler || null;\n loadFolderTree(container);\n}\n\n// Spring-loaded folders (Q117): collapse any auto-expanded folders when the\n// drag ends, regardless of where it dropped. The user got the visual feedback\n// they needed; if the drop landed in a child folder the move already happened.\n// Restoring the prior state matches Outlook/Finder behavior.\ndocument.addEventListener(\"dragstart\", (e) => {\n const dt = e.dataTransfer;\n if (!dt) return;\n // Only track drags that carry a mailx message payload \u2014 ignore unrelated\n // drag interactions (image drag, text selection, etc.).\n const types = dt.types || [];\n if (!Array.from(types).some(t => t.startsWith(\"application/x-mailx-\"))) return;\n dragInFlight = true;\n});\ndocument.addEventListener(\"dragend\", () => {\n if (!dragInFlight) return;\n dragInFlight = false;\n if (dragAutoExpanded.size === 0) return;\n for (const key of dragAutoExpanded) delete expandState[key];\n dragAutoExpanded.clear();\n const treeContainer = document.getElementById(\"folder-tree\");\n if (treeContainer) loadFolderTree(treeContainer);\n});\n\n// Item 12: outbox total drives a synthesized \"Send-pending\" row at the top\n// of the folder tree. The server pushes outboxStatus on every mutation; when\n// the total flips between zero and non-zero we re-render so the row appears\n// / disappears without waiting for the next full refresh.\nlet lastOutboxTotal = 0;\nexport function setOutboxTotal(total: number): void {\n const prev = lastOutboxTotal;\n lastOutboxTotal = total | 0;\n // Zero \u2192 zero: nothing to render, nothing to clear.\n if (prev === 0 && lastOutboxTotal === 0) return;\n const existing = document.getElementById(\"ft-send-pending\") as HTMLElement;\n // Non-zero in both \u2192 just update the badge text; avoid a full re-render.\n if (prev > 0 && lastOutboxTotal > 0 && existing) {\n const badge = existing.querySelector<HTMLElement>(\".ft-badge\");\n if (badge) badge.textContent = String(lastOutboxTotal);\n existing.title = `${lastOutboxTotal} message${lastOutboxTotal === 1 ? \"\" : \"s\"} queued for send`;\n return;\n }\n // Presence flipped (0\u2192N or N\u21920) \u2014 re-render to insert / remove the row.\n const container = document.getElementById(\"folder-tree\");\n if (container) loadFolderTree(container);\n}\n\nasync function loadFolderTree(container: HTMLElement): Promise<void> {\n // Show loading state while preserving existing tree (if any) on refresh\n const hadContent = container.children.length > 0 && !container.querySelector(\".folder-loading\");\n if (!hadContent) {\n container.innerHTML = `<div class=\"folder-loading\">Loading accounts...</div>`;\n }\n\n try {\n const accounts = await getAccounts();\n // The instant getAccounts answers, the IPC bridge is alive. Dismiss\n // the startup overlay NOW \u2014 don't make the user\n // stare at a spinner while we go on to fetch every account's folder\n // list (which is the actual paint of the tree, but it doesn't\n // belong on top of a global modal). The tree's own\n // \".folder-loading\" inline state covers the residual fetch.\n const earlyOverlay = document.getElementById(\"startup-overlay\");\n if (earlyOverlay) { earlyOverlay.classList.add(\"hidden\"); setTimeout(() => earlyOverlay.remove(), 400); }\n // No polling. The daemon now serves IPC immediately with the local\n // accounts.jsonc cache (cloud refresh is fire-and-forget post-launch),\n // so the first getAccounts call returns the real list. If it returns\n // empty, that means truly no accounts are configured \u2014 show the\n // setup form (handled below). When the user adds an account or\n // accounts.jsonc changes from another device, the daemon emits\n // `configChanged` and the UI picks it up via refreshFolderTree.\n\n if (accounts.length === 0) {\n container.innerHTML = `<div class=\"folder-loading\">No accounts</div>`;\n // Hide the message list and show setup in the viewer pane (full width)\n const mlSection = document.querySelector(\".message-list\") as HTMLElement;\n if (mlSection) mlSection.style.display = \"none\";\n const splitter = document.getElementById(\"splitter-h\");\n if (splitter) splitter.style.display = \"none\";\n const mvHeader = document.getElementById(\"mv-header\");\n if (mvHeader) mvHeader.style.display = \"none\";\n const mainBody = document.getElementById(\"mv-body\");\n if (mainBody) {\n const isAndroid = (window as any).mailxapi?.platform === \"android\";\n const formDisplay = isAndroid ? \"display:none;\" : \"\";\n const introText = isAndroid ? \"\" : \"Add your email account to get started.\";\n const checkingHtml = isAndroid ? '<div style=\"padding:0.5rem;color:var(--color-text-muted)\">Checking for accounts...</div>' : \"\";\n const gmailNote = isAndroid ? \"\" : `<p style=\"margin-top:0.5rem;padding:0.5rem 0.75rem;background:color-mix(in oklch, var(--color-accent) 12%, var(--color-bg-surface));border:1px solid var(--color-border);border-radius:4px;font-size:0.9rem\">For now, a <strong>Gmail</strong> or Google Workspace account is required. Support for other providers is coming.</p>`;\n mainBody.innerHTML = `<div style=\"padding:2rem;line-height:1.8;max-width:500px\">\n <h2 style=\"margin-bottom:1rem\">Welcome to mailx</h2>\n <div id=\"setup-device-accounts\">${checkingHtml}</div>\n <div id=\"setup-cloud-status\"></div>\n <p id=\"setup-form-intro\">${introText}</p>\n ${gmailNote}\n <form id=\"setup-form\" style=\"margin-top:1rem;${formDisplay}\">\n <label style=\"display:block;margin-bottom:0.5rem\">\n Email address\n <input id=\"setup-email\" type=\"email\" placeholder=\"you@gmail.com\" required style=\"display:block;width:100%;padding:0.5rem;margin-top:0.25rem;background:var(--color-bg-surface);color:var(--color-text);border:1px solid var(--color-border);border-radius:4px\">\n </label>\n <div id=\"setup-provider-preview\" style=\"display:none;margin-bottom:0.5rem;padding:0.4rem 0.6rem;background:var(--color-bg-surface);border:1px solid var(--color-border);border-radius:4px;font-size:0.9rem\">\n <span id=\"setup-provider-icon\" style=\"display:inline-block;width:1.2em;text-align:center;margin-right:0.4em\"></span><span id=\"setup-provider-label\"></span>\n </div>\n <label id=\"setup-name-row\" style=\"display:none;margin-bottom:0.5rem\">\n Your name <span style=\"color:var(--color-text-muted);font-size:0.85rem\">(optional \u2014 auto-detected from Google)</span>\n <input id=\"setup-name\" type=\"text\" placeholder=\"Your Name (leave blank to use Google profile)\" style=\"display:block;width:100%;padding:0.5rem;margin-top:0.25rem;background:var(--color-bg-surface);color:var(--color-text);border:1px solid var(--color-border);border-radius:4px\">\n </label>\n <label id=\"setup-password-row\" style=\"display:none;margin-bottom:0.5rem\">\n Password\n <input id=\"setup-password\" type=\"password\" placeholder=\"password\" style=\"display:block;width:100%;padding:0.5rem;margin-top:0.25rem;background:var(--color-bg-surface);color:var(--color-text);border:1px solid var(--color-border);border-radius:4px\">\n <div id=\"setup-app-password-help\" style=\"display:none;margin-top:0.25rem;font-size:0.8rem;color:var(--color-text-muted)\"></div>\n </label>\n <button id=\"setup-submit\" type=\"submit\" style=\"display:none;margin-top:1rem;padding:0.5rem 2rem;background:var(--color-accent);color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:1rem\">Add Account</button>\n <div id=\"setup-status\" style=\"margin-top:1rem;color:var(--color-text-muted)\"></div>\n </form>\n <details style=\"margin-top:2rem;color:var(--color-text-muted)\">\n <summary>Manual setup (advanced)</summary>\n <p style=\"margin-top:0.5rem\">Create <code>~/.rmfmail/config.jsonc</code> with a cloud provider:</p>\n <code style=\"display:block;padding:0.75rem;background:var(--color-bg-surface);border:1px solid var(--color-border);border-radius:4px;margin:0.5rem 0;white-space:pre;font-size:0.85rem\">{ \"sharedDir\": { \"provider\": \"gdrive\", \"path\": \"rmfmail\" } }</code>\n <p style=\"margin-top:0.5rem;font-size:0.85rem\">Settings sync via Google Drive API (auto-configured for Gmail accounts).</p>\n </details>\n </div>`;\n // Wire up the setup form\n const form = document.getElementById(\"setup-form\") as HTMLFormElement;\n const emailInput = document.getElementById(\"setup-email\") as HTMLInputElement;\n const statusEl = document.getElementById(\"setup-status\")!;\n // Hide password for OAuth providers, show app-password help for others\n const APP_PASSWORD_HELP: Record<string, string> = {\n \"yahoo.com\": \"Use an app password: Yahoo Settings \u2192 Account Security \u2192 Generate app password\",\n \"aol.com\": \"Use an app password: AOL Settings \u2192 Account Security \u2192 Generate app password\",\n \"icloud.com\": \"Use an app-specific password: appleid.apple.com \u2192 Sign-In and Security \u2192 App-Specific Passwords\",\n };\n // Q67: describe the detected provider so the user knows which\n // auto-config path we're about to take BEFORE they hit Next.\n // Gmail / Google Workspace domains auto-detect via MX in the\n // service; here we can only name the known ones up front and\n // say \"will auto-detect\" for everything else.\n const PROVIDER_PREVIEW: Record<string, { icon: string; label: string }> = {\n \"gmail.com\": { icon: \"\u2709\", label: \"Gmail \u2014 OAuth (no password needed)\" },\n \"googlemail.com\": { icon: \"\u2709\", label: \"Gmail \u2014 OAuth (no password needed)\" },\n \"outlook.com\": { icon: \"\u2709\", label: \"Outlook.com \u2014 OAuth (no password needed)\" },\n \"hotmail.com\": { icon: \"\u2709\", label: \"Outlook.com \u2014 OAuth (no password needed)\" },\n \"live.com\": { icon: \"\u2709\", label: \"Outlook.com \u2014 OAuth (no password needed)\" },\n \"yahoo.com\": { icon: \"\u2709\", label: \"Yahoo Mail \u2014 IMAP (needs app password)\" },\n \"aol.com\": { icon: \"\u2709\", label: \"AOL Mail \u2014 IMAP (needs app password)\" },\n \"icloud.com\": { icon: \"\u2709\", label: \"iCloud Mail \u2014 IMAP (needs app-specific password)\" },\n \"me.com\": { icon: \"\u2709\", label: \"iCloud Mail \u2014 IMAP (needs app-specific password)\" },\n \"mac.com\": { icon: \"\u2709\", label: \"iCloud Mail \u2014 IMAP (needs app-specific password)\" },\n };\n let oauthAutoFired = false;\n emailInput?.addEventListener(\"input\", () => {\n const email = emailInput.value.trim();\n const domain = email.split(\"@\")[1]?.toLowerCase() || \"\";\n const hasAt = email.includes(\"@\") && domain.length > 0;\n const isOAuth = [\"gmail.com\", \"googlemail.com\", \"outlook.com\", \"hotmail.com\", \"live.com\"].includes(domain);\n const isGmailLike = [\"gmail.com\", \"googlemail.com\"].includes(domain);\n // Provider preview row\n const preview = document.getElementById(\"setup-provider-preview\");\n const icon = document.getElementById(\"setup-provider-icon\");\n const label = document.getElementById(\"setup-provider-label\");\n if (preview && icon && label) {\n if (hasAt) {\n const hit = PROVIDER_PREVIEW[domain];\n icon.textContent = hit ? hit.icon : \"\u2753\";\n label.textContent = hit ? hit.label : `${domain} \u2014 will auto-detect via MX records`;\n preview.style.display = \"block\";\n } else {\n preview.style.display = \"none\";\n }\n }\n // OAuth providers: auto-fire setup immediately once domain\n // is recognized \u2014 don't show name/password (name is auto-\n // detected from Google profile, no password needed). This\n // eliminates the \"form flash\" where fields briefly appear\n // before the page reloads.\n if (hasAt && isOAuth && !oauthAutoFired && !setupTriggered) {\n oauthAutoFired = true;\n statusEl.textContent = `Connecting to ${isGmailLike ? \"Gmail\" : \"Outlook\"}...`;\n trySetup();\n return;\n }\n // Non-OAuth: progressive reveal of name + password + submit.\n const nameRow = document.getElementById(\"setup-name-row\");\n const pwRow = document.getElementById(\"setup-password-row\");\n const submitBtn = document.getElementById(\"setup-submit\");\n if (nameRow) nameRow.style.display = hasAt && !isOAuth ? \"block\" : \"none\";\n if (pwRow) pwRow.style.display = hasAt && !isOAuth ? \"block\" : \"none\";\n if (submitBtn) submitBtn.style.display = hasAt && !isOAuth ? \"block\" : \"none\";\n const helpEl = document.getElementById(\"setup-app-password-help\");\n if (helpEl) {\n const help = APP_PASSWORD_HELP[domain];\n helpEl.style.display = help ? \"block\" : \"none\";\n helpEl.textContent = help || \"\";\n }\n });\n // When a valid email is entered, try setup immediately \u2014 if cloud has\n // existing accounts, loads them without needing name/password\n let setupTriggered = false;\n async function trySetup(): Promise<void> {\n const email = emailInput.value.trim();\n if (!email || !email.includes(\"@\") || setupTriggered) return;\n const name = (document.getElementById(\"setup-name\") as HTMLInputElement).value.trim();\n const password = (document.getElementById(\"setup-password\") as HTMLInputElement).value;\n setupTriggered = true;\n statusEl.textContent = \"Checking for existing accounts...\";\n try {\n const data = await setupAccount(name, email, password);\n if (data.ok) {\n // Reload immediately. The status text was a\n // dwell-time band-aid around the user's\n // perception that setup \"didn't complete\";\n // the real fix is for setup itself to be\n // fast enough that the result is visible\n // through the new render. setupAccount's own\n // 20 s+ of OAuth/GDrive work has already\n // happened by the time we get here \u2014 adding\n // 7 s of \"look at this status\" on top is\n // pure latency.\n location.reload();\n } else {\n setupTriggered = false;\n statusEl.style.color = \"#f55\";\n statusEl.textContent = `Error: ${data.error || \"Setup failed\"}`;\n }\n } catch (err: any) {\n setupTriggered = false;\n statusEl.style.color = \"#f55\";\n statusEl.textContent = `Error: ${err.message}`;\n }\n }\n form?.addEventListener(\"submit\", async (e) => {\n e.preventDefault();\n await trySetup();\n });\n // Auto-trigger for OAuth providers when email looks complete\n emailInput?.addEventListener(\"change\", async () => {\n const domain = emailInput.value.split(\"@\")[1]?.toLowerCase() || \"\";\n const isOAuth = [\"gmail.com\", \"googlemail.com\", \"outlook.com\", \"hotmail.com\", \"live.com\"].includes(domain);\n if (isOAuth) await trySetup();\n });\n // Show cloud storage status in setup form\n getVersion().then((d: any) => {\n const cloudEl = document.getElementById(\"setup-cloud-status\");\n if (!cloudEl) return;\n const s = d.storage || {};\n if (s.cloudError) {\n cloudEl.innerHTML = `<div style=\"padding:0.75rem;margin-bottom:1rem;background:#5c1a1a;color:#fca;border:1px solid #a33;border-radius:4px\">\n <strong>Cloud storage unavailable:</strong> ${s.cloudError}<br>\n <span style=\"font-size:0.85rem\">Settings on ${s.provider || \"cloud\"} cannot be read. Add an account below to initialize cloud storage.</span>\n </div>`;\n } else if (s.mode === \"api\") {\n cloudEl.innerHTML = `<div style=\"padding:0.5rem;margin-bottom:1rem;background:var(--color-bg-surface);border:1px solid var(--color-border);border-radius:4px;color:var(--color-text-muted)\">\n Using ${s.provider} API (no local mount)\n </div>`;\n }\n }).catch(() => {});\n // On Android, check for device Google accounts\n getDeviceAccounts().then(async (deviceAccounts) => {\n const pickerEl = document.getElementById(\"setup-device-accounts\");\n if (!pickerEl) return;\n if (deviceAccounts.length === 0) {\n // No device accounts \u2014 show the form\n pickerEl.innerHTML = \"\";\n const f = document.getElementById(\"setup-form\") as HTMLElement;\n const i = document.getElementById(\"setup-form-intro\") as HTMLElement;\n if (f) f.style.display = \"block\";\n if (i) i.textContent = \"Add your email account to get started.\";\n return;\n }\n const formEl = document.getElementById(\"setup-form\") as HTMLElement;\n const introEl = document.getElementById(\"setup-form-intro\") as HTMLElement;\n\n // Auto-setup helper\n async function autoSetup(email: string, name: string): Promise<void> {\n if (introEl) introEl.textContent = \"\";\n if (formEl) formEl.style.display = \"none\";\n pickerEl.innerHTML = `<div style=\"padding:0.5rem;color:var(--color-text-muted)\">Setting up ${email}...</div>`;\n const result = await setupAccount(name, email, \"\");\n if (result?.ok) {\n // Reload immediately \u2014 the dwell-time was a\n // perception band-aid, same removal as the\n // explicit setup form's reload above.\n location.reload();\n } else {\n pickerEl.innerHTML = `<div style=\"padding:0.5rem;color:#f55\">${result?.error || \"Setup failed\"}</div>`;\n if (formEl) formEl.style.display = \"block\";\n }\n }\n\n // One account \u2014 auto-select it\n if (deviceAccounts.length === 1) {\n await autoSetup(deviceAccounts[0].email, deviceAccounts[0].name);\n return;\n }\n\n // Multiple accounts \u2014 show picker\n if (introEl) introEl.textContent = \"Select an account:\";\n if (formEl) formEl.style.display = \"none\";\n pickerEl.innerHTML = deviceAccounts.map((a: { email: string; name: string }) =>\n `<button class=\"device-account-btn\" data-email=\"${a.email}\" data-name=\"${a.name}\" style=\"display:block;width:100%;padding:0.75rem 1rem;margin-bottom:0.5rem;background:var(--color-accent);color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:1rem;text-align:left\">Use ${a.email}</button>`\n ).join(\"\") + `<button id=\"setup-show-form\" style=\"margin-top:0.5rem;padding:0.5rem 1rem;background:none;color:var(--color-text-muted);border:none;cursor:pointer;font-size:0.9rem\">Use a different account...</button>`;\n pickerEl.querySelectorAll(\".device-account-btn\").forEach((btn: Element) => {\n btn.addEventListener(\"click\", async () => {\n await autoSetup((btn as HTMLElement).dataset.email || \"\", (btn as HTMLElement).dataset.name || \"\");\n });\n });\n document.getElementById(\"setup-show-form\")?.addEventListener(\"click\", () => {\n pickerEl.style.display = \"none\";\n if (formEl) formEl.style.display = \"block\";\n if (introEl) introEl.textContent = \"Add your email account to get started.\";\n });\n }).catch(() => {\n // Bridge failed \u2014 show the form\n const p = document.getElementById(\"setup-device-accounts\");\n if (p) p.innerHTML = \"\";\n const f = document.getElementById(\"setup-form\") as HTMLElement;\n const i = document.getElementById(\"setup-form-intro\") as HTMLElement;\n if (f) f.style.display = \"block\";\n if (i) i.textContent = \"Add your email account to get started.\";\n });\n }\n // Dismiss startup overlay\n const overlay = document.getElementById(\"startup-overlay\");\n if (overlay) { overlay.classList.add(\"hidden\"); setTimeout(() => overlay.remove(), 400); }\n return;\n }\n\n // Fetch ALL account folder data in parallel BEFORE touching the DOM\n const accountFolderData: { account: any; folders: any[] }[] = await Promise.all(\n accounts.map(async (account: any) => {\n const accountKey = `account:${account.id}`;\n const accountExpanded = expandState[accountKey] !== false;\n const folders = accountExpanded ? await getFolders(account.id) : [];\n return { account, folders };\n })\n );\n\n // Save scroll position before rebuild\n const savedScroll = container.scrollTop;\n\n // Build entire new tree into a DocumentFragment (off-screen, no reflows)\n const fragment = document.createDocumentFragment();\n\n // Unified Inbox \u2014 always shown so startup auto-selects it consistently\n // (with one account it's effectively that account's INBOX, but the UI\n // stays uniform so the auto-select path doesn't fork on account count)\n if (accounts.length >= 1) {\n const unifiedEl = document.createElement(\"div\");\n unifiedEl.className = \"ft-folder ft-unified\";\n unifiedEl.title = accounts.length > 1\n ? \"Merged inbox view of all accounts \u2014 click to see messages from every account's INBOX sorted by date\"\n : \"Inbox view across all your accounts\";\n unifiedEl.innerHTML = `<span class=\"ft-toggle\"> </span><span class=\"ft-folder-name\">All Inboxes</span>`;\n unifiedEl.addEventListener(\"click\", () => {\n if (selectedElement) selectedElement.classList.remove(\"selected\");\n unifiedEl.classList.add(\"selected\");\n selectedElement = unifiedEl;\n selectedAccountId = null;\n selectedFolderId = -1;\n if (onUnifiedInbox) onUnifiedInbox();\n });\n fragment.appendChild(unifiedEl);\n }\n\n // Item 12: Send-pending virtual row \u2014 synthesized from the outbox\n // queue, only shown when something is actually queued. Clicking\n // opens the outbox-view modal (pink rows, cancellable). Lives at\n // the top of the tree so a stuck send is impossible to miss.\n if (lastOutboxTotal > 0) {\n const pendingEl = document.createElement(\"div\");\n pendingEl.className = \"ft-folder ft-unified ft-send-pending\";\n pendingEl.id = \"ft-send-pending\";\n pendingEl.title = `${lastOutboxTotal} message${lastOutboxTotal === 1 ? \"\" : \"s\"} queued for send`;\n pendingEl.innerHTML = `<span class=\"ft-toggle\"> </span><span class=\"ft-folder-name\">Send-pending</span><span class=\"ft-badge ft-badge-outbox\">${lastOutboxTotal}</span>`;\n pendingEl.addEventListener(\"click\", async () => {\n try {\n const { openOutboxView } = await import(\"./outbox-view.js\");\n openOutboxView();\n } catch { /* outbox-view load failed \u2014 silent is OK, status pill still works */ }\n });\n fragment.appendChild(pendingEl);\n }\n\n // When two accounts share the same display name (e.g. both `bobma`\n // and `gmail` set name=\"Bob Frankston\"), the folder tree previously\n // showed two identical \"Bob Frankston\" headers and the user had no\n // way to tell which was which. Detect collisions on the displayed\n // label and append the email (or account id) to the duplicates so\n // each header is unique.\n const labelOf = (a: any): string => a.label || a.name || a.id;\n const labelCounts = new Map<string, number>();\n for (const { account } of accountFolderData) {\n const l = labelOf(account);\n labelCounts.set(l, (labelCounts.get(l) || 0) + 1);\n }\n for (const { account, folders } of accountFolderData) {\n const accountEl = document.createElement(\"div\");\n accountEl.className = \"ft-account\";\n\n const accountKey = `account:${account.id}`;\n const accountExpanded = expandState[accountKey] !== false; // accounts default expanded\n\n const header = document.createElement(\"div\");\n header.className = \"ft-account-header\";\n const baseLabel = labelOf(account);\n const isDup = (labelCounts.get(baseLabel) || 0) > 1;\n const disambiguator = isDup ? ` (${(account as any).email || account.id})` : \"\";\n header.textContent = `${accountExpanded ? \"\u25BE\" : \"\u25B8\"} ${baseLabel}${disambiguator}`;\n header.addEventListener(\"click\", () => {\n expandState[accountKey] = !accountExpanded;\n saveExpandState();\n const treeContainer = document.getElementById(\"folder-tree\");\n if (treeContainer) loadFolderTree(treeContainer);\n });\n\n // Right-click: full menu instead of plain toggle (Q54).\n header.addEventListener(\"contextmenu\", (e) => {\n e.preventDefault();\n e.stopPropagation();\n const items: MenuItem[] = [\n { label: \"Mark all read (account)\", action: async () => {\n const folderRows = folders.slice();\n for (const f of folderRows) {\n try { await markFolderRead(account.id, f.id); } catch { /* keep going */ }\n }\n const tc = document.getElementById(\"folder-tree\");\n if (tc) loadFolderTree(tc);\n }},\n { label: \"\", action: () => {}, separator: true },\n { label: \"Expand all folders\", action: () => {\n const keys = Object.keys(expandState).filter(k => k.startsWith(`${account.id}:`));\n for (const k of keys) expandState[k] = true;\n expandState[accountKey] = true;\n saveExpandState();\n const tc = document.getElementById(\"folder-tree\");\n if (tc) loadFolderTree(tc);\n }},\n { label: \"Collapse all folders\", action: () => {\n const keys = Object.keys(expandState).filter(k => k.startsWith(`${account.id}:`));\n for (const k of keys) expandState[k] = false;\n expandState[accountKey] = false;\n saveExpandState();\n const tc = document.getElementById(\"folder-tree\");\n if (tc) loadFolderTree(tc);\n }},\n { label: \"\", action: () => {}, separator: true },\n { label: \"Sync this account now\", action: async () => {\n try { await syncAccount(account.id); } catch (err: any) { alert(`Sync failed: ${err?.message || err}`); }\n }},\n ];\n showContextMenu(e.clientX, e.clientY, items);\n });\n\n accountEl.appendChild(header);\n\n if (accountExpanded && folders.length > 0) {\n const delimiter = folders[0]?.delimiter || \".\";\n const tree = buildTree(folders, delimiter, account.id);\n sortFolders(tree);\n\n // Case-duplicate detection: fold folder paths to lowercase and\n // flag any whose form matches another. Common with servers that\n // let users create `Archive` and `archive` as distinct folders,\n // or `Sent Items` alongside a `Sent items` rename gone sideways.\n // A \u26A0 glyph on the affected rows lets the user notice before\n // losing mail to the wrong one.\n const lowerCounts = new Map<string, number>();\n for (const f of folders) {\n const key = (f.path || \"\").toLowerCase();\n lowerCounts.set(key, (lowerCounts.get(key) || 0) + 1);\n }\n const duplicatePaths = new Set<string>();\n for (const [k, c] of lowerCounts) if (c > 1) duplicatePaths.add(k);\n\n for (const node of tree) {\n renderNode(node, accountEl, 1);\n }\n\n if (duplicatePaths.size > 0) {\n accountEl.querySelectorAll<HTMLElement>(\".ft-folder\").forEach(el => {\n const p = (el.dataset.folderPath || \"\").toLowerCase();\n if (duplicatePaths.has(p)) {\n el.classList.add(\"ft-folder-duplicate\");\n el.title = (el.title ? el.title + \" \u2014 \" : \"\") +\n \"Case-duplicate folder name on the server (another folder with the same name in different case exists)\";\n }\n });\n }\n }\n\n fragment.appendChild(accountEl);\n }\n\n // Atomic swap \u2014 single reflow, no intermediate empty state\n container.replaceChildren(fragment);\n\n // Restore scroll position\n container.scrollTop = savedScroll;\n\n // Re-select previous folder, or auto-select on first load\n const allFolderEls = container.querySelectorAll('.ft-folder');\n let target: HTMLElement | null = null;\n\n if (selectedFolderId === -1) {\n // Unified inbox was selected \u2014 just re-highlight it, don't click\n const unified = container.querySelector('.ft-unified') as HTMLElement;\n if (unified) {\n unified.classList.add(\"selected\");\n selectedElement = unified;\n target = unified;\n }\n } else if (selectedAccountId && selectedFolderId !== null && selectedFolderId >= 0) {\n for (const f of allFolderEls) {\n const el = f as HTMLElement;\n if (el.dataset.accountId === selectedAccountId && el.dataset.folderId === String(selectedFolderId)) {\n el.classList.add(\"selected\");\n selectedElement = el;\n target = el;\n break;\n }\n }\n }\n\n // Auto-select on first load OR until we successfully auto-selected at least once\n // (handles Android where folders don't exist on first load \u2014 they arrive after sync)\n if (!target && (isFirstLoad || !hasAutoSelected)) {\n // Auto-select only on first load \u2014 not on refresh (prevents jumping)\n const unified = container.querySelector('.ft-unified') as HTMLElement;\n if (unified) {\n target = unified;\n } else {\n let bestInbox: HTMLElement | null = null;\n let bestCount = -1;\n for (const f of allFolderEls) {\n const name = f.querySelector('.ft-folder-name')?.textContent ?? \"\";\n if (name.toLowerCase() === \"inbox\") {\n const badge = f.querySelector('.ft-badge');\n const count = badge ? parseInt(badge.textContent || \"0\") : 0;\n if (count > bestCount) {\n bestCount = count;\n bestInbox = f as HTMLElement;\n }\n }\n }\n target = bestInbox;\n }\n if (!target && allFolderEls.length > 0) target = allFolderEls[0] as HTMLElement;\n if (target) {\n target.click();\n hasAutoSelected = true;\n }\n }\n isFirstLoad = false;\n // Dismiss startup overlay once tree is loaded\n const overlay = document.getElementById(\"startup-overlay\");\n if (overlay) overlay.classList.add(\"hidden\");\n // Remove from DOM after transition\n setTimeout(() => overlay?.remove(), 400);\n\n } catch (e: any) {\n // Don't destroy existing folder tree on error \u2014 just log it\n console.error(`Folder tree error: ${e.message}`);\n // Only show error if tree is completely empty (first load failure)\n if (container.children.length === 0 || container.querySelector(\".folder-loading\")) {\n const errEl = document.createElement(\"div\");\n errEl.className = \"folder-loading\";\n errEl.textContent = `Error loading folders: ${e.message}`;\n container.replaceChildren(errEl);\n }\n // Dismiss overlay on error too\n const overlay = document.getElementById(\"startup-overlay\");\n if (overlay) {\n const status = document.getElementById(\"startup-status\");\n if (status) status.textContent = `Error: ${e.message}`;\n setTimeout(() => { overlay.classList.add(\"hidden\"); setTimeout(() => overlay.remove(), 400); }, 2000);\n }\n }\n}\n\n/** Refresh folder tree (e.g., after sync) \u2014 debounced to prevent rapid rebuilds */\nexport function refreshFolderTree(): void {\n if (refreshDebounceTimer) clearTimeout(refreshDebounceTimer);\n refreshDebounceTimer = setTimeout(() => {\n refreshDebounceTimer = null;\n const container = document.getElementById(\"folder-tree\");\n if (container) loadFolderTree(container);\n }, 300);\n}\n\n/**\n * Incremental count update \u2014 patches badge counts in-place without rebuilding the DOM.\n * Used for folderCountsChanged events to avoid jitter. Falls back to full rebuild\n * if the folder structure has changed.\n */\nexport async function updateFolderCounts(): Promise<void> {\n const container = document.getElementById(\"folder-tree\");\n if (!container) return;\n\n // If tree hasn't loaded yet, do a full load\n if (container.children.length === 0 || container.querySelector(\".folder-loading\")) {\n refreshFolderTree();\n return;\n }\n\n try {\n const accounts = await getAccounts();\n\n // Fetch all folder data in parallel\n const allFolderData = await Promise.all(\n accounts.map(async (account: any) => {\n const folders = await getFolders(account.id);\n return { accountId: account.id, folders };\n })\n );\n\n // Build a lookup: accountId+folderId \u2192 { unreadCount, totalCount }\n // Also rebuild trees to get aggregated counts\n const countMap = new Map<string, { unread: number; total: number }>();\n for (const { accountId, folders } of allFolderData) {\n const delimiter = folders[0]?.delimiter || \".\";\n const tree = buildTree(folders, delimiter, accountId);\n // Walk the tree and collect counts (buildTree already aggregates)\n function collectCounts(nodes: FolderNode[]): void {\n for (const n of nodes) {\n countMap.set(`${n.accountId}:${n.id}`, { unread: n.unreadCount, total: n.totalCount });\n collectCounts(n.children);\n }\n }\n collectCounts(tree);\n }\n\n // Patch existing DOM elements in-place\n const folderEls = container.querySelectorAll(\".ft-folder[data-account-id][data-folder-id]\");\n let structureChanged = false;\n\n for (const el of folderEls) {\n const htmlEl = el as HTMLElement;\n const key = `${htmlEl.dataset.accountId}:${htmlEl.dataset.folderId}`;\n const counts = countMap.get(key);\n if (!counts) continue; // folder not found \u2014 structure may have changed\n\n // Update unread badge\n const isOutbox = htmlEl.dataset.specialUse === \"outbox\" || htmlEl.dataset.folderPath?.toLowerCase() === \"outbox\";\n let badge = htmlEl.querySelector(\".ft-badge\") as HTMLElement;\n const outboxBadge = htmlEl.querySelector(\".ft-badge-outbox\") as HTMLElement;\n\n if (isOutbox) {\n if (counts.total > 0) {\n if (outboxBadge) {\n outboxBadge.textContent = String(counts.total);\n } else {\n const b = document.createElement(\"span\");\n b.className = \"ft-badge ft-badge-outbox\";\n b.textContent = String(counts.total);\n htmlEl.querySelector(\".ft-folder-name\")?.after(b);\n }\n } else if (outboxBadge) {\n outboxBadge.remove();\n }\n } else {\n if (counts.unread > 0) {\n if (badge) {\n badge.textContent = String(counts.unread);\n } else {\n const b = document.createElement(\"span\");\n b.className = \"ft-badge\";\n b.textContent = String(counts.unread);\n htmlEl.querySelector(\".ft-folder-name\")?.after(b);\n }\n } else if (badge && !badge.classList.contains(\"ft-badge-outbox\")) {\n badge.remove();\n }\n }\n\n // Update total count\n let totalEl = htmlEl.querySelector(\".ft-total-count\") as HTMLElement;\n if (counts.total > 0) {\n if (totalEl) {\n totalEl.textContent = String(counts.total);\n } else {\n const t = document.createElement(\"span\");\n t.className = \"ft-total-count\";\n t.textContent = String(counts.total);\n htmlEl.appendChild(t);\n }\n } else if (totalEl) {\n totalEl.remove();\n }\n }\n\n // Check if folder count changed (new folders added or removed)\n const existingCount = folderEls.length;\n let serverCount = 0;\n for (const { folders } of allFolderData) serverCount += folders.length;\n if (Math.abs(existingCount - serverCount) > 2) {\n // Structure changed significantly \u2014 do a full rebuild\n refreshFolderTree();\n }\n } catch {\n // If count update fails, fall back to full rebuild\n refreshFolderTree();\n }\n}\n", "/** View tabs \u2014 multiple views over one mailbox / one backend.\n *\n * See docs/multi-view.md. A tab is a self-contained *view descriptor*: which\n * folder / unified-inbox / search it shows. The expensive things (DB, the\n * live sync stream, folder counts, contacts) stay shared in the daemon and\n * the one IPC channel \u2014 a tab is just the cheap \"which folder, restored how\"\n * bundle. That bundle is also exactly what a future tear-off window needs,\n * so this module is the seam for Stage 3.\n *\n * Stage 1: snapshot/restore. The DOM has ONE three-pane; switching tabs\n * re-invokes the existing load functions for the target tab's view, and the\n * list's own `positionMemory` restores selection + scroll per view. Inactive\n * tabs hold only their descriptor \u2014 no detached DOM, no second list state.\n */\n\nexport type TabView =\n | { kind: \"unified\" }\n | { kind: \"folder\"; accountId: string; folderId: number; specialUse: string }\n | { kind: \"search\"; query: string; scope: string; accountId: string; folderId: number; includeTrash: boolean };\n\nexport interface ViewTab {\n id: string;\n title: string;\n view: TabView;\n}\n\nconst STORAGE_KEY = \"mailx-view-tabs\";\nlet tabs: ViewTab[] = [];\nlet activeId = \"\";\nlet stripEl: HTMLElement | null = null;\n/** Applies a tab's view to the single three-pane. Set by initTabs; defined in\n * app.ts because it must touch app-level view state + the load functions. */\nlet applyView: (tab: ViewTab) => void = () => { /* */ };\nlet _nextId = 1;\nfunction newId(): string { return `t${_nextId++}`; }\n\nfunction persist(): void {\n try {\n sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ tabs, activeId }));\n } catch { /* sessionStorage unavailable \u2014 tabs just won't survive reload */ }\n}\n\nfunction render(): void {\n if (!stripEl) return;\n stripEl.innerHTML = \"\";\n for (const tab of tabs) {\n const chip = document.createElement(\"div\");\n chip.className = \"view-tab\" + (tab.id === activeId ? \" active\" : \"\");\n chip.dataset.tabId = tab.id;\n const label = document.createElement(\"span\");\n label.className = \"view-tab-label\";\n label.textContent = tab.title || \"(view)\";\n chip.appendChild(label);\n // Close affordance \u2014 hidden when only one tab remains (can't close\n // the last one; the window always shows something).\n if (tabs.length > 1) {\n const close = document.createElement(\"button\");\n close.className = \"view-tab-close\";\n close.textContent = \"\u00D7\";\n close.title = \"Close tab\";\n close.addEventListener(\"click\", (e) => { e.stopPropagation(); closeTab(tab.id); });\n chip.appendChild(close);\n }\n chip.addEventListener(\"click\", () => activate(tab.id));\n stripEl.appendChild(chip);\n }\n const plus = document.createElement(\"button\");\n plus.className = \"view-tab-new\";\n plus.textContent = \"+\";\n plus.title = \"New tab (Ctrl+T)\";\n plus.addEventListener(\"click\", () => openTab({ kind: \"unified\" }, \"All Inboxes\", true));\n stripEl.appendChild(plus);\n // Strip stays visible even with a single tab so the \"+\" button is a\n // discoverable affordance for opening another (Bob 2026-05-18: with the\n // strip hidden there was no hint that Ctrl+T creates a tab). Hidden only\n // before the first tab exists \u2014 nothing to show yet.\n stripEl.hidden = tabs.length < 1;\n}\n\n/** Wire the strip. `apply` is the app.ts callback that loads a tab's view. */\nexport function initTabs(strip: HTMLElement, apply: (tab: ViewTab) => void): void {\n stripEl = strip;\n applyView = apply;\n try {\n const raw = sessionStorage.getItem(STORAGE_KEY);\n if (raw) {\n const parsed = JSON.parse(raw) as { tabs: ViewTab[]; activeId: string };\n if (Array.isArray(parsed?.tabs) && parsed.tabs.length) {\n tabs = parsed.tabs;\n activeId = parsed.activeId || tabs[0].id;\n for (const t of tabs) {\n const n = Number(String(t.id).replace(/^t/, \"\"));\n if (Number.isFinite(n) && n >= _nextId) _nextId = n + 1;\n }\n }\n }\n } catch { /* corrupt \u2014 start fresh */ }\n render();\n}\n\n/** The currently-active tab, or null before any tab exists. */\nexport function activeTab(): ViewTab | null {\n return tabs.find(t => t.id === activeId) || null;\n}\n\n/** Open a new tab for `view` and (by default) switch to it. */\nexport function openTab(view: TabView, title: string, activateIt = true): void {\n const tab: ViewTab = { id: newId(), title, view };\n tabs.push(tab);\n if (activateIt) activeId = tab.id;\n persist();\n render();\n if (activateIt) applyView(tab);\n}\n\n/** Switch to an existing tab and load its view. */\nexport function activate(id: string): void {\n const tab = tabs.find(t => t.id === id);\n if (!tab || id === activeId) return;\n activeId = id;\n persist();\n render();\n applyView(tab);\n}\n\n/** Close a tab. Never closes the last one. If the active tab is closed, the\n * neighbour to its left (or right) becomes active. */\nexport function closeTab(id: string): void {\n if (tabs.length < 2) return;\n const idx = tabs.findIndex(t => t.id === id);\n if (idx < 0) return;\n const wasActive = id === activeId;\n tabs.splice(idx, 1);\n if (wasActive) {\n const next = tabs[Math.max(0, idx - 1)];\n activeId = next.id;\n persist();\n render();\n applyView(next);\n } else {\n persist();\n render();\n }\n}\n\n/** Record the view the user just navigated to in the CURRENT tab \u2014 called\n * from the folder-tree / search handlers. Does NOT re-apply the view (the\n * navigation already loaded it); only keeps the active tab's descriptor +\n * title in sync so a later tab-switch restores the right thing. If no tab\n * exists yet (first navigation after boot), this creates the first tab \u2014\n * so the very first folder/inbox selection seeds the strip. */\nexport function setActiveView(view: TabView, title: string): void {\n let tab = activeTab();\n if (!tab) {\n tab = { id: newId(), title, view };\n tabs.push(tab);\n activeId = tab.id;\n } else {\n tab.view = view;\n tab.title = title;\n }\n persist();\n render();\n}\n", "/**\n * mailx client entry point.\n * Wires together all UI components and WebSocket connection.\n */\n\nimport { initFolderTree, refreshFolderTree, updateFolderCounts, setFolderSynced, getFolderSynced, setOutboxTotal } from \"./components/folder-tree.js\";\nimport { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder, clearSearchMode, getSelectedMessages, markBodiesCached, getCurrentFocused, releaseFocus, removeMessagesAndReconcile, setRowFlagged, scrollFocusedIntoView, refreshPriorityIndex } from \"./components/message-list.js\";\nimport { seenOf, flaggedOf, draftOf, setSeen, setFlagged } from \"@bobfrankston/mailx-types\";\nimport { initTabs, setActiveView as setActiveTabView, openTab, type ViewTab } from \"./components/tabs.js\";\nimport { showMessage, getCurrentMessage, initViewer, popOutCurrentMessage, printCurrentMessage, toggleFullscreenPreview, showPreviewBodyMenu, wrapHtmlBody } from \"./components/message-viewer.js\";\nimport { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessage, deleteMessages, undeleteMessage, restartServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts, updateFlags, markAsSpamMessages, logClientEvent, sendMessage as apiSendMessage, subscribeStore, cancelServerSearch } from \"./lib/api-client.js\";\nimport * as messageState from \"./lib/message-state.js\";\n\n// \u2500\u2500 New message badge (favicon + title) \u2500\u2500\n/** The user-visible app name. Single point of change for the rename;\n * every UI surface that shows \"rmfmail\" reads from here. Static HTML\n * uses placeholder text that `propagateAppName` (called immediately\n * below) overwrites \u2014 so the constant is the only place the literal\n * string lives. */\nexport const APP_NAME = \"rmfmail\";\n\n/** Stamp APP_NAME into every static HTML element that should show it.\n * Runs synchronously at module load \u2014 before paint when imports are\n * fast, with a sub-frame flash of the placeholder otherwise. Add new\n * surfaces here, never inline literals. */\nfunction propagateAppName(): void {\n const set = (id: string, fn: (v: string) => string): void => {\n const el = document.getElementById(id);\n if (el) el.textContent = fn(APP_NAME);\n };\n document.title = APP_NAME;\n set(\"startup-status\", n => `Starting ${n}\u2026`);\n set(\"status-version\", n => n);\n set(\"app-version\", n => n);\n // About-button label / restart-button hover-titles live in HTML\n // attributes; rewrite the ones that include the name.\n const aboutBtn = document.getElementById(\"btn-about\");\n if (aboutBtn) aboutBtn.title = `Show version and build info`;\n const aboutText = aboutBtn?.textContent || \"\";\n if (aboutBtn && aboutText.toLowerCase().includes(\"about\")) {\n aboutBtn.textContent = `About ${APP_NAME}...`;\n }\n}\npropagateAppName();\n(window as any).__btick && (window as any).__btick(\"app.ts module body executing\");\n\n// \u2500\u2500 App-vs-browser policy: this is a desktop app, not a web page. \u2500\u2500\n// Block browser-default accelerators (Reload, Save Page, Print, View\n// Source, history navigation, \u2026) and the right-click context menu so\n// mailx's keymap is the only way to do anything. F12 is preserved as\n// the developer-tools escape hatch (Bob 2026-05-11 explicitly).\n//\n// Capture-phase listener so we intercept BEFORE any inner widget can\n// observe the event \u2014 mailx's own keymap re-emits via specific element\n// handlers (Ctrl+N compose, Ctrl+R reply, etc.) which were never the\n// browser's accelerators in the first place.\n(function blockBrowserKeysAndMenu() {\n const isBlockedKey = (e: KeyboardEvent): boolean => {\n // Always-allow: F12 for DevTools, and any single non-modifier key\n // (typing, arrow nav, Tab/Esc/Enter) which is the user typing.\n if (e.key === \"F12\") return false;\n // Block reload paths: F5, Ctrl+R, Ctrl+Shift+R, Ctrl+F5.\n if (e.key === \"F5\" || e.key === \"F3\") return true;\n if ((e.ctrlKey || e.metaKey) && (e.key === \"r\" || e.key === \"R\")) return true;\n // Browser accelerators bound to Ctrl-letter combos that mailx\n // doesn't want to forward: P (print), S (save page), U (view\n // source), J (downloads), L (focus address bar \u2014 n/a in WebView\n // but harmless), G (find next). Note: O is \"open file\" in\n // browsers \u2014 mailx uses Ctrl+O nowhere meaningful, so block.\n if (e.ctrlKey || e.metaKey) {\n const k = e.key.toLowerCase();\n if ([\"p\", \"s\", \"u\", \"j\", \"l\", \"g\", \"o\"].includes(k)) return true;\n }\n // Alt+Left / Alt+Right = browser back / forward. No use in mailx.\n if (e.altKey && (e.key === \"ArrowLeft\" || e.key === \"ArrowRight\")) return true;\n // Backspace as nav-back when no input is focused \u2014 fires on some\n // WebView builds. Only block when target isn't text-editable.\n if (e.key === \"Backspace\") {\n const t = e.target as HTMLElement | null;\n const editable = t && (t.tagName === \"INPUT\" || t.tagName === \"TEXTAREA\"\n || (t as any).isContentEditable);\n if (!editable) return true;\n }\n return false;\n };\n document.addEventListener(\"keydown\", (e) => {\n if (isBlockedKey(e)) {\n e.preventDefault();\n e.stopPropagation();\n }\n }, true);\n // Right-click context menu \u2014 mailx has its own (showContextMenu) for\n // specific surfaces. Default WebView menu is \"Reload / Save As /\n // View Source / Inspect\" which doesn't belong in a desktop app.\n // Individual handlers that DO want a context menu must\n // `e.preventDefault()` themselves AFTER showing \u2014 this only\n // suppresses the default browser one when nothing else handles.\n document.addEventListener(\"contextmenu\", (e) => {\n // Allow contextmenu to bubble normally so mailx-internal handlers\n // (folder-tree right-click, address-pill right-click, link\n // right-click in preview) can react. We only kill it if it would\n // otherwise show the browser default \u2014 i.e., no other handler\n // called preventDefault.\n if (!e.defaultPrevented) e.preventDefault();\n });\n})();\n\nlet baseTitle = APP_NAME;\nlet lastSeenCount = 0;\nlet badgeCount = 0;\n\nfunction updateBadge(count: number): void {\n badgeCount = count;\n // Update title\n document.title = count > 0 ? `(${count}) ${baseTitle}` : baseTitle;\n // Generate a single badge bitmap used for both the favicon (visible on\n // browser tabs / mobile homescreen) AND the Windows taskbar overlay\n // icon (visible as a Thunderbird-style corner pill on the taskbar\n // button when running via msger). Rendered once, consumed twice.\n const canvas = document.createElement(\"canvas\");\n canvas.width = 32;\n canvas.height = 32;\n const ctx = canvas.getContext(\"2d\")!;\n // Base envelope icon (always drawn \u2014 so the favicon is a recognizable\n // mailx icon even at 0 count).\n ctx.fillStyle = \"#4a7ccc\";\n ctx.fillRect(2, 8, 28, 20);\n ctx.fillStyle = \"#6a9cec\";\n ctx.beginPath();\n ctx.moveTo(2, 8);\n ctx.lineTo(16, 20);\n ctx.lineTo(30, 8);\n ctx.fill();\n if (count > 0) {\n // Red badge circle with count\n ctx.fillStyle = \"#e33\";\n ctx.beginPath();\n ctx.arc(24, 8, 8, 0, Math.PI * 2);\n ctx.fill();\n ctx.fillStyle = \"#fff\";\n ctx.font = \"bold 11px sans-serif\";\n ctx.textAlign = \"center\";\n ctx.textBaseline = \"middle\";\n ctx.fillText(count > 99 ? \"99+\" : String(count), 24, 8);\n }\n // Set as favicon\n let link = document.querySelector(\"link[rel='icon']\") as HTMLLinkElement;\n if (!link) {\n link = document.createElement(\"link\");\n link.rel = \"icon\";\n document.head.appendChild(link);\n }\n const dataUrl = canvas.toDataURL(\"image/png\");\n link.href = dataUrl;\n\n // Also push to the Windows taskbar overlay via msger's IPC helper \u2014\n // no-op on Linux/Mac. For count=0, render a dedicated \"no-overlay\"\n // icon that's all-transparent so the base icon shows cleanly.\n try {\n const msgapi: any = (window as any).msgapi;\n if (msgapi?.setTaskbarOverlay) {\n if (count > 0) {\n // strip \"data:image/png;base64,\" prefix \u2192 base64 only\n const b64 = dataUrl.split(\",\")[1] || \"\";\n msgapi.setTaskbarOverlay(b64, `${count} unread`);\n } else {\n msgapi.setTaskbarOverlay(\"\", \"\");\n }\n }\n } catch { /* msgapi unavailable in browser fallback */ }\n}\n\nasync function updateNewMessageCount(): Promise<void> {\n try {\n const accounts = await getAccounts();\n // Fan out folder queries in parallel \u2014 earlier code awaited each\n // account's `getFolders` in series, so an N-account setup paid N\n // back-to-back IPC round-trips on every count refresh (folderCountsChanged,\n // sync events, IDLE updates).\n const folderLists = await Promise.all(\n accounts.map((acct: any) => getFolders(acct.id).catch(() => [] as any[])),\n );\n let totalUnread = 0;\n for (const folders of folderLists) {\n const inbox = folders.find((f: any) => f.specialUse === \"inbox\");\n if (inbox) totalUnread += inbox.unreadCount || 0;\n }\n // Rail badge: unread count on the Inbox and Unified-inbox rail buttons.\n // Visible even when those views aren't the active one \u2014 part of C33\n // \"rail icon badges for unread counts.\"\n updateRailBadge(\"rail-inbox\", totalUnread);\n updateRailBadge(\"rail-unified\", totalUnread);\n // First load: set baseline\n if (lastSeenCount === 0) { lastSeenCount = totalUnread; updateBadge(0); return; }\n const previousBadge = badgeCount;\n // New messages = increase since last seen\n const newCount = Math.max(0, totalUnread - lastSeenCount);\n updateBadge(newCount);\n // Flash the title when new mail arrives and the window isn't focused.\n // Windows' taskbar mirrors document.title so this acts as a taskbar flash.\n if (newCount > previousBadge && document.visibilityState !== \"visible\") {\n startTitleFlash();\n }\n } catch { /* offline */ }\n}\n\nfunction updateRailBadge(buttonId: string, count: number): void {\n const btn = document.getElementById(buttonId);\n if (!btn) return;\n let badge = btn.querySelector<HTMLElement>(\".rail-badge\");\n if (count <= 0) {\n if (badge) badge.remove();\n return;\n }\n if (!badge) {\n badge = document.createElement(\"span\");\n badge.className = \"rail-badge\";\n btn.appendChild(badge);\n }\n badge.textContent = count > 999 ? \"999+\" : String(count);\n}\n\n// \u2500\u2500 Taskbar flash via title alternation \u2500\u2500\nlet titleFlashTimer: ReturnType<typeof setInterval> | null = null;\nlet titleFlashPhase = false;\n\nfunction startTitleFlash(): void {\n stopTitleFlash();\n titleFlashPhase = true;\n titleFlashTimer = setInterval(() => {\n titleFlashPhase = !titleFlashPhase;\n if (titleFlashPhase) {\n document.title = `\u2709 NEW MAIL (${badgeCount})`;\n } else {\n document.title = badgeCount > 0 ? `(${badgeCount}) ${baseTitle}` : baseTitle;\n }\n }, 1000);\n}\n\nfunction stopTitleFlash(): void {\n if (titleFlashTimer) { clearInterval(titleFlashTimer); titleFlashTimer = null; }\n document.title = badgeCount > 0 ? `(${badgeCount}) ${baseTitle}` : baseTitle;\n}\n\ndocument.addEventListener(\"visibilitychange\", () => {\n if (document.visibilityState === \"visible\") stopTitleFlash();\n});\nwindow.addEventListener(\"focus\", stopTitleFlash);\n\n/** Call when user actively views messages \u2014 resets the badge */\nfunction markAsSeen(): void {\n getAccounts().then(async (accounts: any[]) => {\n // Parallel folder fetch \u2014 see updateNewMessageCount for rationale.\n const folderLists = await Promise.all(\n accounts.map((acct: any) => getFolders(acct.id).catch(() => [] as any[])),\n );\n let total = 0;\n for (const folders of folderLists) {\n const inbox = folders.find((f: any) => f.specialUse === \"inbox\");\n if (inbox) total += inbox.unreadCount || 0;\n }\n lastSeenCount = total;\n updateBadge(0);\n }).catch(() => {});\n}\n\nfunction setTitle(title: string): void {\n baseTitle = title;\n document.title = badgeCount > 0 ? `(${badgeCount}) ${baseTitle}` : baseTitle;\n}\n\n// \u2500\u2500 Alert banner \u2500\u2500\nconst alertBanner = document.getElementById(\"alert-banner\");\nconst alertText = document.getElementById(\"alert-text\");\nconst alertDismiss = document.getElementById(\"alert-dismiss\");\nconst dismissedAlerts = new Set<string>();\n\nlet alertAutoDismissTimer: ReturnType<typeof setTimeout> | null = null;\nfunction showAlert(message: string, key?: string, opts?: { sticky?: boolean }): void {\n if (key && dismissedAlerts.has(key)) return;\n if (alertBanner && alertText) {\n alertText.textContent = message;\n alertBanner.hidden = false;\n alertBanner.dataset.key = key || \"\";\n // Q65: auto-dismiss non-critical banners after 30s; sticky ones\n // (acct-*, ws-error, config-restart) keep showing until user acts.\n if (alertAutoDismissTimer) { clearTimeout(alertAutoDismissTimer); alertAutoDismissTimer = null; }\n const isCritical = !!opts?.sticky\n || (key?.startsWith(\"acct-\"))\n || key === \"ws-error\"\n || key === \"config-restart\";\n if (!isCritical) {\n alertAutoDismissTimer = setTimeout(() => {\n if (alertBanner && alertBanner.dataset.key === (key || \"\")) {\n alertBanner.hidden = true;\n }\n alertAutoDismissTimer = null;\n }, 30_000);\n }\n }\n}\n\n// Non-blocking alert channel for components that can't import showAlert\n// directly (message-viewer, etc.). They dispatch a `mailx-alert` CustomEvent\n// rather than calling the blocking window.alert().\nwindow.addEventListener(\"mailx-alert\", (e: Event) => {\n const d = (e as CustomEvent).detail || {};\n if (d.message) showAlert(String(d.message), d.key);\n});\n\nfunction hideAlert(opts?: { force?: boolean }): void {\n if (alertBanner) {\n // The \"update available\" notice is sticky: routine \"clear the\n // transient banner\" calls (after a successful sync, etc.) must not\n // wipe it \u2014 otherwise the next sync, seconds later, hides the\n // update banner and the user never sees it. Only the explicit X\n // (force) clears it.\n if (!opts?.force && alertBanner.dataset.key === \"update-available\") return;\n const key = alertBanner.dataset.key;\n if (key) dismissedAlerts.add(key);\n alertBanner.hidden = true;\n }\n}\n\nalertDismiss?.addEventListener(\"click\", () => hideAlert({ force: true }));\n\n/** Show the alert banner with a \"Restart\" button wired to the mailxapi\n * restartDaemon action. Used when a watched config file whose changes\n * don't apply live (accounts.jsonc) has been modified. */\nfunction showRestartForConfigBanner(): void {\n if (!alertBanner || !alertText) return;\n // Timestamp in the banner so repeated / spurious fires are visually\n // distinguishable (and the user can see when the change actually\n // happened, useful for debugging false triggers).\n const ts = new Date().toLocaleTimeString([], { hour12: false });\n alertText.textContent = `[${ts}] accounts.jsonc changed \u2014 restart to apply.`;\n alertBanner.hidden = false;\n alertBanner.dataset.key = \"config-restart\";\n // Avoid duplicate buttons across repeat changes.\n const existing = alertBanner.querySelector(\"#alert-restart-btn\");\n if (existing) return;\n const btn = document.createElement(\"button\");\n btn.id = \"alert-restart-btn\";\n btn.textContent = \"Restart now\";\n btn.style.cssText = \"margin-left: 12px; padding: 3px 12px; cursor: pointer;\";\n btn.addEventListener(\"click\", async () => {\n btn.disabled = true;\n btn.textContent = \"Restarting\u2026\";\n try {\n const ipc: any = (window as any).mailxapi;\n if (ipc?.restartDaemon) {\n await ipc.restartDaemon();\n // Service is going down; the WebView should reload shortly\n // when the replacement daemon takes over. Force a reload\n // after a short delay in case the event doesn't arrive.\n setTimeout(() => location.reload(), 2000);\n } else {\n // Non-IPC (server/browser mode) \u2014 location reload won't\n // restart the daemon but at least gives the user feedback.\n location.reload();\n }\n } catch (e: any) {\n btn.textContent = `Failed: ${e?.message || e}`;\n btn.disabled = false;\n }\n });\n alertText.after(btn);\n}\n\n// \u2500\u2500 Wire up components \u2500\u2500\n\nconst folderTree = document.getElementById(\"folder-tree\")!;\nlet currentFolderSpecialUse = \"\";\n\n// Selection / preview drift is now structurally impossible: the list owns\n// focus, the viewer is a passive renderer, and `releaseFocus()` is the\n// only way to deselect+clear. The old `mailx-clear-viewer` event and the\n// app-level clearViewer() shim are no longer needed.\n\nconst folderTitleEl = document.getElementById(\"ml-folder-title\");\nlet currentFolderName = \"\";\nlet currentFolderSyncedAt: number | undefined;\n\nfunction formatAge(ms: number): string {\n const s = Math.round(ms / 1000);\n if (s < 60) return `${s}s ago`;\n const m = Math.round(s / 60);\n if (m < 60) return `${m}m ago`;\n const h = Math.round(m / 60);\n if (h < 24) return `${h}h ago`;\n return `${Math.round(h / 24)}d ago`;\n}\n\nfunction renderNarrowFolderTitle(): void {\n if (!folderTitleEl) return;\n if (currentFolderSyncedAt) {\n const age = formatAge(Date.now() - currentFolderSyncedAt);\n folderTitleEl.innerHTML = `${currentFolderName}<span class=\"ml-folder-age\"> \u00B7 ${age}</span>`;\n folderTitleEl.title = `Last synced ${new Date(currentFolderSyncedAt).toLocaleTimeString()}`;\n } else {\n folderTitleEl.textContent = currentFolderName;\n folderTitleEl.title = \"\";\n }\n}\n\nfunction setNarrowFolderTitle(name: string): void {\n currentFolderName = name;\n currentFolderSyncedAt = getFolderSynced(currentAccountId, currentFolderId);\n renderNarrowFolderTitle();\n}\n\n// Tick the \"3m ago\" text every 30s so it stays truthful without flooding repaints.\nsetInterval(() => {\n if (currentFolderSyncedAt) renderNarrowFolderTitle();\n}, 30_000);\n\ninitFolderTree(folderTree, (accountId, folderId, folderName, specialUse) => {\n currentFolderSpecialUse = specialUse;\n currentAccountId = accountId;\n currentFolderId = folderId;\n // Drop search state on folder switch \u2014 input alone wasn't enough,\n // searchMode stayed true and the next loadMessages was rerouted.\n if (searchInput) { searchInput.value = \"\"; updateSearchHighlight(); }\n clearSearchMode();\n markAsSeen();\n releaseFocus();\n loadMessages(accountId, folderId, 1, specialUse);\n setTitle(`${APP_NAME} - ${folderName}`);\n setNarrowFolderTitle(folderName);\n // Record the navigation in the active tab so a later tab-switch restores\n // this folder. Folder navigation happens IN the current tab.\n setActiveTabView({ kind: \"folder\", accountId, folderId, specialUse }, folderName);\n document.dispatchEvent(new CustomEvent(\"mailx-folder-changed\", { detail: { accountId, folderId } }));\n}, () => {\n // Unified inbox handler\n currentFolderSpecialUse = \"inbox\";\n // Clear search state \u2014 switching folders should drop any active filter.\n // Pre-fix: the search input retained its old value AND search mode\n // stayed active, so \"All Inboxes\" appeared to be ignoring its own\n // contents (Bob 2026-05-09: \"I just switched to all inboxes but the\n // search is still showing even though all the entries are there.\").\n if (searchInput) { searchInput.value = \"\"; updateSearchHighlight(); }\n clearSearchMode();\n releaseFocus();\n loadUnifiedInbox();\n setTitle(`${APP_NAME} - All Inboxes`);\n setNarrowFolderTitle(\"All Inboxes\");\n setActiveTabView({ kind: \"unified\" }, \"All Inboxes\");\n});\n\n// \u2500\u2500 View tabs (docs/multi-view.md) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// `applyTabView` loads a tab's view into the single three-pane when the user\n// switches tabs. It mirrors the folder-tree handlers above but must NOT call\n// setActiveTabView (that would be a no-op update, but keeping them separate\n// keeps \"user navigated\" distinct from \"tab restored\").\nfunction applyTabView(tab: ViewTab): void {\n if (searchInput) { searchInput.value = \"\"; updateSearchHighlight(); }\n clearSearchMode();\n releaseFocus();\n const v = tab.view;\n if (v.kind === \"unified\") {\n currentFolderSpecialUse = \"inbox\";\n loadUnifiedInbox();\n setTitle(`${APP_NAME} - All Inboxes`);\n setNarrowFolderTitle(\"All Inboxes\");\n } else if (v.kind === \"folder\") {\n currentFolderSpecialUse = v.specialUse;\n currentAccountId = v.accountId;\n currentFolderId = v.folderId;\n loadMessages(v.accountId, v.folderId, 1, v.specialUse);\n setTitle(`${APP_NAME} - ${tab.title}`);\n setNarrowFolderTitle(tab.title);\n } else {\n if (searchInput) { searchInput.value = v.query; updateSearchHighlight(); }\n loadSearchResults(v.query, v.scope, v.accountId, v.folderId, v.includeTrash);\n setTitle(`${APP_NAME} - Search`);\n setNarrowFolderTitle(`Search: ${v.query}`);\n }\n}\nconst tabStripEl = document.getElementById(\"view-tab-strip\");\nif (tabStripEl) initTabs(tabStripEl, applyTabView);\n\ninitMessageList((_accountId, _uid, _folderId) => {\n // The list row's setFocus() already drove the viewer with its own\n // envelope (header + preview paint synchronously, body fetches in\n // background). Calling showMessage here a SECOND time without the\n // envelope wiped the good render and forced a bare \"Loading\u2026\"\n // placeholder until the body IPC returned \u2014 making every click feel\n // like a network round-trip. This callback now only handles the\n // narrow-screen layout switch.\n if (window.innerWidth <= 768) {\n document.getElementById(\"message-viewer\")?.classList.add(\"narrow-active\");\n document.getElementById(\"message-list\")?.classList.add(\"narrow-hidden\");\n // Selecting a message means the user is done with the rail / folder\n // drawers \u2014 auto-dismiss either if left open. Without this the rail\n // floats over the message body (the \"rail on top of the letter\" bug).\n document.querySelector(\".icon-rail\")?.classList.remove(\"open\");\n document.querySelector(\".folder-panel\")?.classList.remove(\"open\");\n }\n});\ninitViewer();\n(window as any).__btick && (window as any).__btick(\"init* done \u2014 first interactive moment\");\nrequestAnimationFrame(() => (window as any).__btick && (window as any).__btick(\"first rAF after init\"));\n\n// Status bar: show focused message UID/folder for debugging. The list\n// fires mailx-focus-changed with the focused envelope (or null). Pure\n// observation \u2014 this listener never drives the viewer.\ndocument.addEventListener(\"mailx-focus-changed\", (e: any) => {\n const acctEl = document.getElementById(\"status-accounts\");\n if (!acctEl) return;\n const sel = e.detail as { accountId: string; uid: number; folderId: number } | null;\n if (sel) {\n acctEl.textContent = `${sel.accountId}/uid:${sel.uid} folder:${sel.folderId}`;\n acctEl.style.color = \"\";\n } else {\n acctEl.textContent = \"\";\n }\n});\n\n// Q53: per-account last-sync timestamps surfaced via the status-sync hover.\nconst lastSyncByAccount: Record<string, number> = {};\nfunction recordAccountSync(accountId: string): void {\n lastSyncByAccount[accountId] = Date.now();\n refreshSyncTooltip();\n}\nfunction refreshSyncTooltip(): void {\n const el = document.getElementById(\"status-sync\");\n if (!el) return;\n const accts = Object.keys(lastSyncByAccount).sort();\n if (accts.length === 0) { el.title = \"\"; return; }\n el.title = \"Last sync:\\n\" + accts.map(a => {\n const ts = lastSyncByAccount[a];\n const d = new Date(ts);\n return ` ${a}: ${d.toLocaleTimeString()} (${formatAge(Date.now() - ts)})`;\n }).join(\"\\n\");\n}\n// Refresh the tooltip every 30s so the \"(12m ago)\" stays current even with\n// no new sync events.\nsetInterval(refreshSyncTooltip, 30_000);\n\n// \u2500\u2500 Auto two-line when message list is narrow \u2500\u2500\nconst messageList = document.getElementById(\"message-list\");\nif (messageList) {\n const twoLineThreshold = 600; // px \u2014 switch to two-line below this width\n const userTwoLine = localStorage.getItem(\"mailx-two-line\") === \"true\";\n new ResizeObserver(([entry]) => {\n const narrow = entry.contentRect.width < twoLineThreshold;\n // Auto two-line when narrow, respect user preference when wide\n if (narrow) {\n messageList.classList.add(\"two-line\");\n } else if (!userTwoLine) {\n messageList.classList.remove(\"two-line\");\n }\n }).observe(messageList);\n}\n\n// \u2500\u2500 Narrow/medium drawer toggles \u2500\u2500\n// Hamburger (\u2630): rail drawer on narrow; on wider tiers the rail is already\n// visible so this is a no-op visually (the toggle still fires but the rail\n// has no `.open` style to invoke).\n// Folder (\uD83D\uDCC1): folder-panel drawer on any tier where it's positioned as an\n// overlay (medium + narrow).\ndocument.getElementById(\"btn-menu\")?.addEventListener(\"click\", () => {\n document.querySelector(\".icon-rail\")?.classList.toggle(\"open\");\n // Rail drawer and folder drawer are mutually exclusive \u2014 opening one\n // closes the other so they don't fight for the left edge.\n document.querySelector(\".folder-panel\")?.classList.remove(\"open\");\n});\ndocument.getElementById(\"btn-folder-toggle\")?.addEventListener(\"click\", () => {\n document.querySelector(\".folder-panel\")?.classList.toggle(\"open\");\n document.querySelector(\".icon-rail\")?.classList.remove(\"open\");\n});\n\nconst backToList = (e: Event) => {\n e.preventDefault();\n e.stopPropagation();\n // If user is in full-screen-viewer mode, the first back tap should exit\n // full-screen and return to the normal narrow split (list + active\n // viewer). It shouldn't also deselect \u2014 that would yank the user out two\n // levels in one tap.\n if (document.body.classList.contains(\"viewer-fullscreen\")) {\n document.body.classList.remove(\"viewer-fullscreen\");\n return;\n }\n document.getElementById(\"message-viewer\")?.classList.remove(\"narrow-active\");\n document.getElementById(\"message-list\")?.classList.remove(\"narrow-hidden\");\n // Release focus so the viewer clears. Without this a subsequent sync\n // reload could re-show the same message and re-trigger narrow-active.\n releaseFocus();\n};\ndocument.getElementById(\"btn-back\")?.addEventListener(\"click\", backToList);\n// Android WebView sometimes drops synthetic clicks after a touchend inside a\n// header bar layered above the iframe \u2014 handle touchend explicitly too.\ndocument.getElementById(\"btn-back\")?.addEventListener(\"touchend\", backToList);\n\n// Pop-out viewer button \u2014 desktop spawns a floating overlay (multiple at\n// once), mobile toggles `body.viewer-fullscreen` for full-screen reading.\n// Threshold and behavior live in popOutCurrentMessage.\ndocument.getElementById(\"mv-popout\")?.addEventListener(\"click\", () => popOutCurrentMessage());\ndocument.getElementById(\"btn-print\")?.addEventListener(\"click\", () => printCurrentMessage());\n\n// Close folder panel when a folder is selected (narrow mode)\n// Also reset narrow navigation: show message list, hide viewer\ndocument.getElementById(\"folder-tree\")?.addEventListener(\"click\", (e) => {\n if (window.innerWidth <= 768 && (e.target as HTMLElement).closest(\".ft-folder\")) {\n document.querySelector(\".folder-panel\")?.classList.remove(\"open\");\n document.getElementById(\"message-viewer\")?.classList.remove(\"narrow-active\");\n document.getElementById(\"message-list\")?.classList.remove(\"narrow-hidden\");\n }\n});\n\n// Close folder overlay when user clicks outside it (narrow mode OR\n// medium-width mode where the folder panel slides in as an overlay).\n// Uses capture phase so it beats any child handler that might stopPropagation.\ndocument.addEventListener(\"pointerdown\", (e) => {\n const panel = document.querySelector(\".folder-panel\");\n if (!panel || !panel.classList.contains(\"open\")) return;\n const target = e.target as HTMLElement;\n // Ignore clicks inside the panel itself and on either toggle button.\n // Without `#btn-folder-toggle` in this list, clicking the folder icon\n // while the panel is open closed it here (capture phase) then the click\n // handler reopened it \u2014 net effect: panel stuck open, \"doesn't toggle\".\n if (target.closest(\".folder-panel\")\n || target.closest(\"#btn-menu\")\n || target.closest(\"#btn-folder-toggle\")) return;\n // Only auto-dismiss when we're in overlay mode (small or medium screens).\n // On wide screens the panel is a permanent column and the \"open\" class\n // is irrelevant.\n if (window.innerWidth <= 1100 || window.innerHeight <= 600) {\n panel.classList.remove(\"open\");\n }\n}, true);\n\n// Same auto-dismiss for the icon-rail drawer (narrow only \u2014 on medium/wide\n// the rail is a permanent column and `.open` has no visual effect).\ndocument.addEventListener(\"pointerdown\", (e) => {\n const rail = document.querySelector(\".icon-rail\");\n if (!rail || !rail.classList.contains(\"open\")) return;\n const target = e.target as HTMLElement;\n if (target.closest(\".icon-rail\") || target.closest(\"#btn-menu\")) return;\n if (window.innerWidth <= 768) rail.classList.remove(\"open\");\n}, true);\n\n// The rail stays open until the user clicks outside it \u2014 handled by the\n// document-level outside-click handler above. No per-button close: chaining\n// rail actions (toggle theme, then check About, then change a setting)\n// without having to re-open the rail each time was the explicit ask.\n\n// \u2500\u2500 Toolbar actions \u2500\u2500\n\ndocument.getElementById(\"btn-sync\")?.addEventListener(\"click\", async () => {\n const btn = document.getElementById(\"btn-sync\") as HTMLButtonElement;\n btn.disabled = true;\n btn.classList.add(\"syncing\");\n const statusSync = document.getElementById(\"status-sync\");\n if (statusSync) statusSync.textContent = \"Syncing...\";\n\n try {\n await triggerSync();\n // Button stays spinning \u2014 WebSocket syncProgress/folderCountsChanged will update UI\n // Set a timeout to re-enable if no WebSocket response\n setTimeout(() => {\n btn.disabled = false;\n btn.classList.remove(\"syncing\");\n refreshFolderTree();\n reloadCurrentFolder();\n if (statusSync && statusSync.textContent === \"Syncing...\") {\n statusSync.textContent = `Synced ${new Date().toLocaleTimeString(undefined, { hour: \"2-digit\", minute: \"2-digit\", hour12: false })}`;\n }\n }, 30000);\n } catch (e: any) {\n if (statusSync) statusSync.textContent = `Sync error: ${e.message}`;\n btn.disabled = false;\n btn.classList.remove(\"syncing\");\n }\n});\n\n// Restart menu dropdown\nconst restartBtn = document.getElementById(\"btn-restart\");\nconst restartDropdown = document.getElementById(\"restart-dropdown\");\nrestartBtn?.addEventListener(\"click\", () => {\n restoreToolbarDropdown(\"restart-dropdown\", \"restart-menu\");\n if (restartDropdown) restartDropdown.hidden = !restartDropdown.hidden;\n});\ndocument.addEventListener(\"click\", (e) => {\n if (restartDropdown && !restartDropdown.hidden && !(e.target as HTMLElement).closest(\"#restart-menu\")) {\n restartDropdown.hidden = true;\n }\n});\n\ndocument.getElementById(\"btn-restart-quick\")?.addEventListener(\"click\", async () => {\n if (restartDropdown) restartDropdown.hidden = true;\n if (isApp) {\n // Android has no daemon \u2014 only the WebView. Reload-the-page is the\n // right action there. Desktop IPC mode is a different story below.\n if ((window as any).mailxapi?.platform === \"android\") {\n const f = document.createElement(\"iframe\");\n f.style.display = \"none\";\n f.src = \"mailxapi://checkUpdate\";\n document.body.appendChild(f);\n setTimeout(() => f.remove(), 100);\n location.reload();\n return;\n }\n // Desktop IPC mode: there IS a daemon (the --daemon child of mailx)\n // running mailx-service / mailx-imap / mailx-store. Just calling\n // location.reload() reloads the WebView but the daemon keeps running\n // the old code, so daemon-side changes (sync, store, IPC handlers)\n // don't get picked up. Trigger restartDaemon \u2014 it spawns a fresh\n // `mailx` process, hands off the instance.json slot, then gracefully\n // shuts down the current daemon. The UI reloads after a short delay\n // so the new daemon's WebView replaces this one.\n const statusSync = document.getElementById(\"status-sync\");\n if (statusSync) statusSync.textContent = \"Restarting...\";\n const ipc = (window as any).mailxapi;\n if (ipc?.restartDaemon) {\n try { await ipc.restartDaemon(); } catch { /* daemon shutting down */ }\n setTimeout(() => location.reload(), 2000);\n } else {\n // Older host with no restartDaemon IPC \u2014 fall back to UI reload.\n location.reload();\n }\n } else {\n const statusSync = document.getElementById(\"status-sync\");\n if (statusSync) statusSync.textContent = \"Restarting...\";\n try { await restartServer(); } catch { /* server is shutting down */ }\n }\n});\n\ndocument.getElementById(\"btn-update\")?.addEventListener(\"click\", async () => {\n if (restartDropdown) restartDropdown.hidden = true;\n const statusSync = document.getElementById(\"status-sync\");\n if (statusSync) statusSync.textContent = \"Checking for updates...\";\n const ipc = (window as any).mailxapi || (window as any).opener?.mailxapi;\n if (ipc?.performUpdate) {\n if (statusSync) statusSync.textContent = \"Updating... mailx will restart when done\";\n ipc.performUpdate();\n } else if (statusSync) {\n statusSync.textContent = \"Update not available in this mode\";\n }\n});\n\ndocument.getElementById(\"btn-rebuild\")?.addEventListener(\"click\", async () => {\n if (restartDropdown) restartDropdown.hidden = true;\n if (!confirm(\"Rebuild local cache?\\n\\nThis wipes the local database and message store, then re-downloads everything.\\nAccounts and settings are preserved.\\n\\nThis is safe and usually takes just a few minutes.\")) return;\n const statusSync = document.getElementById(\"status-sync\");\n if (statusSync) statusSync.textContent = \"Rebuilding...\";\n try { await restartServer(); } catch { /* restarting */ }\n});\n\ndocument.getElementById(\"btn-factory-reset\")?.addEventListener(\"click\", async () => {\n if (restartDropdown) restartDropdown.hidden = true;\n if (!confirm(\"Factory reset?\\n\\nThis deletes ALL data \u2014 accounts, settings, messages, cache.\\nYou will need to set up your account again.\")) return;\n const ipc = (window as any).mailxapi;\n if (ipc?.resetAll) {\n await ipc.resetAll();\n } else {\n // Fallback: clear IndexedDB + localStorage manually\n const dbs = await indexedDB.databases();\n for (const db of dbs) { if (db.name) indexedDB.deleteDatabase(db.name); }\n localStorage.clear();\n location.reload();\n }\n});\n\n// \u2500\u2500 Compose / Reply / Forward \u2500\u2500\n\ntype ComposeMode = \"new\" | \"reply\" | \"replyAll\" | \"forward\";\n\nasync function openCompose(mode: ComposeMode, overrideMsg?: any, overrideAccountId?: string): Promise<void> {\n logClientEvent(\"openCompose-entry\", { mode });\n // `overrideMsg` lets a detached surface (the message pop-out) compose a\n // reply/forward for ITS message rather than whatever the main viewer has\n // selected. Same `{ message, accountId }` shape getCurrentMessage returns.\n const current = overrideMsg\n ? { message: overrideMsg, accountId: overrideAccountId || currentAccountId }\n : getCurrentMessage();\n // Local-first: if the row is selected we already have its headers in the\n // local DB. Populate the compose form unconditionally; the user can edit\n // anything missing. Don't show \"still loading\" alerts \u2014 the message IS\n // loaded (it's in the list), body is a separate fetch that isn't needed\n // for Reply's headers. Missing fields become empty strings.\n if ((mode === \"reply\" || mode === \"replyAll\" || mode === \"forward\") && !current) {\n // Only true blocker: no message selected at all.\n console.warn(`[compose] ${mode} \u2014 no message selected`);\n return;\n }\n // Parallel-load: kick off getAccounts AND open the iframe in the same\n // tick. The iframe doesn't need the account list until after its editor\n // bootstraps (200-500 ms for TinyMCE, less for Quill); by then the IPC\n // round-trip has resolved. Earlier code awaited getAccounts FIRST,\n // adding the IPC latency to the perceived Ctrl+N \u2192 editor-visible time.\n // We post `compose-init-ready` to the iframe once init is in\n // sessionStorage so compose.ts's IIFE can read synchronously without\n // polling.\n const accountsP = getAccounts();\n const msg = current?.message;\n // Title bar text needs the subject from msg (no IPC dependency) \u2014 build\n // it now so the iframe can be opened with the final title and avoid a\n // flash of placeholder text.\n const titlePrefix =\n mode === \"reply\" ? \"Reply\" :\n mode === \"replyAll\" ? \"Reply All\" :\n mode === \"forward\" ? \"Forward\" :\n \"Compose\";\n const titleSubject = mode === \"new\" ? \"\" : (msg?.subject || \"\");\n const frame = showComposeOverlay(titleSubject ? `${titlePrefix}: ${titleSubject}` : titlePrefix);\n // Now finish initialisation off the critical path \u2014 editor bootstrap\n // inside the iframe runs concurrently with this await.\n const accounts = await accountsP;\n const accountId = current?.accountId || accounts[0]?.id || \"\";\n const rePrefix = /^(re|fwd?):\\s*/i;\n const cleanSubject = msg ? msg.subject.replace(rePrefix, \"\") : \"\";\n\n const init: any = {\n mode,\n accountId,\n to: [],\n cc: [],\n subject: \"\",\n bodyHtml: \"\",\n inReplyTo: \"\",\n references: [],\n accounts: accounts.map((a: any) => ({ id: a.id, name: a.name, email: a.email, signature: a.signature, sig: a.sig })),\n };\n\n // Auto-detect reply From: if the message was delivered to an identity address\n // (an alias on the account's domain, or the explicit `identityDomains` list\n // in accounts.jsonc), reply from that address instead of the account's\n // primary. Always derive identityDomains from the account email's domain\n // when not configured \u2014 explicit list was a regression source (users would\n // see Reply pick the wrong From silently when the list was missing).\n const account = accounts.find((a: any) => a.id === accountId);\n const explicitDomains: string[] = (account?.identityDomains || []).map((d: string) => d.toLowerCase());\n const accountDomain = (account?.email || \"\").split(\"@\")[1]?.toLowerCase();\n // Fold subsumed subdomains. If the list has both `frankston.com` and\n // `bobf.frankston.com`, drop the longer entry \u2014 the matcher's\n // `endsWith(\".frankston.com\")` test already catches it, so listing\n // both is wasted clutter. Same-pair dedup + parent-shadow filter in\n // one pass.\n function foldDomains(list: string[]): string[] {\n const unique = Array.from(new Set(list));\n return unique.filter(d => !unique.some(p => p !== d && d.endsWith(`.${p}`)));\n }\n const identityDomains: string[] = foldDomains(\n explicitDomains.length > 0\n ? explicitDomains\n : (accountDomain ? [accountDomain] : []),\n );\n function detectReplyFrom(): string | undefined {\n if (!msg) return undefined;\n // Delivered-To is set by the receiving server \u2014 it IS an identity at this\n // account, by definition. Trust it unconditionally when present (after\n // deliveredToPrefix stripping in the service). Fall back to To/Cc only\n // when their domain matches the account's identityDomains, since To/Cc\n // can be set by the sender and aren't authoritative.\n if (msg.deliveredTo) {\n console.log(`[compose] reply From \u2192 ${msg.deliveredTo} (Delivered-To)`);\n return msg.deliveredTo;\n }\n if (identityDomains.length === 0) return undefined;\n const candidates: string[] = [\n ...((msg.to || []).map((a: any) => a.address)),\n ...((msg.cc || []).map((a: any) => a.address)),\n ].filter(Boolean);\n for (const addr of candidates) {\n const domain = addr.split(\"@\")[1]?.toLowerCase();\n if (domain && identityDomains.some(d => domain === d || domain.endsWith(`.${d}`))) {\n console.log(`[compose] reply From \u2192 ${addr} (To/Cc match)`);\n return addr;\n }\n }\n console.log(`[compose] no identity match`);\n return undefined;\n }\n\n // Trace what we're feeding the reply \u2014 `[reply-init]` on the daemon log\n // will show the exact `from`/`to`/`cc` values present on the source\n // message at construction time, so a \"To field empty\" report can be\n // diagnosed without re-deriving state. Bob 2026-05-13: had empty To on\n // a clear-From mailing list message; need to see whether msg.from was\n // populated or arrived as null.\n if (msg) {\n try {\n const dump = {\n mode,\n accountId,\n hasMsg: true,\n from: msg.from || null,\n toLen: Array.isArray(msg.to) ? msg.to.length : -1,\n ccLen: Array.isArray(msg.cc) ? msg.cc.length : -1,\n deliveredTo: msg.deliveredTo || \"\",\n identityDomains,\n subject: msg.subject || \"\",\n };\n const apiClient = (window as any).mailxapi;\n if (apiClient?.logClientEvent) {\n apiClient.logClientEvent(\"reply-init\", dump);\n } else {\n console.log(\"[reply-init]\", dump);\n }\n } catch { /* tracing must never break compose */ }\n } else {\n try {\n (window as any).mailxapi?.logClientEvent?.(\"reply-init\", { mode, accountId, hasMsg: false });\n } catch { /* */ }\n }\n\n // Defensive: msg.from / msg.to may be missing on rows that arrived before\n // headers finished loading. Don't push undefined into init.to \u2014 that\n // bubbles to the compose form as literal \"undefined\". Empty-out gracefully.\n if (msg && mode === \"reply\") {\n init.to = msg.from ? [msg.from] : [];\n init.subject = `Re: ${cleanSubject}`;\n init.bodyHtml = quoteBody(msg);\n init.inReplyTo = msg.messageId || \"\";\n init.references = [...(msg.references || []), msg.messageId].filter(Boolean);\n init.fromAddress = detectReplyFrom();\n } else if (msg && mode === \"replyAll\") {\n const toList: any[] = msg.from ? [msg.from] : [];\n if (Array.isArray(msg.to)) {\n for (const a of msg.to) {\n if (a?.address && a.address !== msg.from?.address) toList.push(a);\n }\n }\n init.to = toList;\n init.cc = Array.isArray(msg.cc) ? msg.cc : [];\n init.subject = `Re: ${cleanSubject}`;\n init.bodyHtml = quoteBody(msg);\n init.inReplyTo = msg.messageId || \"\";\n init.references = [...(msg.references || []), msg.messageId].filter(Boolean);\n init.fromAddress = detectReplyFrom();\n } else if (msg && mode === \"forward\") {\n init.subject = `Fwd: ${cleanSubject}`;\n init.bodyHtml = forwardBody(msg);\n init.fromAddress = detectReplyFrom();\n }\n\n\n // Store init data for compose window to pick up. sessionStorage is the\n // canonical handoff path \u2014 same origin between parent and iframe; the\n // compose IIFE reads it after the editor finishes booting. We also\n // postMessage the iframe so it can short-circuit the listen-for-message\n // wait if it's already past editor init.\n sessionStorage.setItem(\"composeInit\", JSON.stringify(init));\n try { frame?.contentWindow?.postMessage({ type: \"compose-init-ready\" }, \"*\"); } catch { /* */ }\n}\n\nfunction showComposeOverlay(title = \"Compose\"): HTMLIFrameElement {\n const wrapper = document.createElement(\"div\");\n wrapper.className = \"compose-overlay\";\n // Full-screen on small/short screens, floating on larger\n const isSmall = window.innerWidth <= 768 || window.innerHeight <= 600;\n if (isSmall) {\n wrapper.style.cssText = \"position:fixed;inset:0;z-index:1600;display:flex;flex-direction:column;background:#fff;\";\n } else {\n // CSS `resize:both` only gives a single lower-right grip. To allow\n // dragging any edge or corner, we omit it here and attach eight\n // manual handles via addComposeResizeHandles() below.\n // Open horizontally centred, near the top \u2014 not docked to the\n // lower-right corner (Bob 2026-05-18). The title bar still drags it\n // anywhere; this is just the initial placement.\n wrapper.style.cssText = \"position:fixed;top:48px;left:calc((100vw - min(900px,55vw)) / 2);width:min(900px,55vw);height:min(700px,70vh);z-index:1600;border-radius:8px;box-shadow:0 4px 24px rgba(0,0,0,0.3);display:flex;flex-direction:column;overflow:hidden;\";\n }\n\n // Title bar \u2014 drag to move; right-side cluster holds discard, popout, close.\n const titleBar = document.createElement(\"div\");\n titleBar.style.cssText = \"display:flex;align-items:center;justify-content:space-between;padding:4px 8px;background:#e8ecf0;border-radius:8px 8px 0 0;cursor:move;user-select:none;flex-shrink:0;\";\n const titleText = document.createElement(\"span\");\n titleText.textContent = title;\n titleBar.appendChild(titleText);\n\n const btnCluster = document.createElement(\"div\");\n btnCluster.style.cssText = \"display:flex;align-items:center;gap:2px;\";\n titleBar.appendChild(btnCluster);\n\n // Outline SVG icons matched to the rest of the toolbar \u2014 16px, 1.6 stroke,\n // currentColor so hover styles work via plain CSS.\n const SVG_ATTRS = `width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"`;\n const TRASH_SVG = `<svg ${SVG_ATTRS}><path d=\"M3 6h18\"/><path d=\"M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6\"/><path d=\"M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2\"/></svg>`;\n const POPOUT_SVG = `<svg ${SVG_ATTRS}><path d=\"M15 3h6v6\"/><path d=\"M10 14 21 3\"/><path d=\"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\"/></svg>`;\n const RESTORE_SVG = `<svg ${SVG_ATTRS}><path d=\"M21 9v-6h-6\"/><path d=\"M3 15v6h6\"/><path d=\"M21 3l-7 7\"/><path d=\"M3 21l7-7\"/></svg>`;\n const makeIconBtn = (svg: string, titleStr: string, hoverColor: string): HTMLButtonElement => {\n const b = document.createElement(\"button\");\n b.title = titleStr;\n b.style.cssText = \"background:none;border:none;cursor:pointer;color:#666;padding:4px 6px;border-radius:4px;display:inline-flex;align-items:center;justify-content:center;line-height:0;\";\n b.innerHTML = svg;\n b.addEventListener(\"mouseenter\", () => b.style.color = hoverColor);\n b.addEventListener(\"mouseleave\", () => b.style.color = \"#666\");\n return b;\n };\n\n const discardBtn = makeIconBtn(TRASH_SVG, \"Discard draft\", \"#c00\");\n discardBtn.addEventListener(\"click\", () => {\n try {\n const win = frame.contentWindow;\n if (win) win.dispatchEvent(new Event(\"compose-discard\"));\n } catch { /* */ }\n });\n btnCluster.appendChild(discardBtn);\n\n // Popout = toggle between the floating overlay and a host-window-filling\n // layout. The compose iframe can't open in a real OS window (msger custom\n // protocol doesn't propagate to child windows), so \"popout\" here means\n // \"fill the host window\" \u2014 second click restores the floating geometry.\n const popoutBtn = makeIconBtn(POPOUT_SVG, \"Maximize\", \"#000\");\n let maximized = false;\n let savedCss = \"\";\n popoutBtn.addEventListener(\"click\", () => {\n if (!maximized) {\n savedCss = wrapper.style.cssText;\n wrapper.style.cssText = \"position:fixed;inset:0;z-index:1600;display:flex;flex-direction:column;background:#fff;overflow:hidden;\";\n popoutBtn.innerHTML = RESTORE_SVG;\n popoutBtn.title = \"Restore\";\n maximized = true;\n } else {\n wrapper.style.cssText = savedCss;\n popoutBtn.innerHTML = POPOUT_SVG;\n popoutBtn.title = \"Maximize\";\n maximized = false;\n }\n });\n btnCluster.appendChild(popoutBtn);\n\n const closeBtn = document.createElement(\"button\");\n closeBtn.textContent = \"\u2715\";\n closeBtn.title = \"Save draft and close\";\n closeBtn.style.cssText = \"background:none;border:none;font-size:16px;cursor:pointer;color:#666;padding:2px 6px;border-radius:4px;\";\n closeBtn.addEventListener(\"mouseenter\", () => closeBtn.style.color = \"#c00\");\n closeBtn.addEventListener(\"mouseleave\", () => closeBtn.style.color = \"#666\");\n closeBtn.addEventListener(\"click\", () => {\n // compose.ts handles the prompt (Save/Discard/Cancel) and then calls\n // window.close() which is redirected to wrapper.remove() at line below.\n // If the user cancels the prompt, closeCompose() is never called and\n // the wrapper stays. Don't force-remove on a timer \u2014 that defeats Cancel.\n try {\n const win = frame.contentWindow;\n if (win) win.dispatchEvent(new Event(\"compose-save-and-close\"));\n } catch { /* */ }\n });\n btnCluster.appendChild(closeBtn);\n\n // Drag to move. While dragging we set pointer-events:none on the iframe\n // so mouse events don't get swallowed by the inner document the moment\n // the cursor crosses into the iframe region. Without that, drag only\n // worked if you stayed on the title bar pixels, which is why it felt\n // broken except at the lower-right (resize grip) corner.\n let dragX = 0, dragY = 0;\n titleBar.addEventListener(\"mousedown\", (e: MouseEvent) => {\n if (e.target === closeBtn) return;\n e.preventDefault();\n const rect = wrapper.getBoundingClientRect();\n dragX = e.clientX - rect.left;\n dragY = e.clientY - rect.top;\n // Clamp movement to the viewport so the title bar stays grabbable.\n const clamp = (val: number, min: number, max: number) => Math.max(min, Math.min(max, val));\n const shield = installDragShield(\"move\");\n const onMove = (ev: MouseEvent) => {\n ev.preventDefault();\n const w = wrapper.offsetWidth;\n const h = wrapper.offsetHeight;\n const left = clamp(ev.clientX - dragX, 0, window.innerWidth - 40);\n const top = clamp(ev.clientY - dragY, 0, window.innerHeight - 40);\n wrapper.style.left = `${left}px`;\n wrapper.style.top = `${top}px`;\n wrapper.style.bottom = \"auto\";\n wrapper.style.right = \"auto\";\n };\n const onUp = () => {\n shield.remove();\n document.removeEventListener(\"mousemove\", onMove);\n document.removeEventListener(\"mouseup\", onUp);\n };\n document.addEventListener(\"mousemove\", onMove);\n document.addEventListener(\"mouseup\", onUp);\n });\n\n const frame = document.createElement(\"iframe\");\n frame.src = \"compose/compose.html\";\n frame.style.cssText = \"flex:1;border:none;background:#fff;width:100%;\";\n\n // Close when compose calls window.close()\n frame.addEventListener(\"load\", () => {\n try {\n const win = frame.contentWindow;\n if (win) {\n (win as any).close = () => wrapper.remove();\n }\n } catch { /* cross-origin safety */ }\n });\n\n // Bring to front on click\n wrapper.addEventListener(\"mousedown\", () => {\n document.querySelectorAll(\".compose-overlay\").forEach(el => (el as HTMLElement).style.zIndex = \"1000\");\n wrapper.style.zIndex = \"1001\";\n });\n\n wrapper.appendChild(titleBar);\n wrapper.appendChild(frame);\n if (!isSmall) addComposeResizeHandles(wrapper, frame);\n document.body.appendChild(wrapper);\n return frame;\n}\n\n/** Drop a transparent full-viewport shield in front of every other element\n * so mousemove events stay in the document during a drag. Setting\n * pointer-events:none on the compose iframe alone wasn't enough \u2014 the\n * message-list / preview iframes underneath still captured the cursor\n * when it crossed their boundaries, freezing the drag at random\n * midpoints (most visibly at the list\u2194preview seam). One shield blocks\n * every iframe at once. Caller removes it on mouseup. */\nfunction installDragShield(cursor: string): HTMLDivElement {\n const shield = document.createElement(\"div\");\n shield.style.cssText = `position:fixed;inset:0;z-index:9999;background:transparent;cursor:${cursor};user-select:none;`;\n document.body.appendChild(shield);\n return shield;\n}\n\n/** Attach eight resize grippers (4 edges + 4 corners) to a positioned wrapper.\n * CSS `resize:both` only supports a single lower-right corner; replacing it\n * with manual handles is the only way to make any side / corner draggable.\n * Each handle captures the starting geometry on mousedown, then mousemove\n * adjusts width/height/left/top in the directions implied by which edge. */\nfunction addComposeResizeHandles(wrapper: HTMLElement, frame: HTMLIFrameElement): void {\n const MIN_W = 320;\n const MIN_H = 200;\n const dirs: { [k: string]: { cursor: string; style: string } } = {\n n: { cursor: \"ns-resize\", style: \"top:-3px;left:12px;right:12px;height:6px;\" },\n s: { cursor: \"ns-resize\", style: \"bottom:-3px;left:12px;right:12px;height:6px;\" },\n e: { cursor: \"ew-resize\", style: \"right:-3px;top:12px;bottom:12px;width:6px;\" },\n w: { cursor: \"ew-resize\", style: \"left:-3px;top:12px;bottom:12px;width:6px;\" },\n ne: { cursor: \"nesw-resize\", style: \"top:-3px;right:-3px;width:14px;height:14px;\" },\n nw: { cursor: \"nwse-resize\", style: \"top:-3px;left:-3px;width:14px;height:14px;\" },\n se: { cursor: \"nwse-resize\", style: \"bottom:-3px;right:-3px;width:14px;height:14px;\" },\n sw: { cursor: \"nesw-resize\", style: \"bottom:-3px;left:-3px;width:14px;height:14px;\" },\n };\n for (const [dir, conf] of Object.entries(dirs)) {\n const h = document.createElement(\"div\");\n h.style.cssText = `position:absolute;z-index:2;background:transparent;cursor:${conf.cursor};${conf.style}`;\n h.addEventListener(\"mousedown\", (e: MouseEvent) => {\n e.preventDefault();\n e.stopPropagation();\n const rect = wrapper.getBoundingClientRect();\n const startX = e.clientX, startY = e.clientY;\n const startL = rect.left, startT = rect.top;\n const startW = rect.width, startH = rect.height;\n // Pin to top-left so left/top can be adjusted to grow upward/leftward.\n wrapper.style.left = `${startL}px`;\n wrapper.style.top = `${startT}px`;\n wrapper.style.right = \"auto\";\n wrapper.style.bottom = \"auto\";\n const shield = installDragShield(conf.cursor);\n const onMove = (ev: MouseEvent) => {\n const dx = ev.clientX - startX;\n const dy = ev.clientY - startY;\n let newW = startW, newH = startH, newL = startL, newT = startT;\n if (dir.includes(\"e\")) newW = Math.max(MIN_W, startW + dx);\n if (dir.includes(\"w\")) { newW = Math.max(MIN_W, startW - dx); newL = startL + (startW - newW); }\n if (dir.includes(\"s\")) newH = Math.max(MIN_H, startH + dy);\n if (dir.includes(\"n\")) { newH = Math.max(MIN_H, startH - dy); newT = startT + (startH - newH); }\n wrapper.style.width = `${newW}px`;\n wrapper.style.height = `${newH}px`;\n wrapper.style.left = `${newL}px`;\n wrapper.style.top = `${newT}px`;\n };\n const onUp = () => {\n shield.remove();\n document.removeEventListener(\"mousemove\", onMove);\n document.removeEventListener(\"mouseup\", onUp);\n };\n document.addEventListener(\"mousemove\", onMove);\n document.addEventListener(\"mouseup\", onUp);\n });\n wrapper.appendChild(h);\n }\n}\n\n// Marketing-email layout tables (deeply nested, fixed widths) collapse to\n// 30-40px columns inside a phone-width compose pane and wrap text\n// character-by-character. Strip styles + flatten tables before quoting.\nfunction sanitizeQuotedBody(msg: any): string {\n // Two-mode quote: plain-text gets a pre-wrap wrapper so line breaks\n // render as breaks; HTML is preserved verbatim modulo a minimal scrub\n // for tags that have no legitimate place inside a quoted reply.\n //\n // We do NOT strip inline styles, classes, or table attributes any\n // more \u2014 that earlier aggressive strip destroyed 95% of the original\n // sender's formatting (CSS in email is almost entirely inline; see\n // discussion 2026-05-11 about how every mail client preserves the\n // source HTML). Wide content is now clamped via CSS (.reply *\n // { max-width: 100% } in compose.css) instead of by rewriting the\n // DOM. Tables stay tables, paragraphs stay paragraphs, font sizes\n // and colors survive.\n //\n // Script-class tags (<script>, <style>, <link>, <base>, on*= attrs,\n // javascript: URLs) are belt-and-braces \u2014 sanitizeHtml in mailx-core\n // already strips them at body-store time, so msg.bodyHtml shouldn't\n // contain them. Stripping again here is cheap insurance against a\n // future provider/path that didn't go through that pipeline.\n const isPlainText = !msg.bodyHtml;\n if (isPlainText) {\n // Full HTML escape. Leaving `>` unescaped was tempting for source\n // readability but breaks HTML in edge cases \u2014 TinyMCE's normalize-\n // on-paste re-interprets the input, and stray `>` near sequences\n // like `<!--` / `-->` / `<!` in plain-text bodies can be misread\n // by the parser. Per Bob 2026-05-12: \"not just ugly, it breaks\n // the HTML.\" Trivial source-clutter is the lesser evil.\n //\n // CRLF \u2192 <br>. `white-space:pre-wrap` alone is not enough: TinyMCE\n // (and other HTML editors) normalize text-node whitespace when\n // setContent ingests the HTML, collapsing `\\n` to spaces BEFORE\n // CSS runs. The literal `<br>` survives the normalization, so we\n // get one line break per source line whether the editor preserves\n // raw whitespace or not. pre-wrap stays as belt-and-braces and to\n // keep multi-space runs (alignment, indented quote-markers like\n // `> > >`) visible.\n const escaped = String(msg.bodyText || \"\")\n .replace(/&/g, \"&\").replace(/</g, \"<\").replace(/>/g, \">\")\n .replace(/\\r\\n?/g, \"\\n\")\n .replace(/\\n/g, \"<br>\");\n return `<div style=\"white-space:pre-wrap;font-family:inherit;margin:0\">${escaped}</div>`;\n }\n let body: string = msg.bodyHtml;\n // Minimal defense-in-depth strip. <style> blocks would leak global\n // CSS into the compose document; <link> / <base> would fetch remote\n // resources; <script> would be inert in contenteditable but the tag\n // would persist into the sent message which is rude.\n body = body.replace(/<script[^>]*>[\\s\\S]*?<\\/script>/gi, \"\");\n body = body.replace(/<style[^>]*>[\\s\\S]*?<\\/style>/gi, \"\");\n body = body.replace(/<link[^>]*>/gi, \"\");\n body = body.replace(/<base[^>]*>/gi, \"\");\n body = body.replace(/\\s+on\\w+=\"[^\"]*\"/gi, \"\");\n body = body.replace(/\\s+on\\w+='[^']*'/gi, \"\");\n return body;\n}\n\nfunction quoteBody(msg: any): string {\n const date = new Date(msg.date).toLocaleString();\n const from = msg.from.name ? `${msg.from.name} <${msg.from.address}>` : msg.from.address;\n const body = sanitizeQuotedBody(msg);\n // Lead with an empty paragraph so every editor has a real block for the\n // caret to land in and the user's reply to flow into \u2014 bare <br>s aren't\n // a block container, so TinyMCE's caret fell through to the quote (Bob\n // 2026-05-21). The blank-line spacing lives in <br>s AFTER the </p>, not\n // inside it.\n return `<p></p><br><br><div class=\"reply\"><p>On ${date}, ${from} wrote:</p><blockquote>${body}</blockquote></div>`;\n}\n\nfunction forwardBody(msg: any): string {\n const date = new Date(msg.date).toLocaleString();\n const from = msg.from.name ? `${msg.from.name} <${msg.from.address}>` : msg.from.address;\n const to = msg.to.map((a: any) => a.name ? `${a.name} <${a.address}>` : a.address).join(\", \");\n const body = sanitizeQuotedBody(msg);\n return `<p></p><br><br><div class=\"reply\"><p>---------- Forwarded message ----------<br>From: ${from}<br>Date: ${date}<br>Subject: ${msg.subject}<br>To: ${to}</p>${body}</div>`;\n}\n\n// \u2500\u2500 Delete with undo \u2500\u2500\n\ninterface DeletedMessage {\n accountId: string;\n uid: number;\n folderId: number;\n subject: string;\n}\n\ninterface MovedBatch {\n messages: { accountId: string; uid: number; sourceFolderId: number }[];\n targetAccountId: string;\n targetFolderId: number;\n}\n\nlet lastDeleted: DeletedMessage | null = null;\nlet lastMoved: MovedBatch | null = null;\nlet undoTimeout: ReturnType<typeof setTimeout> | null = null;\n\n/** Route a \"delete the selection\" action (Delete key, Ctrl+D, top trash\n * button) to whichever pane has a selection. Tasks take priority \u2014 if\n * any task rows are selected, delete those; otherwise fall through to\n * messages. The previous behavior was to always delete messages, which\n * surprised users who'd just selected a few tasks and hit Delete. */\nasync function deleteSelection(): Promise<void> {\n try {\n const sidebar = await import(\"./components/calendar-sidebar.js\");\n if (sidebar.getSelectedTaskUuids().length > 0) {\n await sidebar.deleteSelectedTasks();\n return;\n }\n } catch { /* sidebar module not loaded yet \u2014 fall through to messages */ }\n await deleteSelectedMessages();\n}\n\nasync function deleteSelectedMessages(): Promise<void> {\n const selected = getSelectedMessages();\n\n // Fall back to single message from viewer if nothing selected in list\n if (selected.length === 0) {\n const current = getCurrentMessage();\n if (!current) return;\n selected.push({ accountId: current.accountId, uid: current.message.uid, folderId: current.message.folderId });\n }\n\n const statusSync = document.getElementById(\"status-sync\");\n\n // Optimistic UI: remove from list IMMEDIATELY, then queue the IPC.\n // Old order awaited the daemon round-trip (IPC + DB updates) before\n // the rows disappeared, which felt sluggish on bigger selections or\n // when the IPC was congested. Spam button already worked this way;\n // trash now matches. If the IPC fails, the next folder reload\n // re-populates the row and the catch block surfaces the error.\n const snapshot = [...selected];\n removeMessagesAndReconcile(selected);\n\n // Undo support set immediately too \u2014 Ctrl+Z works the moment rows\n // disappear from the list, not only after the daemon ACKs.\n if (snapshot.length === 1) {\n lastDeleted = { ...snapshot[0], subject: \"\" };\n if (statusSync) statusSync.textContent = `Trashed 1 message (syncing) \u2014 Ctrl+Z to undo`;\n } else {\n lastDeleted = null;\n if (statusSync) statusSync.textContent = `Trashed ${snapshot.length} messages (syncing)`;\n }\n if (undoTimeout) clearTimeout(undoTimeout);\n undoTimeout = setTimeout(() => {\n lastDeleted = null;\n if (statusSync?.textContent?.includes(\"undo\")) statusSync.textContent = \"\";\n }, 30000);\n\n // Fire-and-forget per local-first: optimistic remove above already\n // updated the UI; the daemon-side trash is sync DB + queued IMAP.\n // An IPC 120s timeout doesn't mean the trash failed \u2014 surfacing it\n // as a status-bar error would only mislead. Real errors are still\n // reported by next sync's diagnostics.\n const byAccount = new Map<string, number[]>();\n for (const msg of snapshot) {\n const uids = byAccount.get(msg.accountId) || [];\n uids.push(msg.uid);\n byAccount.set(msg.accountId, uids);\n }\n for (const [accountId, uids] of byAccount) {\n deleteMessages(accountId, uids).catch((e: any) => {\n console.error(`Delete failed for ${accountId}: ${e?.message || e}`);\n if (statusSync) statusSync.textContent = `Delete sync issue (${accountId}): ${e?.message || e}`;\n });\n }\n}\n\nasync function undoDelete(): Promise<void> {\n if (!lastDeleted) return;\n const { accountId, uid, folderId } = lastDeleted;\n\n try {\n await undeleteMessage(accountId, uid, folderId);\n\n const statusSync = document.getElementById(\"status-sync\");\n if (statusSync) statusSync.textContent = \"Message restored\";\n lastDeleted = null;\n if (undoTimeout) clearTimeout(undoTimeout);\n reloadCurrentFolder();\n } catch (e: any) {\n console.error(`Undo failed: ${e.message}`);\n }\n}\n\nasync function undoMove(): Promise<void> {\n if (!lastMoved) return;\n const { messages } = lastMoved;\n const statusSync = document.getElementById(\"status-sync\");\n try {\n // Group by (sourceAccountId, sourceFolderId) and move each group back\n const byDest = new Map<string, { accountId: string; folderId: number; uids: number[] }>();\n for (const m of messages) {\n const key = `${m.accountId}:${m.sourceFolderId}`;\n if (!byDest.has(key)) byDest.set(key, { accountId: m.accountId, folderId: m.sourceFolderId, uids: [] });\n byDest.get(key)!.uids.push(m.uid);\n }\n const { moveMessages, moveMessage } = await import(\"./lib/api-client.js\");\n for (const group of byDest.values()) {\n if (group.uids.length === 1) await moveMessage(group.accountId, group.uids[0], group.folderId);\n else await moveMessages(group.accountId, group.uids, group.folderId);\n }\n if (statusSync) statusSync.textContent = `Undid move of ${messages.length} message${messages.length !== 1 ? \"s\" : \"\"}`;\n lastMoved = null;\n if (undoTimeout) clearTimeout(undoTimeout);\n reloadCurrentFolder();\n } catch (e: any) {\n console.error(`Undo move failed: ${e.message}`);\n if (statusSync) statusSync.textContent = `Undo move failed: ${e.message}`;\n }\n}\n\n// Listen for the \"mailx-moved\" custom event emitted by folder-tree's drop\n// handler so Ctrl+Z can reverse the most recent move.\ndocument.addEventListener(\"mailx-moved\", (e: any) => {\n lastMoved = e.detail as MovedBatch;\n lastDeleted = null; // Ctrl+Z undoes whichever came last\n if (undoTimeout) clearTimeout(undoTimeout);\n undoTimeout = setTimeout(() => { lastMoved = null; }, 60000);\n});\n\ndocument.getElementById(\"btn-delete\")?.addEventListener(\"click\", deleteSelection);\n// Same handlers also bound to the top-toolbar icons so delete/spam work\n// regardless of whether a message is open in the viewer. Useful for quick\n// triage from a list-only view. Top trash button uses deleteSelection so\n// it follows the user's task selection when present (otherwise messages).\ndocument.getElementById(\"btn-tb-delete\")?.addEventListener(\"click\", deleteSelection);\ndocument.getElementById(\"btn-tb-spam\")?.addEventListener(\"click\", spamSelectedMessages);\n\n// \u2500\u2500 Flag toggle \u2500\u2500\n/** Sync the toolbar Flag button (glyph + gold colour) to the viewed\n * message's flagged state, so it mirrors the message-list row star. */\nfunction updateFlagButton(): void {\n const btn = document.getElementById(\"btn-flag\");\n if (!btn) return;\n const sel = getCurrentFocused();\n const yes = !!sel && flaggedOf(sel);\n btn.classList.toggle(\"flagged\", yes);\n btn.textContent = yes ? \"\u2605\" : \"\u2606\";\n}\ndocument.addEventListener(\"mailx-message-shown\", updateFlagButton);\n\ndocument.getElementById(\"btn-flag\")?.addEventListener(\"click\", async () => {\n const sel = getCurrentFocused();\n if (!sel) return;\n const wasFlagged = flaggedOf(sel);\n setFlagged(sel, !wasFlagged);\n updateFlagButton();\n try {\n await updateFlags(sel.accountId, sel.uid, sel.flags);\n messageState.updateMessageFlags(sel.accountId, sel.uid, sel.flags);\n // Row owns its own DOM \\u2014 go through the row object so class + star\n // update atomically and the list/preview stay in sync.\n setRowFlagged(sel.accountId, sel.uid, !wasFlagged);\n } catch (e: unknown) {\n // Revert local state on failure so visual + data stay consistent.\n setFlagged(sel, wasFlagged);\n updateFlagButton();\n console.error(`Flag toggle failed: ${(e as Error).message}`);\n }\n});\n\nasync function spamSelectedMessages(): Promise<void> {\n console.log(\"[spam] click \u2014 finding selection\");\n const selected = getSelectedMessages();\n if (selected.length === 0) {\n const current = getCurrentMessage();\n if (!current) {\n console.warn(\"[spam] no message selected and none in viewer \u2014 nothing to do\");\n alert(\"No message selected. Click a message first, then the spam button.\");\n return;\n }\n selected.push({ accountId: current.accountId, uid: current.message.uid, folderId: current.message.folderId });\n }\n console.log(`[spam] marking ${selected.length} message(s):`, selected);\n const statusSync = document.getElementById(\"status-sync\");\n // Optimistic: remove from list immediately so the user sees action happen.\n // If the IPC fails, put them back. This matches local-first \u2014 the server\n // sync is a background detail, the user's action should feel instant.\n const snapshot = [...selected];\n removeMessagesAndReconcile(selected);\n // Fire-and-forget per local-first: the optimistic remove above has\n // already updated the UI; the service-side move is sync DB + queued\n // IMAP. An IPC 120s timeout here doesn't mean the move failed \u2014 it\n // means the response is stuck behind a long-running prior op (e.g.\n // simpleParser blocking the event loop). The local commit and server\n // sync still happen. Surfacing it as a failure with an alert lies to\n // the user; the next folder reload reconciles either way.\n const byAccount = new Map<string, number[]>();\n for (const msg of snapshot) {\n const uids = byAccount.get(msg.accountId) || [];\n uids.push(msg.uid);\n byAccount.set(msg.accountId, uids);\n }\n if (statusSync) statusSync.textContent = `Spam: ${snapshot.length} queued \u2014 pending server sync`;\n for (const [accountId, uids] of byAccount) {\n markAsSpamMessages(accountId, uids)\n .then(result => {\n console.log(`[spam] ${accountId}: moved ${result?.moved ?? uids.length} to folderId=${result?.targetFolderId}`);\n })\n .catch(e => {\n console.error(`[spam] ${accountId} failed:`, e);\n if (statusSync) statusSync.textContent = `Spam sync issue (${accountId}): ${e?.message || e}`;\n });\n }\n}\n\ndocument.getElementById(\"btn-spam\")?.addEventListener(\"click\", spamSelectedMessages);\n\n/** Show/hide the Spam button based on whether the current account has \"spam\" configured. */\nasync function refreshSpamButtonVisibility(): Promise<void> {\n const btn = document.getElementById(\"btn-spam\") as HTMLButtonElement | null;\n if (!btn) return;\n const current = getCurrentMessage();\n const accountId = current?.accountId || currentAccountId;\n if (!accountId) { btn.hidden = true; return; }\n try {\n const accounts = await getAccounts();\n const acct = accounts.find((a: any) => a.id === accountId);\n btn.hidden = !acct?.spam;\n } catch { btn.hidden = true; }\n}\n\ndocument.addEventListener(\"mailx-message-shown\", refreshSpamButtonVisibility);\ndocument.addEventListener(\"mailx-folder-changed\", refreshSpamButtonVisibility);\n\n// Q100 placeholder \u2014 append a row to ~/.mailx/spam.csv for later analysis.\n// No folder move, no flag change, no auto-delete. Button is always visible\n// (no configuration required; unlike btn-spam which needs a junk folder).\ndocument.getElementById(\"btn-spam-report\")?.addEventListener(\"click\", async () => {\n const current = getCurrentMessage();\n const msg = current?.message;\n const accountId = current?.accountId;\n if (!msg || !accountId) return;\n const btn = document.getElementById(\"btn-spam-report\") as HTMLButtonElement;\n const originalLabel = btn.textContent;\n btn.disabled = true;\n btn.textContent = \"\u2026\";\n try {\n const { recordSpamReport } = await import(\"./lib/api-client.js\");\n await recordSpamReport(accountId, msg.uid, msg.folderId);\n btn.textContent = \"\u2713\";\n const status = document.getElementById(\"status-sync\");\n if (status) status.textContent = \"Logged to ~/.rmfmail/spam.csv\";\n setTimeout(() => { btn.textContent = originalLabel; btn.disabled = false; }, 1500);\n } catch (e: any) {\n btn.textContent = \"\u2717\";\n const status = document.getElementById(\"status-sync\");\n if (status) status.textContent = `Spam log failed: ${e?.message || e}`;\n setTimeout(() => { btn.textContent = originalLabel; btn.disabled = false; }, 2500);\n }\n});\n\ndocument.getElementById(\"btn-compose\")?.addEventListener(\"click\", () => openCompose(\"new\"));\n\ndocument.getElementById(\"btn-mark-unread\")?.addEventListener(\"click\", () => {\n // Toggle \\Seen on the currently-focused message. Mirrors the R\n // keyboard shortcut and the right-click \"Mark unread\" menu item, but\n // as a visible toolbar button so users discover the behavior.\n const sel = getCurrentFocused();\n if (!sel) return;\n const wasSeen = seenOf(sel);\n setSeen(sel, !wasSeen);\n updateFlags(sel.accountId, sel.uid, sel.flags).then(() => {\n messageState.updateMessageFlags(sel.accountId, sel.uid, sel.flags);\n const row = document.querySelector(`.ml-row[data-uid=\"${sel.uid}\"][data-account-id=\"${sel.accountId}\"]`) as HTMLElement;\n if (row) row.classList.toggle(\"unread\", wasSeen);\n }).catch(() => { setSeen(sel, wasSeen); });\n});\n\ndocument.getElementById(\"btn-reply\")?.addEventListener(\"click\", () => openCompose(\"reply\"));\ndocument.getElementById(\"btn-reply-all\")?.addEventListener(\"click\", () => openCompose(\"replyAll\"));\ndocument.getElementById(\"btn-forward\")?.addEventListener(\"click\", () => openCompose(\"forward\"));\n\n// \u2500\u2500 Icon rail wiring \u2500\u2500\n// Rail is the always-visible vertical bar on the far left (Thunderbird/Dovecot\n// style). Mostly mirrors toolbar/menu actions for one-click access; calendar /\n// tasks / contacts buttons are placeholders until those features ship.\ndocument.getElementById(\"rail-compose\")?.addEventListener(\"click\", () => openCompose(\"new\"));\ndocument.getElementById(\"rail-inbox\")?.addEventListener(\"click\", () => {\n // Trigger the existing folder-tree click on the first inbox folder.\n const inbox = document.querySelector('.folder-tree .folder-item[data-special-use=\"inbox\"]') as HTMLElement | null;\n inbox?.click();\n});\ndocument.getElementById(\"rail-unified\")?.addEventListener(\"click\", () => {\n const unified = document.querySelector('.folder-tree .all-inboxes') as HTMLElement | null\n || document.getElementById(\"ft-all-inboxes\");\n unified?.click();\n});\ndocument.getElementById(\"rail-contacts\")?.addEventListener(\"click\", async () => {\n const { openAddressBook } = await import(\"./components/address-book.js\");\n openAddressBook();\n setRailActive(\"rail-contacts\");\n});\n// Q114 decided 2026-04-24: full-screen calendar/tasks modals are\n// temporarily retired \u2014 the right-docked sidebar (calendar-sidebar.ts)\n// owns both views. Rail buttons now just reveal the sidebar. Files kept\n// (`calendar.ts`, `tasks.ts`) for potential revival; not imported.\ndocument.getElementById(\"rail-calendar\")?.addEventListener(\"click\", async () => {\n const { showCalendarSidebar } = await import(\"./components/calendar-sidebar.js\");\n await showCalendarSidebar();\n // Flip the View-menu checkbox so the on-state stays coherent across paths.\n const optSidebar = document.getElementById(\"opt-calendar-sidebar\") as HTMLInputElement | null;\n if (optSidebar) optSidebar.checked = true;\n setRailActive(\"rail-calendar\");\n});\ndocument.getElementById(\"rail-tasks\")?.addEventListener(\"click\", async () => {\n const { showCalendarSidebar } = await import(\"./components/calendar-sidebar.js\");\n await showCalendarSidebar();\n // Scroll the sidebar to the tasks section if possible.\n document.getElementById(\"cal-side-tasks\")?.scrollIntoView({ block: \"start\", behavior: \"smooth\" });\n const optSidebar = document.getElementById(\"opt-calendar-sidebar\") as HTMLInputElement | null;\n if (optSidebar) optSidebar.checked = true;\n setRailActive(\"rail-tasks\");\n});\n/** Open a toolbar dropdown (View / Settings) anchored to a rail icon.\n *\n * In wide mode the toolbar buttons own the menu and do their own toggle.\n * In narrow mode the toolbar is `display:none`, so the dropdown \u2014 which\n * lives as a child of `.tb-menu` inside the toolbar \u2014 is invisible no\n * matter what `hidden` is set to. Forwarding the rail click to the\n * toolbar button toggled `hidden` but the user still saw nothing, hence\n * the \"setup icon does nothing\" bug report.\n *\n * The fix: when a rail icon opens the menu, reparent the dropdown to\n * `<body>` (so the toolbar's display:none can't hide it), switch to\n * `position: fixed` anchored to the icon, and let the existing menu\n * handlers fire normally \u2014 same dropdown DOM, same listeners, same\n * content. We never put the dropdown back: the toolbar handler also\n * works on a body-attached dropdown (it's just toggling .hidden), and\n * re-parenting on every toggle would defeat any focus state inside. */\nfunction openMenuFromRail(dropdownId: string, _anchor: HTMLElement): void {\n const dd = document.getElementById(dropdownId);\n if (!dd) return;\n if (!dd.hidden) { dd.hidden = true; return; }\n // Close sibling rail-opened menus so two don't stack.\n for (const id of [\"settings-dropdown\", \"view-dropdown\", \"restart-dropdown\"]) {\n const other = document.getElementById(id);\n if (other && other !== dd) other.hidden = true;\n }\n if (dd.parentElement?.classList.contains(\"tb-menu\")) {\n document.body.appendChild(dd);\n }\n // Uniform behavior across desktop and Android: render rail-opened menus\n // as a centered modal with a dimmed backdrop. Anchor-based positioning\n // was inconsistent (worked on Android narrow but not Mac/desktop wide;\n // bottom-rail icons sit at the viewport floor and the menu spilled off\n // or landed under chrome). A centered card is always visible, always\n // dismissible by tapping the backdrop, and the same on every platform.\n dd.style.cssText = \"\";\n dd.style.position = \"fixed\";\n dd.style.top = \"50%\";\n dd.style.left = \"50%\";\n dd.style.transform = \"translate(-50%, -50%)\";\n dd.style.maxHeight = \"85vh\";\n dd.style.maxWidth = \"92vw\";\n dd.style.overflowY = \"auto\";\n dd.style.boxShadow = \"0 8px 32px rgba(0,0,0,0.4)\";\n dd.style.zIndex = \"10000\";\n dd.hidden = false;\n ensureRailMenuBackdrop();\n}\n\n/** Restore a body-parented rail-opened dropdown back to its toolbar parent\n * and clear inline positioning, so the toolbar button's plain\n * `dd.hidden = !dd.hidden` toggle renders the dropdown in its usual\n * position-absolute place. Without this, the toolbar dropdown opens at\n * the rail-modal's centered position (or somewhere else stale) the first\n * time a user mixes rail-open and toolbar-open in the same session. */\nfunction restoreToolbarDropdown(ddId: string, tbMenuId: string): void {\n const dd = document.getElementById(ddId);\n const tb = document.getElementById(tbMenuId);\n if (!dd || !tb) return;\n if (dd.parentElement === document.body) {\n tb.appendChild(dd);\n dd.style.cssText = \"\";\n }\n}\n\n/** Narrow-tier rail menus need a tap-anywhere-to-close backdrop because the\n * rail itself stays open under the modal. The document outside-click handler\n * hides the dropdown but doesn't visually convey \"modal\" \u2014 the backdrop\n * dims the page, makes the modal look like one, and gives a single\n * unambiguous click target for dismissal. Auto-removes when the dropdown\n * hides (we observe the [hidden] flip via MutationObserver). */\nfunction ensureRailMenuBackdrop(): void {\n if (document.getElementById(\"rail-menu-backdrop\")) return;\n const bd = document.createElement(\"div\");\n bd.id = \"rail-menu-backdrop\";\n bd.style.cssText = \"position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9999;\";\n bd.addEventListener(\"click\", () => {\n for (const id of [\"settings-dropdown\", \"view-dropdown\", \"restart-dropdown\"]) {\n const dd = document.getElementById(id);\n if (dd && !dd.hidden) dd.hidden = true;\n }\n bd.remove();\n });\n document.body.appendChild(bd);\n // Self-clean when no rail menu is open\n const cleanup = () => {\n const anyOpen = [\"settings-dropdown\", \"view-dropdown\", \"restart-dropdown\"]\n .some(id => { const el = document.getElementById(id); return el && !el.hidden; });\n if (!anyOpen) bd.remove();\n };\n const obs = new MutationObserver(cleanup);\n for (const id of [\"settings-dropdown\", \"view-dropdown\", \"restart-dropdown\"]) {\n const el = document.getElementById(id);\n if (el) obs.observe(el, { attributes: true, attributeFilter: [\"hidden\"] });\n }\n}\n\ndocument.getElementById(\"rail-settings\")?.addEventListener(\"click\", (e) => {\n e.stopPropagation();\n openMenuFromRail(\"settings-dropdown\", e.currentTarget as HTMLElement);\n});\ndocument.getElementById(\"rail-view\")?.addEventListener(\"click\", (e) => {\n e.stopPropagation();\n openMenuFromRail(\"view-dropdown\", e.currentTarget as HTMLElement);\n});\ndocument.getElementById(\"rail-help\")?.addEventListener(\"click\", () => {\n document.getElementById(\"btn-about\")?.click();\n});\n// rail-theme button removed 2026-05-04 \u2014 theme picker lives in the Settings\n// dropdown (collapsed into a single \"Theme: <current> \u25B8\" item with a popout).\n// Removing the dedicated rail icon was the user's call; keeping the radios in\n// Settings is enough since the picker is rare-use.\n\nfunction applyTheme(theme: \"system\" | \"light\" | \"dark\"): void {\n document.documentElement.setAttribute(\"data-theme\", theme);\n try { localStorage.setItem(\"mailx-theme\", theme); } catch { /* private mode */ }\n // Reflect in the Settings menu radios so the two paths stay in sync.\n const radio = document.getElementById(`opt-theme-${theme}`) as HTMLInputElement | null;\n if (radio) radio.checked = true;\n // Update the submenu's \"current\" badge so the user sees the choice\n // without expanding.\n const cur = document.getElementById(\"theme-submenu-current\");\n if (cur) cur.textContent = theme.charAt(0).toUpperCase() + theme.slice(1);\n}\n\n/** Set the document root font-size in px. The CSS uses rem-based sizes, so\n * changing the root scales the entire UI proportionally. Persisted to\n * localStorage and to preferences.jsonc.ui.fontSize (the latter syncs\n * across devices via GDrive). The pref already existed in the schema\n * with a 15px default \u2014 it just wasn't wired up. */\nfunction applyFontSize(px: number): void {\n const clamped = Math.max(10, Math.min(24, px));\n document.documentElement.style.fontSize = `${clamped}px`;\n try { localStorage.setItem(\"mailx-font-size\", String(clamped)); } catch { /* private mode */ }\n}\n\n// Restore saved font size from localStorage on startup. preferences.jsonc\n// is the cloud-synced source of truth but reading it requires async IPC;\n// localStorage gives us a synchronous, instant first paint at the right\n// size. The async path can update later if the cloud value differs.\n(() => {\n const saved = (() => {\n try { return parseInt(localStorage.getItem(\"mailx-font-size\") || \"15\", 10); } catch { return 15; }\n })();\n applyFontSize(Number.isFinite(saved) ? saved : 15);\n})();\n\n// Restore saved theme + wire the Settings radios. Defaults to \"system\".\n(() => {\n const saved = (() => { try { return localStorage.getItem(\"mailx-theme\") || \"system\"; } catch { return \"system\"; } })();\n applyTheme(saved as \"system\" | \"light\" | \"dark\");\n for (const t of [\"system\", \"light\", \"dark\"] as const) {\n document.getElementById(`opt-theme-${t}`)?.addEventListener(\"change\", (e) => {\n if ((e.target as HTMLInputElement).checked) applyTheme(t);\n });\n }\n // Wire the submenu toggle. Click to expand; click outside / on a radio closes.\n const togBtn = document.getElementById(\"theme-submenu-toggle\");\n const popout = document.getElementById(\"theme-submenu-popout\");\n if (togBtn && popout) {\n togBtn.addEventListener(\"click\", (e) => {\n e.stopPropagation();\n const open = popout.hidden;\n popout.hidden = !open;\n togBtn.setAttribute(\"aria-expanded\", String(open));\n });\n // Close on radio change so the menu doesn't linger after the choice.\n popout.querySelectorAll<HTMLInputElement>('input[type=\"radio\"]').forEach(r => {\n r.addEventListener(\"change\", () => {\n popout.hidden = true;\n togBtn.setAttribute(\"aria-expanded\", \"false\");\n });\n });\n }\n})();\n// Highlight the current rail target. For now just inbox is the default; once\n// calendar/tasks ship, update this on view change.\nfunction setRailActive(id: string): void {\n document.querySelectorAll(\".rail-btn[data-active]\").forEach(el => el.removeAttribute(\"data-active\"));\n document.getElementById(id)?.setAttribute(\"data-active\", \"true\");\n}\ndocument.addEventListener(\"mailx-folder-changed\", () => setRailActive(\"rail-inbox\"));\n\n// Context menu events from message-list right-click\ndocument.addEventListener(\"mailx-compose\", ((e: CustomEvent) => {\n if (e.detail.mode === \"draft\" && sessionStorage.getItem(\"composeInit\")) {\n // Draft already stored by viewer \u2014 just show overlay\n showComposeOverlay();\n } else {\n openCompose(e.detail.mode);\n }\n}) as EventListener);\ndocument.addEventListener(\"mailx-delete\", () => deleteSelection());\n\n// Share / mailto intents (Android-side: MainActivity routes share-sheet\n// invocations and mailto: links here. Desktop has no equivalent today \u2014\n// MSIX / Windows share-target requires packaging changes we deferred).\n// Payload: { action, text, subject, mailto }. Mailto URLs are parsed\n// (RFC 2368: mailto:to?subject=...&body=...&cc=...&bcc=...) and used to\n// pre-fill compose. Plain shares (text/url) drop into the body / subject.\ndocument.addEventListener(\"mailx-share-intent\", ((e: CustomEvent) => {\n const detail = e.detail || {};\n const init: any = { mode: \"new\", to: [], cc: [], bcc: [], subject: \"\", bodyHtml: \"\" };\n if (detail.mailto) {\n try {\n const url = new URL(detail.mailto);\n const to = decodeURIComponent(url.pathname).split(\",\").map((s: string) => s.trim()).filter(Boolean);\n init.to = to;\n const sp = url.searchParams;\n const subj = sp.get(\"subject\"); if (subj) init.subject = subj;\n const body = sp.get(\"body\"); if (body) init.bodyHtml = `<p>${body.replace(/\\n/g, \"<br>\")}</p>`;\n const cc = sp.get(\"cc\"); if (cc) init.cc = cc.split(\",\").map(s => s.trim()).filter(Boolean);\n const bcc = sp.get(\"bcc\"); if (bcc) init.bcc = bcc.split(\",\").map(s => s.trim()).filter(Boolean);\n } catch { /* malformed mailto: \u2014 drop the URL into body so user can fix */ init.bodyHtml = `<p>${escapeHtml(detail.mailto)}</p>`; }\n } else {\n if (detail.subject) init.subject = detail.subject;\n if (detail.text) init.bodyHtml = `<p>${escapeHtml(String(detail.text)).replace(/\\n/g, \"<br>\")}</p>`;\n }\n sessionStorage.setItem(\"composeInit\", JSON.stringify(init));\n showComposeOverlay(init.subject || \"Compose\");\n}) as EventListener);\n\n// \u2500\u2500 Search \u2500\u2500\n\nlet searchTimeout: ReturnType<typeof setTimeout>;\nconst searchInput = document.getElementById(\"search-input\") as HTMLInputElement;\nconst searchScope = document.getElementById(\"search-scope\") as HTMLSelectElement;\n\n// Recent-searches dropdown \u2014 backed by localStorage, populated into the\n// existing <datalist id=\"search-history\"> so the browser's native picker\n// shows on focus/click. No edit / delete UI; editing is implicit (pick a\n// past query, modify, hit Enter \u2014 the modified version is recorded).\n// Cap at 25 to keep the list usable.\nconst SEARCH_HISTORY_KEY = \"mailx-search-history\";\nconst SEARCH_HISTORY_MAX = 25;\nfunction loadSearchHistory(): string[] {\n try { return JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) || \"[]\"); }\n catch { return []; }\n}\nfunction saveSearchHistory(list: string[]): void {\n try { localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(list)); }\n catch { /* private mode */ }\n}\nfunction refreshSearchHistoryDatalist(): void {\n const dl = document.getElementById(\"search-history\") as HTMLDataListElement | null;\n if (!dl) return;\n const items = loadSearchHistory();\n dl.innerHTML = items.map(q => `<option value=\"${q.replace(/\"/g, \""\")}\"></option>`).join(\"\");\n}\nfunction recordSearchHistory(query: string): void {\n const trimmed = query.trim();\n if (!trimmed) return;\n const cur = loadSearchHistory();\n // Move-to-front dedup, AND drop any entry that is a prefix of this\n // query \u2014 that collapses the in-progress typing (\"Hod\", \"Hodd\") into\n // the final \"Hoddie\", so a live search the user never pressed Enter on\n // still lands in history exactly once.\n const filtered = cur.filter(q => q !== trimmed && !trimmed.startsWith(q));\n filtered.unshift(trimmed);\n if (filtered.length > SEARCH_HISTORY_MAX) filtered.length = SEARCH_HISTORY_MAX;\n saveSearchHistory(filtered);\n refreshSearchHistoryDatalist();\n}\nrefreshSearchHistoryDatalist();\n\n// \u2500\u2500 Live search-syntax highlighting \u2500\u2500\n// An overlay behind the search input colors recognized qualifiers\n// (from:/to:/subject:/date:/after:/before:/has:/is:/folder:), FTS boolean\n// operators (OR/NOT/AND), quoted phrases, and /regex/ literals \u2014 reparsed\n// on every keystroke so the user sees how mailx will interpret the query.\n// An invalid /regex/ only goes red after a brief settle delay, so it\n// doesn't flash red while the pattern is still being typed.\nconst searchHl = document.getElementById(\"search-hl\");\nconst SEARCH_QUALIFIERS = new Set([\"from\", \"to\", \"subject\", \"date\", \"after\", \"before\", \"has\", \"is\", \"folder\"]);\nconst SEARCH_REGEX_SETTLE_MS = 500;\nlet searchRegexSettleTimer: ReturnType<typeof setTimeout> | null = null;\n\nfunction escapeHl(s: string): string {\n return s.replace(/[&<>]/g, c => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\" }[c] || c));\n}\n\nfunction renderSearchHighlight(settled: boolean): void {\n if (!searchHl || !searchInput) return;\n const text = searchInput.value;\n // Tokenize keeping whitespace runs so the overlay lines up glyph-for-\n // glyph with the input's own text.\n const tokens = text.match(/\\s+|\"[^\"]*\"?|\\S+/g) || [];\n let html = \"\";\n for (const tok of tokens) {\n if (/^\\s+$/.test(tok)) { html += escapeHl(tok); continue; }\n // /regex/ literal \u2014 leading slash, body, optional closing slash.\n if (tok.startsWith(\"/\") && tok.length > 1) {\n let bad = false;\n if (tok.endsWith(\"/\")) {\n try { new RegExp(tok.slice(1, -1), \"i\"); } catch { bad = true; }\n }\n const cls = (bad && settled) ? \"sh-regex-bad\" : \"sh-regex\";\n html += `<span class=\"${cls}\">${escapeHl(tok)}</span>`;\n continue;\n }\n // qualifier:value \u2014 color the keyword: prefix when recognized.\n const qm = tok.match(/^([a-z]+):(.*)$/i);\n if (qm && SEARCH_QUALIFIERS.has(qm[1].toLowerCase())) {\n html += `<span class=\"sh-key\">${escapeHl(qm[1] + \":\")}</span>${escapeHl(qm[2])}`;\n continue;\n }\n // FTS boolean operator.\n if (/^(OR|NOT|AND)$/.test(tok)) {\n html += `<span class=\"sh-op\">${escapeHl(tok)}</span>`;\n continue;\n }\n // Quoted phrase.\n if (tok.startsWith(\"\\\"\")) {\n html += `<span class=\"sh-phrase\">${escapeHl(tok)}</span>`;\n continue;\n }\n html += escapeHl(tok);\n }\n searchHl.innerHTML = html;\n searchHl.scrollLeft = searchInput.scrollLeft;\n}\n\nfunction updateSearchHighlight(): void {\n renderSearchHighlight(false);\n if (searchRegexSettleTimer) clearTimeout(searchRegexSettleTimer);\n searchRegexSettleTimer = setTimeout(() => renderSearchHighlight(true), SEARCH_REGEX_SETTLE_MS);\n}\n\n// Keep the overlay scroll-aligned when the input scrolls horizontally,\n// and seed it once at startup.\nsearchInput?.addEventListener(\"scroll\", () => {\n if (searchHl) searchHl.scrollLeft = searchInput.scrollLeft;\n});\nsearchInput?.addEventListener(\"change\", () => updateSearchHighlight());\nupdateSearchHighlight();\n\n// Server search is slow (per-folder IMAP SEARCH across ~90 folders). It must\n// never gate the local result display, and it should only fire once typing\n// has settled. Local search runs immediately on every doSearch; the server\n// pass is scheduled `SERVER_SEARCH_DALLY_MS` later and any new keystroke\n// clears+reschedules it. message-list's loadGen counter discards a stale\n// server pass's results, so a restart is effectively an abort at the UI.\nconst SERVER_SEARCH_DALLY_MS = 700;\nlet serverSearchTimer: ReturnType<typeof setTimeout> | null = null;\n\nfunction doSearch(immediate = false): void {\n const query = searchInput.value.trim();\n if (query.length === 0) { reloadCurrentFolder(); return; }\n if (query.length < 2 && !immediate) return;\n\n // P20: orthogonal \"Server\" checkbox. When checked, the search ALSO spans\n // every folder on the server \u2014 but as a deferred second phase, never a\n // replacement for the instant local pass.\n const serverCheck = document.getElementById(\"search-server-too\") as HTMLInputElement | null;\n const trashCheck = document.getElementById(\"search-include-trash\") as HTMLInputElement | null;\n const localScope = searchScope?.value || \"all\";\n const includeTrash = !!trashCheck?.checked;\n const serverOn = !!serverCheck?.checked;\n\n // Any re-run aborts a pending server pass \u2014 it'll be rescheduled below\n // if still wanted. This is the \"editing the search aborts + restarts\"\n // behavior: each keystroke cancels the prior server search. clearTimeout\n // kills a not-yet-fired pass; cancelServerSearch() aborts one that's\n // already mid-sweep on the daemon (generation bump \u2192 loop bails).\n if (serverSearchTimer) { clearTimeout(serverSearchTimer); serverSearchTimer = null; }\n cancelServerSearch();\n\n // \"This folder\" scope: instant client-side filter of the visible rows.\n // Only when the server checkbox is OFF \u2014 with it on we want the real\n // local+server search, not a row filter.\n if (localScope === \"current\" && !serverOn && !immediate) {\n const body = document.getElementById(\"ml-body\");\n if (body) {\n const lower = query.toLowerCase();\n for (const row of body.querySelectorAll(\".ml-row\")) {\n const text = row.textContent?.toLowerCase() || \"\";\n (row as HTMLElement).classList.toggle(\"filter-hidden\", !text.includes(lower));\n }\n }\n return;\n }\n\n // \u2500\u2500 Phase 1: LOCAL search \u2014 always, immediately \u2500\u2500\n // The local SQLite store is fast; results paint as the user types.\n const localScopeEff = localScope === \"current\" ? \"current\" : \"all\";\n loadSearchResults(query, localScopeEff, currentAccountId, currentFolderId, includeTrash);\n setTitle(`${APP_NAME} - Search: ${query}`);\n setActiveTabView(\n { kind: \"search\", query, scope: serverOn ? \"server\" : localScopeEff, accountId: currentAccountId, folderId: currentFolderId, includeTrash },\n `Search: ${query}`,\n );\n // Record every executed search \u2014 Enter AND a settled debounced search\n // alike. The prefix-pruning in recordSearchHistory() collapses the\n // keystroke progression, so a search the user typed without pressing\n // Enter (\"Hoddie\") still shows up in history.\n recordSearchHistory(query);\n\n // \u2500\u2500 Phase 2: SERVER search \u2014 deferred, augments phase 1 \u2500\u2500\n // Fires after the dally so a burst of keystrokes only triggers one IMAP\n // sweep. The local rows from phase 1 stay on screen the whole time\n // (loadSearchResults won't blank a list that already has rows), so the\n // server pass never makes the user stare at an empty list.\n if (serverOn) {\n serverSearchTimer = setTimeout(() => {\n serverSearchTimer = null;\n loadSearchResults(query, \"server\", currentAccountId, currentFolderId, includeTrash);\n }, SERVER_SEARCH_DALLY_MS);\n }\n}\n\n// Track current folder for scoped search\nlet currentAccountId = \"\";\nlet currentFolderId = 0;\nlet reloadDebounceTimer: ReturnType<typeof setTimeout> | null = null;\n\nsearchInput?.addEventListener(\"input\", () => {\n clearTimeout(searchTimeout);\n if (serverSearchTimer) { clearTimeout(serverSearchTimer); serverSearchTimer = null; cancelServerSearch(); }\n updateSearchHighlight();\n if (searchInput.value.trim() === \"\") {\n // Cleared \u2014 reset immediately, no debounce. Must exit search mode\n // first; otherwise reloadCurrentFolder() sees searchMode=true and\n // re-runs the stale query (user-reported regression 2026-04-24).\n clearSearchMode();\n const body = document.getElementById(\"ml-body\");\n if (body) body.querySelectorAll(\".filter-hidden\").forEach(r => r.classList.remove(\"filter-hidden\"));\n reloadCurrentFolder();\n setTitle(APP_NAME);\n } else {\n // Short debounce \u2014 just enough to coalesce a keystroke burst. The\n // local pass is cheap; the server pass has its own longer dally\n // inside doSearch, so this stays snappy.\n searchTimeout = setTimeout(() => doSearch(false), 180);\n }\n});\nsearchInput?.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") {\n clearTimeout(searchTimeout);\n doSearch(true);\n }\n if (e.key === \"Escape\") {\n searchInput.value = \"\";\n if (serverSearchTimer) { clearTimeout(serverSearchTimer); serverSearchTimer = null; }\n cancelServerSearch();\n updateSearchHighlight();\n clearSearchMode();\n // Clear any client-side filters\n const body = document.getElementById(\"ml-body\");\n if (body) body.querySelectorAll(\".filter-hidden\").forEach(r => r.classList.remove(\"filter-hidden\"));\n reloadCurrentFolder();\n setTitle(APP_NAME);\n }\n});\n\n// Re-run the active search when the scope dropdown or \"server too\" checkbox\n// flips. Without this, switching all/current/server after typing the query\n// left the old result set on screen \u2014 the controls looked like they did\n// nothing. Treat the change as `immediate=true` so the user sees the new\n// scope's results without having to retype Enter; clear any client-side\n// filter-hidden flags from the prior \"current folder\" path so the row set\n// resets cleanly.\nfunction rerunActiveSearch(): void {\n if (!searchInput || searchInput.value.trim() === \"\") return;\n const body = document.getElementById(\"ml-body\");\n if (body) body.querySelectorAll(\".filter-hidden\").forEach(r => r.classList.remove(\"filter-hidden\"));\n clearTimeout(searchTimeout);\n doSearch(true);\n}\nsearchScope?.addEventListener(\"change\", rerunActiveSearch);\ndocument.getElementById(\"search-server-too\")?.addEventListener(\"change\", rerunActiveSearch);\ndocument.getElementById(\"search-include-trash\")?.addEventListener(\"change\", rerunActiveSearch);\n\n// Message state handles move/delete \u2014 no manual event listener needed\n\n// \u2500\u2500 Folder filter \u2500\u2500\nconst ftFilterInput = document.getElementById(\"ft-filter-input\") as HTMLInputElement;\nif (ftFilterInput) {\n ftFilterInput.addEventListener(\"input\", () => {\n const query = ftFilterInput.value.toLowerCase();\n const tree = document.getElementById(\"folder-tree\");\n if (!tree) return;\n\n if (!query) {\n // Clear filter \u2014 show everything\n tree.querySelectorAll(\".ft-filter-hidden\").forEach(el => el.classList.remove(\"ft-filter-hidden\"));\n return;\n }\n\n // Hide all folders first, then show matches + their parent accounts\n const folders = tree.querySelectorAll(\".ft-folder\");\n const accounts = tree.querySelectorAll(\".ft-account\");\n\n for (const acct of accounts) (acct as HTMLElement).classList.add(\"ft-filter-hidden\");\n for (const f of folders) (f as HTMLElement).classList.add(\"ft-filter-hidden\");\n\n for (const f of folders) {\n const name = f.querySelector(\".ft-folder-name\")?.textContent?.toLowerCase() || \"\";\n if (name.includes(query)) {\n (f as HTMLElement).classList.remove(\"ft-filter-hidden\");\n // Show parent account\n const acct = f.closest(\".ft-account\");\n if (acct) (acct as HTMLElement).classList.remove(\"ft-filter-hidden\");\n }\n }\n\n // Also show unified inbox if it matches\n const unified = tree.querySelector(\".ft-unified\");\n if (unified) {\n const text = unified.textContent?.toLowerCase() || \"\";\n (unified as HTMLElement).classList.toggle(\"ft-filter-hidden\", !text.includes(query));\n }\n });\n\n ftFilterInput.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Escape\") {\n ftFilterInput.value = \"\";\n ftFilterInput.dispatchEvent(new Event(\"input\"));\n }\n });\n}\n\n// \u2500\u2500 Open links from email body in system browser \u2500\u2500\n\nwindow.addEventListener(\"message\", (e) => {\n // Relay traces from iframes (compose) to Node via our working bridge.\n // The iframe calls logClientEvent which tries its own bridge first; if\n // that path is broken it also posts here as backup. Tag gets a `via-relay`\n // suffix when the iframe couldn't reach its own bridge \u2014 that alone\n // diagnoses whether the iframe bridge works.\n if (e.data?.type === \"mailx-trace\" && typeof e.data.tag === \"string\") {\n const relayTag = e.data.bridged ? e.data.tag : `${e.data.tag} (via-relay)`;\n logClientEvent(relayTag, e.data.data);\n return;\n }\n // Compose-send relay: iframe posts the send request here because its own\n // bridge call to sendMessage was failing to reach Node. This window's\n // bridge is proven (getAccounts / getOutboxStatus run every few seconds\n // with no failures), so we do the IPC from here and post the result back\n // to the iframe via its source. `e.source` is the iframe's window; use it\n // so targeting works even if the iframe moves in the DOM.\n // S61 2026-04-24: parent-relay compose close. On Android the\n // window.close() override applied in `frame.onload` doesn't always fire\n // (WebView2 / MAUI WebView dispatches close to the shell in some cases),\n // leaving the compose overlay stuck after Send. postMessage is reliable;\n // compose.ts's closeCompose() posts this, and we find-and-remove the\n // overlay whose iframe window matches e.source.\n if (e.data?.type === \"mailx-compose-close\") {\n const src = e.source as Window | null;\n document.querySelectorAll<HTMLElement>(\".compose-overlay\").forEach(el => {\n const iframe = el.querySelector<HTMLIFrameElement>(\"iframe\");\n if (!src || iframe?.contentWindow === src) el.remove();\n });\n return;\n }\n if (e.data?.type === \"mailx-compose-send\" && e.data.id && e.data.body) {\n const src = e.source as Window | null;\n const id = e.data.id;\n logClientEvent(\"relay-compose-send-received\", { id });\n (async () => {\n try {\n await apiSendMessage(e.data.body);\n logClientEvent(\"relay-compose-send-ok\", { id });\n src?.postMessage({ type: \"mailx-compose-send-result\", id, ok: true }, \"*\" as any);\n } catch (err: any) {\n const msg = err?.message || String(err);\n logClientEvent(\"relay-compose-send-error\", { id, error: msg });\n src?.postMessage({ type: \"mailx-compose-send-result\", id, ok: false, error: msg }, \"*\" as any);\n }\n })();\n return;\n }\n // Generic IPC relay: the iframe's api-client routes every IPC call through\n // postMessage when it's running in a child frame. Same reason as the\n // compose-send relay \u2014 sendMessage wasn't the only method the iframe's\n // bridge dropped; saveDraft hit the same wall (\"Draft save failed: mailxapi\n // timeout\"). This handler invokes the named method on THIS window's\n // mailxapi and posts the result back to the iframe.\n if (e.data?.type === \"mailx-ipc\" && e.data.id && e.data.method) {\n const src = e.source as Window | null;\n const { id, method, args } = e.data;\n const bridge = (window as any).mailxapi;\n const fn = bridge?.[method];\n if (typeof fn !== \"function\") {\n src?.postMessage({ type: \"mailx-ipc-result\", id, ok: false, error: `parent bridge has no method \"${method}\"` }, \"*\" as any);\n return;\n }\n try {\n const result = fn.apply(bridge, args || []);\n Promise.resolve(result).then(\n (value) => src?.postMessage({ type: \"mailx-ipc-result\", id, ok: true, result: value }, \"*\" as any),\n (err) => src?.postMessage({ type: \"mailx-ipc-result\", id, ok: false, error: err?.message || String(err) }, \"*\" as any),\n );\n } catch (err: any) {\n src?.postMessage({ type: \"mailx-ipc-result\", id, ok: false, error: err?.message || String(err) }, \"*\" as any);\n }\n return;\n }\n if (e.data?.type === \"openLink\" && e.data.url) {\n window.open(e.data.url, \"_blank\", \"noopener,noreferrer\");\n }\n if (e.data?.type === \"linkClick\" && e.data.url) {\n const url = e.data.url;\n if ((window as any).mailxapi?.platform === \"android\") {\n // Android: use mailxapi:// bridge scheme \u2014 OnNavigating intercepts it\n // and opens in system browser. Raw http:// in sub-frames doesn't trigger OnNavigating.\n const f = document.createElement(\"iframe\");\n f.style.display = \"none\";\n f.src = `mailxapi://openurl?url=${encodeURIComponent(url)}`;\n document.body.appendChild(f);\n setTimeout(() => f.remove(), 500);\n } else {\n window.open(url, \"_blank\", \"noopener,noreferrer\");\n }\n }\n if (e.data?.type === \"previewToggleFullscreen\") {\n toggleFullscreenPreview();\n return;\n }\n if (e.data?.type === \"previewContextMenu\") {\n // Translate iframe-relative coords to viewport. Find the iframe whose\n // contentWindow matches the source so we map correctly even when\n // multiple iframes are present (compose iframe + viewer iframe).\n let iframeRect: DOMRect | null = null;\n for (const f of Array.from(document.querySelectorAll(\"iframe\"))) {\n if ((f as HTMLIFrameElement).contentWindow === e.source) { iframeRect = f.getBoundingClientRect(); break; }\n }\n const x = (iframeRect?.left || 0) + (e.data.x || 0);\n const y = (iframeRect?.top || 0) + (e.data.y || 0);\n showPreviewBodyMenu(\n x, y,\n String(e.data.selectedText || \"\"),\n e.source as Window | null,\n String(e.data.linkUrl || \"\") || undefined,\n String(e.data.linkText || \"\") || undefined,\n );\n return;\n }\n if (e.data?.type === \"linkContextMenu\") {\n // C29: right-click in body iframe \u2192 Open / Save / Copy URL menu.\n // Iframe's clientX/Y is relative to the iframe; translate to viewport.\n let iframeRect: DOMRect | null = null;\n for (const f of Array.from(document.querySelectorAll(\"iframe\"))) {\n if ((f as HTMLIFrameElement).contentWindow === e.source) { iframeRect = f.getBoundingClientRect(); break; }\n }\n const x = (iframeRect?.left || 0) + (e.data.x || 0);\n const y = (iframeRect?.top || 0) + (e.data.y || 0);\n const url: string = e.data.url || \"\";\n // Find a sensible filename for the Save action.\n const guessName = (() => {\n try {\n const u = new URL(url);\n const last = u.pathname.split(\"/\").pop() || \"\";\n return last && last.includes(\".\") ? last : \"\";\n } catch { return \"\"; }\n })();\n const items: { label: string; action: () => void }[] = [\n { label: \"Open in browser\", action: () => {\n window.open(url, \"_blank\", \"noopener,noreferrer\");\n }},\n { label: guessName ? `Save \"${guessName}\"\u2026` : \"Save link as\u2026\", action: () => {\n // Trigger a download via anchor with download attr.\n const a = document.createElement(\"a\");\n a.href = url;\n if (guessName) a.download = guessName;\n else a.download = \"\";\n a.style.display = \"none\";\n document.body.appendChild(a);\n a.click();\n setTimeout(() => a.remove(), 1000);\n }},\n { label: \"Copy URL\", action: async () => {\n try { await navigator.clipboard.writeText(url); }\n catch { prompt(\"URL:\", url); }\n }},\n { label: \"Copy link text\", action: async () => {\n try { await navigator.clipboard.writeText(e.data.text || url); }\n catch { prompt(\"Text:\", e.data.text || url); }\n }},\n ];\n // Build a tiny inline menu (showContextMenu would do but it's in components/).\n const menu = document.createElement(\"div\");\n menu.style.cssText = `position:fixed;z-index:2400;background:var(--color-bg);border:1px solid var(--color-border);border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,0.2);padding:4px 0;font-size:13px;min-width:180px;`;\n menu.style.left = `${Math.min(x, window.innerWidth - 200)}px`;\n menu.style.top = `${Math.min(y, window.innerHeight - 200)}px`;\n // mousedown inside the menu must NOT reach the document-level\n // dismiss handler \u2014 otherwise the menu is removed before click\n // fires on the row and the action silently no-ops (user report\n // 2026-04-24). Stop propagation at the menu root covers every row.\n menu.addEventListener(\"mousedown\", (ev) => ev.stopPropagation());\n for (const it of items) {\n const row = document.createElement(\"div\");\n row.textContent = it.label;\n row.style.cssText = `padding:6px 12px;cursor:pointer;`;\n row.addEventListener(\"mouseenter\", () => row.style.background = \"var(--color-bg-hover)\");\n row.addEventListener(\"mouseleave\", () => row.style.background = \"\");\n row.addEventListener(\"click\", () => { menu.remove(); it.action(); });\n menu.appendChild(row);\n }\n document.body.appendChild(menu);\n const dismiss = () => { menu.remove(); document.removeEventListener(\"mousedown\", dismiss); document.removeEventListener(\"keydown\", dismiss); };\n setTimeout(() => {\n document.addEventListener(\"mousedown\", dismiss);\n document.addEventListener(\"keydown\", dismiss);\n }, 0);\n return;\n }\n if (e.data?.type === \"mailx-send-error\") {\n // Send failed AFTER compose closed (fire-and-forget model). Surface in\n // the status bar so the user sees something instead of the silence.\n const statusSync = document.getElementById(\"status-sync\");\n if (statusSync) {\n statusSync.textContent = `Send failed: ${e.data.message}`;\n statusSync.style.color = \"oklch(0.65 0.2 25)\";\n }\n return;\n }\n if (e.data?.type === \"linkHover\") {\n // Cancel any pending show \u2014 every hoverover/hoverout from the iframe\n // triggers this branch. Without the timer, the popover appears\n // instantly and lingers when the user moves to do anything else,\n // including punching through the compose overlay (which sits at\n // z-index 1000 \u2014 popover was at 10000, hence the bug in the\n // screenshot). Now: 1500ms hover delay; suppressed entirely when\n // any overlay (compose, modal) is open; auto-dismissed on click,\n // scroll, blur, or any keypress.\n const w = window as any;\n if (w._linkHoverShowTimer) { clearTimeout(w._linkHoverShowTimer); w._linkHoverShowTimer = null; }\n let pop = document.getElementById(\"link-hover-popover\") as HTMLDivElement | null;\n const hidePop = () => { if (pop) pop.style.display = \"none\"; };\n if (!e.data.url) { hidePop(); return; }\n // Suppress when compose / modal overlay is up \u2014 user shouldn't see\n // a tooltip for a link they can't reach without dismissing first.\n if (document.querySelector(\".compose-overlay, .mailx-modal-backdrop\")) { hidePop(); return; }\n const data = e.data;\n const source = e.source;\n w._linkHoverShowTimer = setTimeout(() => {\n // Re-check overlay state at fire time \u2014 overlay may have appeared\n // during the 1500ms wait.\n if (document.querySelector(\".compose-overlay, .mailx-modal-backdrop\")) return;\n if (!pop) {\n pop = document.createElement(\"div\");\n pop.id = \"link-hover-popover\";\n // z-index 500 \u2014 above the message body iframe (no z-index)\n // but BELOW the compose overlay (z-index 1000) and modals (2000).\n pop.style.cssText = \"position:fixed;z-index:500;max-width:520px;padding:6px 10px;background:var(--color-surface,#fff);color:var(--color-text,#000);border:1px solid var(--color-border,#888);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.18);font-size:12px;line-height:1.4;word-break:break-all;pointer-events:none;\";\n document.body.appendChild(pop);\n // One-time dismissers on the popover lifetime.\n const dismiss = () => hidePop();\n document.addEventListener(\"mousedown\", dismiss, true);\n document.addEventListener(\"scroll\", dismiss, true);\n document.addEventListener(\"keydown\", dismiss, true);\n window.addEventListener(\"blur\", dismiss);\n }\n pop.textContent = data.url;\n pop.style.display = \"block\";\n let iframeRect: DOMRect | null = null;\n for (const f of Array.from(document.querySelectorAll(\"iframe\"))) {\n if ((f as HTMLIFrameElement).contentWindow === source) { iframeRect = f.getBoundingClientRect(); break; }\n }\n const r = data.rect;\n if (iframeRect && r) {\n const x = Math.max(4, Math.min(window.innerWidth - 528, iframeRect.left + r.left));\n let y = iframeRect.top + r.bottom + 4;\n if (y + 60 > window.innerHeight) y = Math.max(4, iframeRect.top + r.top - 60);\n pop.style.left = x + \"px\";\n pop.style.top = y + \"px\";\n }\n }, 1500);\n }\n if (e.data?.type === \"previewKey\" && typeof e.data.key === \"string\") {\n // Re-dispatch as a real keydown on document so the hotkey handler\n // below runs the same code path as a list-focused keypress. Used\n // when focus is inside the sandboxed preview iframe \u2014 works on\n // platforms where parent-side contentDocument listeners don't.\n const ev = new KeyboardEvent(\"keydown\", {\n key: e.data.key, code: e.data.code || \"\",\n ctrlKey: !!e.data.ctrlKey, shiftKey: !!e.data.shiftKey,\n altKey: !!e.data.altKey, metaKey: !!e.data.metaKey,\n bubbles: true, cancelable: true,\n });\n document.dispatchEvent(ev);\n }\n});\n\n// \u2500\u2500 Splitter drag \u2500\u2500\n\nconst splitter = document.getElementById(\"splitter-h\");\nif (splitter) {\n // Restore saved position\n const saved = localStorage.getItem(\"mailx-split\");\n if (saved) document.documentElement.style.setProperty(\"--list-viewer-split\", saved);\n\n let dragging = false;\n let startX: number;\n let startSplit: number;\n\n splitter.addEventListener(\"pointerdown\", (e: PointerEvent) => {\n dragging = true;\n startX = e.clientX;\n const mainArea = document.querySelector(\".main-area\") as HTMLElement;\n startSplit = mainArea.getBoundingClientRect().width * (parseFloat(getComputedStyle(document.documentElement).getPropertyValue(\"--list-viewer-split\")) / 100);\n splitter.setPointerCapture(e.pointerId);\n });\n\n splitter.addEventListener(\"pointermove\", (e: PointerEvent) => {\n if (!dragging) return;\n const mainArea = document.querySelector(\".main-area\") as HTMLElement;\n const totalWidth = mainArea.getBoundingClientRect().width;\n const newSplit = ((startSplit + (e.clientX - startX)) / totalWidth) * 100;\n const clamped = Math.max(15, Math.min(85, newSplit));\n const val = `${clamped}%`;\n document.documentElement.style.setProperty(\"--list-viewer-split\", val);\n localStorage.setItem(\"mailx-split\", val);\n });\n\n splitter.addEventListener(\"pointerup\", () => { dragging = false; });\n}\n\n// \u2500\u2500 WebSocket for live updates \u2500\u2500\n\nconnectWebSocket();\n\n// Priority-sender index \u2014 fetch once on load. Refreshed on the contacts\n// configChanged event (below) so changes from another device propagate\n// without a full reload.\nrefreshPriorityIndex().catch(() => { /* service may not be up yet */ });\n\nonWsEvent((event) => {\n const statusSync = document.getElementById(\"status-sync\");\n const startupStatus = document.getElementById(\"startup-status\");\n\n switch (event.type) {\n case \"connected\":\n if (statusSync) statusSync.textContent = \"Connected\";\n if (startupStatus) startupStatus.textContent = \"Loading accounts...\";\n // Don't refresh folder tree on connect \u2014 it's already loaded by initFolderTree\n break;\n case \"syncProgress\": {\n // Aggregate folders phases (\"folders:<path>\" when starting a folder,\n // \"folders-done\" between folders) print as a proportion so the user\n // can see forward progress instead of a meaningless \"47%\". Older\n // phase strings (\"sync:<path>\", \"folders\") still render raw.\n let label = `${event.phase} ${event.progress || 0}%`;\n if (typeof event.phase === \"string\" && event.phase.startsWith(\"folders:\")) {\n const folderPath = event.phase.slice(\"folders:\".length);\n label = `folders \u2014 ${folderPath} (${event.progress || 0}%)`;\n } else if (event.phase === \"folders-done\") {\n label = `folders ${event.progress || 0}% done`;\n }\n if (statusSync) statusSync.textContent = `Syncing ${event.accountId}: ${label}`;\n if (startupStatus) startupStatus.textContent = `Syncing ${event.accountId}: ${label}`;\n // Mark syncing folder in tree \u2014 bubble up to visible parent if collapsed\n const syncPath = event.phase?.startsWith(\"sync:\") ? event.phase.slice(5) : null;\n // Clear previous syncing markers for this account\n document.querySelectorAll(`.ft-folder.ft-syncing[data-account-id=\"${event.accountId}\"]`).forEach(el => el.classList.remove(\"ft-syncing\"));\n if (syncPath && event.progress < 100) {\n // Try exact match first\n let folderEl = document.querySelector(`.ft-folder[data-account-id=\"${event.accountId}\"][data-folder-path=\"${CSS.escape(syncPath)}\"]`);\n if (!folderEl) {\n // Folder not visible (parent collapsed) \u2014 find nearest visible ancestor\n const parts = syncPath.split(/[./]/);\n for (let i = parts.length - 1; i >= 1; i--) {\n const parentPath = parts.slice(0, i).join(\".\");\n folderEl = document.querySelector(`.ft-folder[data-account-id=\"${event.accountId}\"][data-folder-path=\"${CSS.escape(parentPath)}\"]`);\n if (folderEl) break;\n }\n }\n if (folderEl) folderEl.classList.add(\"ft-syncing\");\n }\n break;\n }\n case \"syncComplete\":\n // After sync completes, refresh the folder tree (critical for first-run on Android\n // where folders don't exist until sync fetches them from Gmail API)\n refreshFolderTree();\n // Q53: track per-account last-sync timestamp for the status-bar hover.\n recordAccountSync(event.accountId);\n // Earlier I added reloadCurrentFolder() here to fix the\n // \"phone INBOX stays on placeholder\" report. That broke desktop\n // because syncComplete fires repeatedly on the desktop sync\n // loop, and each reload rebuilds the message-list DOM\n // mid-read \u2014 the focusedRow Row object becomes a stale\n // pointer to a detached element, and the viewer's \"Select a\n // message to read\" placeholder stays up while the new row\n // shows .selected. Removed: folderCountsChanged already\n // triggers a debounced reload when new messages actually\n // arrive (which is what the phone case really needs anyway).\n break;\n case \"folderSynced\":\n // Per-folder timestamps \u2014 drives the tooltip + freshness dot.\n for (const entry of event.entries || []) {\n setFolderSynced(event.accountId, entry.folderId, entry.syncedAt);\n if (currentFolderId === entry.folderId && currentAccountId === event.accountId) {\n currentFolderSyncedAt = entry.syncedAt;\n renderNarrowFolderTitle();\n }\n }\n break;\n case \"folderCountsChanged\": {\n // Incremental update only \u2014 updateFolderCounts patches badge counts\n // in-place and falls back to a full refreshFolderTree() when the\n // folder structure has actually changed. Calling both was doing a\n // 300 ms debounced rebuild on every sync tick even when just the\n // unread count moved \u2014 visible as folder-tree flicker on Dovecot\n // accounts where STATUS polls fire frequently.\n updateFolderCounts();\n updateNewMessageCount();\n // Debounced silent reload \u2014 preserves scroll position, selection, and viewer\n if (reloadDebounceTimer) clearTimeout(reloadDebounceTimer);\n reloadDebounceTimer = setTimeout(() => {\n reloadDebounceTimer = null;\n reloadCurrentFolder();\n }, 2000);\n // Sync succeeded \u2014 clear any transient error banner and re-enable sync button\n hideAlert();\n const syncBtn = document.getElementById(\"btn-sync\") as HTMLButtonElement;\n if (syncBtn) { syncBtn.disabled = false; syncBtn.classList.remove(\"syncing\"); }\n if (statusSync) statusSync.textContent = `Synced ${new Date().toLocaleTimeString(undefined, { hour: \"2-digit\", minute: \"2-digit\", hour12: false })}`;\n break;\n }\n case \"updateAvailable\": {\n const banner = document.getElementById(\"alert-banner\");\n const text = document.getElementById(\"alert-text\");\n if (banner && text) {\n banner.hidden = false;\n // Mark as the sticky update banner so hideAlert() (called\n // after every successful sync) leaves it alone.\n banner.dataset.key = \"update-available\";\n // Green update banner \u2014 matches the Android update bar Bob\n // likes (was blue, oklch hue 250).\n banner.style.background = \"oklch(0.52 0.14 150)\";\n // Stash the update banner contents so updateFailed can restore\n // it (offering a retry) instead of leaving \"Updating...\" pinned.\n const restoreHtml = `${APP_NAME} ${event.latest} available (you have ${event.current}) \u2014 <button id=\"btn-do-update\" style=\"background:none;border:1px solid #fff;color:#fff;padding:0.15em 0.5em;border-radius:3px;cursor:pointer;margin-left:0.5em\">Update now</button>`;\n (window as any).__mailxUpdateBannerHtml = restoreHtml;\n text.innerHTML = restoreHtml;\n document.getElementById(\"btn-do-update\")?.addEventListener(\"click\", () => {\n text.textContent = \"Updating... mailx will restart when done\";\n // performUpdate runs npm install then restarts the service\n const ipc = (window as any).mailxapi || (window as any).opener?.mailxapi;\n if (ipc?.performUpdate) ipc.performUpdate();\n });\n }\n break;\n }\n case \"updateFailed\": {\n // Service tried to install but failed (typically offline). Restore\n // the \"Update now\" banner so the user can retry \u2014 and prefix it\n // with a short status so they know why the previous tap silently\n // came back. mailx itself keeps running on the current version.\n const banner = document.getElementById(\"alert-banner\");\n const text = document.getElementById(\"alert-text\");\n if (banner && text) {\n const restoreHtml = (window as any).__mailxUpdateBannerHtml as string | undefined;\n const prefix = event.offline ? \"No connection \u2014 update postponed. \" : \"Update failed \u2014 \";\n banner.hidden = false;\n banner.dataset.key = \"update-available\"; // sticky \u2014 survives sync\n banner.style.background = event.offline ? \"oklch(0.42 0.06 70)\" : \"oklch(0.45 0.12 25)\";\n text.innerHTML = `${prefix}${restoreHtml ?? \"\"}`;\n document.getElementById(\"btn-do-update\")?.addEventListener(\"click\", () => {\n text.textContent = \"Updating... mailx will restart when done\";\n const ipc = (window as any).mailxapi || (window as any).opener?.mailxapi;\n if (ipc?.performUpdate) ipc.performUpdate();\n });\n }\n break;\n }\n case \"syncActionFailed\": {\n // Surface sync failures (move/delete/flag not applied on server)\n // so the user knows local-first actions haven't propagated yet.\n // Status-bar hint always; banner only when the service is telling\n // us it has GIVEN UP (message starts with \"Gave up\"). A full sync\n // over a flaky link fires syncActionFailed once per attempt, and\n // a banner per attempt floods DOM work on the WebView main thread.\n const action = event.action === \"move\" ? \"Move\" : event.action === \"delete\" ? \"Delete\" : event.action;\n if (statusSync) statusSync.textContent = `Sync failed: ${action}`;\n if (typeof event.error === \"string\" && /gave up/i.test(event.error)) {\n showAlert(\n `Couldn't ${action.toLowerCase()} message on the server after retries: ${event.error}. Local change kept.`,\n \"conflict\",\n );\n }\n break;\n }\n case \"reload\":\n location.reload();\n break;\n case \"bodyCached\":\n // Prefetch (or on-demand fetch) downloaded a body \u2014 flip the\n // \"not-downloaded\" indicator to the teal dot for any rows in view.\n markBodiesCached(event.items || []);\n break;\n // bodyAvailable and messageRemoved migrated to subscribeStore below.\n case \"syncStateChanged\": {\n // Drives the bottom-right sync-status pill. Same surface used by\n // syncProgress / folderSynced; reconciler ticks this every few\n // seconds so the pill reflects current queue depth.\n if (!statusSync) break;\n const msgs = event.messageActions ?? 0;\n const bodies = event.bodyFetches ?? 0;\n if (msgs === 0 && bodies === 0) {\n // Don't clobber an in-progress per-account \"Syncing accountX\u2026\"\n // status if reconciler ticks while a fresh sync is happening.\n if (!/syncing|fetching/i.test(statusSync.textContent || \"\")) {\n statusSync.textContent = \"Sync OK\";\n statusSync.removeAttribute(\"data-sync-state\");\n }\n } else {\n const parts: string[] = [];\n if (msgs) parts.push(`${msgs} action${msgs === 1 ? \"\" : \"s\"}`);\n if (bodies) parts.push(`${bodies} bod${bodies === 1 ? \"y\" : \"ies\"}`);\n statusSync.textContent = `Syncing ${parts.join(\" + \")}`;\n statusSync.setAttribute(\"data-sync-state\", \"syncing\");\n }\n break;\n }\n case \"configChanged\":\n // A watched config file was modified \u2014 could be user edit via the\n // JSONC editor, a GDrive sync, or mailx itself saving (e.g.\n // allowlist update on \"allow sender\").\n //\n // For accounts.jsonc specifically, surface a sticky banner with a\n // Restart button \u2014 the file change has no effect on the running\n // daemon (IMAP connections, token caches, sync loops use the old\n // config snapshot), and users shouldn't need `mailx -kill` just\n // to apply an edit. For other files (allowlist / clients /\n // config) the service handles live, a status-bar flash suffices.\n if (statusSync) {\n statusSync.textContent = `${event.filename} updated`;\n setTimeout(() => {\n if (statusSync.textContent === `${event.filename} updated`) statusSync.textContent = \"\";\n }, 8000);\n }\n if (event.filename && /accounts\\.jsonc/i.test(String(event.filename))) {\n showRestartForConfigBanner();\n }\n break;\n case \"cloudError\":\n // Cloud read/write failed (Google Drive auth/network/etc.). Show a\n // sticky banner so the user knows the change wasn't synced. When\n // error is null, the next successful op cleared it \u2014 hide it.\n if (event.error) {\n const where = event.filename ? ` (${event.op || \"sync\"} ${event.filename})` : \"\";\n showAlert(`Cloud sync error${where}: ${event.error}`, \"cloud-error\");\n } else {\n // Only hide if the visible banner is the cloud-error one\n if (alertBanner && alertBanner.dataset.key === \"cloud-error\") {\n alertBanner.hidden = true;\n dismissedAlerts.delete(\"cloud-error\");\n }\n }\n break;\n case \"error\":\n if (statusSync) statusSync.textContent = `Error: ${event.message}`;\n showAlert(event.message, \"ws-error\");\n break;\n case \"fatal\":\n // Unrecoverable startup failure \u2014 sticky red banner, no auto-dismiss.\n // Used for GDrive-folder-missing and similar conditions where the\n // app cannot function. console.warn would be invisible in the GUI.\n if (alertBanner) alertBanner.style.background = \"oklch(0.45 0.18 25)\";\n showAlert(event.message || \"Fatal error\", event.key || \"fatal\", { sticky: true });\n if (statusSync) statusSync.textContent = `FATAL: ${event.message}`;\n break;\n case \"outboxStatus\":\n renderOutboxStatus(event);\n break;\n case \"calendarUpdated\":\n case \"tasksUpdated\":\n // Reauth succeeded (or was never broken): clear any lingering\n // scope banner for this feature. Handled here (not just in the\n // sidebar) because the global fallback banner isn't tied to the\n // sidebar's lifecycle.\n if (alertBanner && /^scope-(calendar|tasks|google)$/.test(alertBanner.dataset.key || \"\")) {\n alertBanner.hidden = true;\n alertBanner.dataset.key = \"\";\n alertBanner.querySelector(\".status-action\")?.remove();\n }\n break;\n case \"authScopeError\": {\n // Fallback banner: calendar-sidebar.ts already shows this inline\n // when the sidebar is visible, but if the user has the sidebar\n // off or is on a narrow tier where it's hidden, the error would\n // otherwise be invisible. Global banner with the same button.\n const feat = event.feature || \"google\";\n const key = `scope-${feat}`;\n const msg = event.message || `Google ${feat} access needs re-consent.`;\n showAlert(msg, key, { sticky: true });\n const bannerText = document.getElementById(\"alert-text\");\n if (bannerText && bannerText.textContent === msg) {\n const existing = bannerText.parentElement?.querySelector(\".status-action\");\n if (!existing) {\n const btn = document.createElement(\"button\");\n btn.className = \"status-action\";\n btn.textContent = \"Re-authenticate\";\n btn.addEventListener(\"click\", async () => {\n btn.disabled = true;\n btn.textContent = \"Opening browser\u2026\";\n try {\n const { reauthGoogleScopes } = await import(\"./lib/api-client.js\");\n await reauthGoogleScopes();\n btn.textContent = \"Consent opened \u2014 finish in browser\";\n } catch (err: any) {\n btn.disabled = false;\n btn.textContent = `Failed: ${err?.message || err}`;\n }\n });\n bannerText.parentElement?.insertBefore(btn, document.getElementById(\"alert-dismiss\"));\n }\n }\n break;\n }\n case \"accountError\": {\n // Show actual error + hint in banner\n const msg = `${event.accountId}: ${event.error}`;\n showAlert(msg, `acct-${event.accountId}`);\n // Add action button: Re-authenticate for OAuth, Retry for password accounts\n const bannerText = document.getElementById(\"alert-text\");\n if (bannerText && bannerText.textContent === msg) {\n const existing = bannerText.parentElement?.querySelector(\".status-action\");\n if (!existing) {\n const btn = document.createElement(\"button\");\n btn.className = \"status-action\";\n if (event.isOAuth) {\n btn.textContent = \"Re-authenticate\";\n btn.addEventListener(\"click\", async () => {\n btn.disabled = true;\n btn.textContent = \"Authenticating...\";\n try {\n const data = await reauthenticate(event.accountId);\n if (data.ok) {\n hideAlert();\n const acctEl = document.getElementById(\"status-accounts\");\n if (acctEl) { acctEl.textContent = `${event.accountId}: reconnected`; acctEl.style.color = \"\"; }\n } else {\n btn.textContent = \"Re-authenticate\";\n btn.disabled = false;\n }\n } catch {\n btn.textContent = \"Re-authenticate\";\n btn.disabled = false;\n }\n });\n } else {\n btn.textContent = \"Retry\";\n btn.addEventListener(\"click\", async () => {\n btn.disabled = true;\n btn.textContent = \"Syncing...\";\n try {\n const data = await syncAccount(event.accountId);\n if (data.ok) {\n hideAlert();\n const acctEl = document.getElementById(\"status-accounts\");\n if (acctEl) { acctEl.textContent = `${event.accountId}: reconnected`; acctEl.style.color = \"\"; }\n } else {\n btn.textContent = \"Retry\";\n btn.disabled = false;\n }\n } catch {\n btn.textContent = \"Retry\";\n btn.disabled = false;\n }\n });\n }\n bannerText.parentElement?.insertBefore(btn, document.getElementById(\"alert-dismiss\"));\n }\n }\n // Also show in status bar\n const acctEl = document.getElementById(\"status-accounts\");\n if (acctEl) {\n acctEl.textContent = `${event.accountId}: ${event.hint}`;\n acctEl.style.color = \"oklch(0.65 0.2 25)\";\n }\n break;\n }\n case \"openMailto\": {\n // P115: OS-level mailto: handler. CLI dropped a pending file; the\n // daemon picked it up and forwarded the parsed fields. Open compose\n // pre-populated. Treat as a fresh New compose, not a reply \u2014 the\n // browser doesn't know about a parent message.\n openComposeFromMailto({\n to: Array.isArray(event.to) ? event.to : [],\n cc: Array.isArray(event.cc) ? event.cc : [],\n bcc: Array.isArray(event.bcc) ? event.bcc : [],\n subject: event.subject || \"\",\n body: event.body || \"\",\n inReplyTo: event.inReplyTo || \"\",\n }).catch((e: any) => console.error(\"openComposeFromMailto failed:\", e?.message || e));\n break;\n }\n }\n});\n\n// Store-bus consumers (post-refactor). The list's mailx-remove-stale\n// listener handles row-removal + focus-advance atomically; we just\n// translate bus events into that one signal so there's a single\n// owner of \"row is no longer here.\"\nsubscribeStore(\"*\", (ev: any) => {\n if (ev.kind === \"bodyAvailable\" && ev.accountId && ev.uid) {\n // Flip the row's \"not-downloaded\" dot. Viewer subscribes separately\n // (in message-viewer.ts) to re-render the focused message.\n markBodiesCached([{ accountId: ev.accountId, uid: ev.uid }]);\n } else if (ev.kind === \"messageRemoved\" && ev.accountId && ev.uid) {\n // Server-side EXPUNGE (or another device deleted).\n document.dispatchEvent(new CustomEvent(\"mailx-remove-stale\", {\n detail: { accountId: ev.accountId, uid: ev.uid },\n }));\n } else if (ev.kind === \"messageMoved\" && ev.accountId && ev.uid) {\n // Moved to a different folder \u2014 from the current folder's view\n // it's the same as removal. The list's remove-stale handler\n // figures out whether it was actually in the current view; if not,\n // it's a no-op. Local optimistic delete also flows here when the\n // user hits Del on another device \u2014 same handler, same advance.\n document.dispatchEvent(new CustomEvent(\"mailx-remove-stale\", {\n detail: { accountId: ev.accountId, uid: ev.uid },\n }));\n }\n});\n\n/** Open a compose window pre-filled from a mailto: URL. Same plumbing as\n * openCompose(\"new\") but the init shape comes from the parsed URL instead\n * of the currently-selected message. The body arrives as plain text from\n * the URL \u2014 we wrap it in a single <p> so Quill renders it as paragraphs\n * rather than a single line, escaping `<` so embedded angle brackets in a\n * signature/template don't get interpreted as tags. */\nasync function openComposeFromMailto(m: {\n to: string[]; cc: string[]; bcc: string[];\n subject: string; body: string; inReplyTo: string;\n}): Promise<void> {\n // Open the iframe immediately and load accounts in parallel \u2014 same\n // pattern as openCompose. The mailto handler should never feel like\n // \"waiting for the system\" to a user who clicked a link.\n const accountsP = getAccounts();\n const frame = showComposeOverlay(m.subject ? `Compose: ${m.subject}` : \"Compose\");\n const accounts = await accountsP;\n const accountId = accounts[0]?.id || \"\";\n const escape = (s: string) => s.replace(/[&<>]/g, c =>\n ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\" }[c]!));\n const bodyHtml = m.body\n ? \"<p>\" + escape(m.body).replace(/\\r?\\n/g, \"</p><p>\") + \"</p>\"\n : \"\";\n const init: any = {\n mode: \"new\",\n accountId,\n to: m.to.map(addr => ({ name: \"\", address: addr })),\n cc: m.cc.map(addr => ({ name: \"\", address: addr })),\n bcc: m.bcc.map(addr => ({ name: \"\", address: addr })),\n subject: m.subject,\n bodyHtml,\n inReplyTo: m.inReplyTo,\n references: m.inReplyTo ? [m.inReplyTo] : [],\n accounts: accounts.map((a: any) => ({ id: a.id, name: a.name, email: a.email, signature: a.signature, sig: a.sig })),\n };\n sessionStorage.setItem(\"composeInit\", JSON.stringify(init));\n try { frame?.contentWindow?.postMessage({ type: \"compose-init-ready\" }, \"*\"); } catch { /* */ }\n}\n\n// \u2500\u2500 Keyboard shortcuts \u2500\u2500\n\n// Capture-phase pre-handler: intercept WebView accelerator keys that would\n// otherwise trigger a browser action (Ctrl+R / Ctrl+Shift+R = reload). Call\n// the action directly here \u2014 earlier version re-dispatched a synthetic\n// keydown to document, which bubbled back up to this window listener and\n// fired again, breaking unrelated shortcuts (user-reported: Ctrl+N\n// stopped composing). Direct dispatch avoids the recursion.\n//\n// Whether preventDefault actually suppresses WebView2's native reload\n// depends on AreBrowserAcceleratorKeysEnabled \u2014 if it still reloads,\n// the msger Rust side needs the flag flipped (option 1 in the original\n// design notes).\nwindow.addEventListener(\"keydown\", (e) => {\n if (!e.ctrlKey || e.altKey || e.metaKey) return;\n if (e.key !== \"r\" && e.key !== \"R\") return;\n // Skip when the user is typing in an input (compose textarea etc.) so\n // we don't hijack their typing to fire reply-all.\n const t = e.target as HTMLElement | null;\n const tag = t?.tagName;\n if (tag === \"INPUT\" || tag === \"TEXTAREA\" || tag === \"SELECT\" || t?.isContentEditable) return;\n e.preventDefault();\n e.stopImmediatePropagation();\n if (e.shiftKey) openCompose(\"replyAll\");\n else openCompose(\"reply\");\n}, { capture: true });\n\ndocument.addEventListener(\"keydown\", (e) => {\n // Ctrl+N or Ctrl+Shift+M = Compose\n if ((e.ctrlKey && e.key === \"n\") || (e.ctrlKey && e.shiftKey && e.key === \"M\")) {\n e.preventDefault();\n openCompose(\"new\");\n }\n // Ctrl+P = Print the focused message. preventDefault stops the host\n // browser from printing the whole app window instead of the letter.\n if (e.ctrlKey && (e.key === \"p\" || e.key === \"P\") && !e.shiftKey && !e.altKey) {\n const t = e.target as HTMLElement | null;\n const inText = t && (t.tagName === \"INPUT\" || t.tagName === \"TEXTAREA\" || t.isContentEditable);\n if (!inText) {\n e.preventDefault();\n printCurrentMessage();\n }\n }\n // Ctrl+T = new view tab. The strip is hidden with a single tab (no wasted\n // band), so this is how the second tab gets opened; once 2+ exist the\n // strip's \"+\" is also available.\n if (e.ctrlKey && (e.key === \"t\" || e.key === \"T\") && !e.shiftKey && !e.altKey) {\n e.preventDefault();\n openTab({ kind: \"unified\" }, \"All Inboxes\", true);\n }\n // Ctrl+R = Reply (without Shift)\n if (e.ctrlKey && e.key === \"r\" && !e.shiftKey && !e.altKey && !e.metaKey) {\n e.preventDefault();\n openCompose(\"reply\");\n }\n // Ctrl+Shift+R = Reply All\n if (e.ctrlKey && e.shiftKey && e.key === \"R\" && !e.altKey && !e.metaKey) {\n e.preventDefault();\n openCompose(\"replyAll\");\n }\n // Ctrl+F = Forward (without Shift). Use toLowerCase so a Caps-Lock or\n // shifted state doesn't bypass us. Single handler \u2014 the previous\n // duplicate fired openCompose twice, which double-loaded the compose\n // iframe and the second copy got an empty sessionStorage (the first\n // had already consumed it), producing an empty Forward form.\n if (e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && (e.key === \"f\" || e.key === \"F\")) {\n e.preventDefault();\n openCompose(\"forward\");\n }\n // Ctrl/Cmd +/-/0 = UI zoom in / out / reset. Reads the current font\n // size from the root style and steps by 1px. Skip when the focus is\n // inside an input / contenteditable so the user's text isn't hijacked\n // (compose autocomplete already handles its own zoom in the Quill\n // editor via Ctrl+wheel). The browser's own Ctrl+/Ctrl- is also\n // suppressed because we preventDefault.\n const zoomKey = (e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey;\n if (zoomKey && (e.key === \"=\" || e.key === \"+\" || e.key === \"-\" || e.key === \"0\")) {\n const t = e.target as HTMLElement | null;\n const tag = t?.tagName;\n if (tag === \"INPUT\" || tag === \"TEXTAREA\" || tag === \"SELECT\" || t?.isContentEditable) return;\n const cur = parseFloat(document.documentElement.style.fontSize) ||\n parseFloat(getComputedStyle(document.documentElement).fontSize) || 15;\n let next = cur;\n if (e.key === \"0\") next = 15; // reset to default\n else if (e.key === \"-\") next = cur - 1; // smaller\n else next = cur + 1; // = or +\n e.preventDefault();\n applyFontSize(next);\n }\n // Ctrl+A = Select all visible messages\n if (e.ctrlKey && e.key === \"a\") {\n const t = e.target as HTMLElement | null;\n const tag = t?.tagName;\n // In a text field / editor, Ctrl+A means \"select the text\" \u2014 never\n // hijack it to select-all-messages. The old guard's `.closest(...,\n // body)` always matched (everything is inside <body>), so Ctrl+A in\n // the search box selected every message instead of the box's text,\n // and the field couldn't be selected+cleared (Bob 2026-05-18).\n if (tag === \"INPUT\" || tag === \"TEXTAREA\" || tag === \"SELECT\" || t?.isContentEditable) return;\n const mlBody = document.getElementById(\"ml-body\");\n if (mlBody) {\n e.preventDefault();\n mlBody.querySelectorAll(\".ml-row\").forEach(r => r.classList.add(\"selected\"));\n }\n }\n // Ctrl+D or Delete = Delete selected messages.\n // Focus is the only signal. Text input has focus \u2192 Delete is a native\n // character edit, ALWAYS. List has focus (a row, the list container,\n // anything outside a text editor) \u2192 Delete acts on the list selection,\n // single or multi. The 2026-05-09 \"multi-select while typing\" carve-out\n // is gone \u2014 it cost Bob real messages 2026-05-20 by hard-deleting the\n // auto-selected row every time he hit Delete to backspace in the search\n // input.\n if ((e.ctrlKey && e.key === \"d\") || e.key === \"Delete\") {\n const t = e.target as HTMLElement | null;\n const tag = t?.tagName;\n const editable = t?.isContentEditable;\n const inEditable = tag === \"INPUT\" || tag === \"TEXTAREA\" || tag === \"SELECT\" || editable;\n if (inEditable) return;\n e.preventDefault();\n deleteSelection();\n }\n // Ctrl+Z = Undo the most recent delete or move\n if (e.ctrlKey && e.key === \"z\") {\n if (lastMoved) {\n e.preventDefault();\n undoMove();\n } else if (lastDeleted) {\n e.preventDefault();\n undoDelete();\n }\n }\n // F5 = Sync\n if (e.key === \"F5\") {\n e.preventDefault();\n document.getElementById(\"btn-sync\")?.click();\n }\n // R (no modifiers) or Ctrl+Q = Toggle read/unread on the focused row.\n // Ctrl+Q mirrors the Outlook-style \"mark read/unread\" combo so it works\n // even when focus is in a text input (Ctrl-modifier guarantees no\n // collision with typing). Bare R defers when typing.\n const isToggleSeen =\n (e.key.toLowerCase() === \"r\" && !e.ctrlKey && !e.metaKey && !e.altKey) ||\n (e.ctrlKey && !e.metaKey && !e.altKey && e.key.toLowerCase() === \"q\");\n if (isToggleSeen) {\n const active = document.activeElement;\n const inText = active && (active.tagName === \"INPUT\" || active.tagName === \"TEXTAREA\" || active.tagName === \"SELECT\" || (active as HTMLElement).isContentEditable);\n // Bare R yields to text inputs; Ctrl+Q overrides them so it's reachable\n // from the search box or compose draft list.\n if (!e.ctrlKey && inText) return;\n const sel = getCurrentFocused();\n if (!sel) return;\n e.preventDefault();\n const wasSeen = seenOf(sel);\n setSeen(sel, !wasSeen);\n updateFlags(sel.accountId, sel.uid, sel.flags).then(() => {\n messageState.updateMessageFlags(sel.accountId, sel.uid, sel.flags);\n const row = document.querySelector(`.ml-row[data-uid=\"${sel.uid}\"][data-account-id=\"${sel.accountId}\"]`) as HTMLElement;\n if (row) row.classList.toggle(\"unread\", wasSeen);\n }).catch(() => { setSeen(sel, wasSeen); });\n }\n // Z = locate the focused row in the list (scroll-to-selected). After\n // scrolling the list out of sync with the preview, this snaps back.\n if (e.key.toLowerCase() === \"z\" && !e.ctrlKey && !e.metaKey && !e.altKey) {\n const active = document.activeElement;\n if (active && (active.tagName === \"INPUT\" || active.tagName === \"TEXTAREA\" || active.tagName === \"SELECT\")) return;\n const editable = (active as HTMLElement | null)?.isContentEditable;\n if (editable) return;\n e.preventDefault();\n scrollFocusedIntoView();\n }\n // F6 / Shift+F6 \u2014 standard pane-switch shortcut. Cycles focus among\n // folder tree \u2192 message list \u2192 message viewer.\n if (e.key === \"F6\") {\n e.preventDefault();\n cyclePaneFocus(e.shiftKey);\n }\n // ? = show keyboard shortcuts help. Skip when typing in inputs so a\n // literal \"?\" in search/compose doesn't pop the dialog.\n if (e.key === \"?\" && !e.ctrlKey && !e.metaKey && !e.altKey) {\n const active = document.activeElement;\n if (active && (active.tagName === \"INPUT\" || active.tagName === \"TEXTAREA\" || active.tagName === \"SELECT\")) return;\n const editable = (active as HTMLElement | null)?.isContentEditable;\n if (editable) return;\n e.preventDefault();\n openShortcutsDialog();\n }\n // Arrow keys + Home/End/PgUp/PgDn \u2014 navigate message list (Q58).\n if ([\"ArrowDown\", \"ArrowUp\", \"Home\", \"End\", \"PageDown\", \"PageUp\"].includes(e.key)) {\n const active = document.activeElement;\n if (active && (active.tagName === \"INPUT\" || active.tagName === \"TEXTAREA\" || active.tagName === \"SELECT\")) return;\n const body = document.getElementById(\"ml-body\");\n if (!body) return;\n const rows = Array.from(body.querySelectorAll<HTMLElement>(\".ml-row\"));\n if (rows.length === 0) return;\n const selected = body.querySelector<HTMLElement>(\".ml-row.selected\");\n const idx = selected ? rows.indexOf(selected) : -1;\n let target: HTMLElement | undefined;\n if (e.key === \"ArrowDown\") target = rows[idx + 1] || rows[idx];\n else if (e.key === \"ArrowUp\") target = rows[Math.max(0, idx - 1)];\n else if (e.key === \"Home\") target = rows[0];\n else if (e.key === \"End\") target = rows[rows.length - 1];\n else if (e.key === \"PageDown\") target = rows[Math.min(rows.length - 1, idx + 10)];\n else if (e.key === \"PageUp\") target = rows[Math.max(0, idx - 10)];\n if (target && (!selected || target !== selected)) {\n e.preventDefault();\n target.click();\n target.scrollIntoView({ block: \"nearest\" });\n }\n }\n});\n\n// \u2500\u2500 Double-click viewer chrome \u2192 fullscreen preview \u2500\u2500\n// The iframe's own contentDocument has its own dblclick handler (in\n// message-viewer.ts). This catches dblclicks on the headers / from row /\n// reply-to row of the viewer that aren't inside the iframe.\ndocument.getElementById(\"message-viewer\")?.addEventListener(\"dblclick\", (e) => {\n const target = e.target as HTMLElement | null;\n // Don't toggle when double-clicking interactive controls (chips, buttons,\n // links, address widgets) \u2014 only on the chrome / empty space.\n if (target?.closest(\"a, button, input, select, textarea, [contenteditable]\")) return;\n toggleFullscreenPreview();\n});\n\n// \u2500\u2500 F6 pane cycling \u2500\u2500\n\nfunction cyclePaneFocus(reverse: boolean): void {\n // Major panes in tab order. Skip ones not currently visible (folder\n // tree is hidden in narrow tier when the rail/drawer is closed; message\n // viewer is only meaningful with a message open).\n const panes: { id: string; el: HTMLElement | null }[] = [\n { id: \"folder-tree\", el: document.getElementById(\"folder-tree\") },\n { id: \"ml-body\", el: document.getElementById(\"ml-body\") },\n { id: \"message-viewer\", el: document.getElementById(\"message-viewer\") },\n ].filter(p => {\n if (!p.el) return false;\n const r = p.el.getBoundingClientRect();\n return r.width > 0 && r.height > 0;\n });\n if (panes.length === 0) return;\n const active = document.activeElement as HTMLElement | null;\n const currentIdx = panes.findIndex(p => p.el && (p.el === active || p.el.contains(active!)));\n const step = reverse ? -1 : 1;\n const nextIdx = currentIdx < 0 ? 0 : (currentIdx + step + panes.length) % panes.length;\n const next = panes[nextIdx];\n if (!next.el) return;\n // Make the pane focusable if it isn't already; tabindex=-1 lets us\n // focus it programmatically without inserting it into Tab order.\n if (!next.el.hasAttribute(\"tabindex\")) next.el.setAttribute(\"tabindex\", \"-1\");\n next.el.focus();\n // Visual hint \u2014 brief outline so the user can see which pane took focus.\n next.el.style.outline = \"2px solid var(--color-accent, #3b82f6)\";\n next.el.style.outlineOffset = \"-2px\";\n setTimeout(() => { next.el!.style.outline = \"\"; next.el!.style.outlineOffset = \"\"; }, 600);\n}\n\n// \u2500\u2500 Keyboard shortcuts help dialog \u2500\u2500\n\nfunction openShortcutsDialog(): void {\n if (document.querySelector(\".mailx-shortcuts-modal\")) return;\n const backdrop = document.createElement(\"div\");\n backdrop.className = \"mailx-modal-backdrop mailx-shortcuts-modal\";\n const panel = document.createElement(\"div\");\n panel.className = \"mailx-modal\";\n panel.style.maxWidth = \"640px\";\n const tabs = [\n {\n id: \"app\", label: \"Application\",\n rows: [\n [\"Compose\", \"Ctrl+N\"],\n [\"Reply\", \"Ctrl+R\"],\n [\"Reply all\", \"Ctrl+Shift+R\"],\n [\"Forward\", \"Ctrl+F\"],\n [\"Sync\", \"F5\"],\n [\"Delete selected\", \"Del or Ctrl+D\"],\n [\"Undo last delete/move\", \"Ctrl+Z\"],\n [\"Toggle read/unread\", \"R\"],\n [\"Toggle flag (\u2605)\", \"(\u2691 button in viewer)\"],\n [\"Select all visible\", \"Ctrl+A\"],\n [\"Navigate list\", \"\u2191 \u2193 Home End PgUp PgDn\"],\n [\"Scroll-to-focused row\", \"Z\"],\n [\"Switch pane\", \"F6 / Shift+F6\"],\n [\"Find / search\", \"(focus the search box)\"],\n [\"Show this help\", \"?\"],\n [\"Close dialog\", \"Esc\"],\n ],\n },\n {\n id: \"compose\", label: \"Compose\",\n rows: [\n [\"Send\", \"Ctrl+Enter\"],\n [\"Insert / edit link\", \"Ctrl+K\"],\n [\"Remove link\", \"Ctrl+Shift+K\"],\n [\"Bold / italic / underline\", \"Ctrl+B / Ctrl+I / Ctrl+U\"],\n [\"Strikethrough\", \"Ctrl+Shift+X\"],\n [\"Ordered list\", \"Ctrl+Shift+7\"],\n [\"Bulleted list\", \"Ctrl+Shift+8\"],\n [\"Indent / outdent\", \"Ctrl+] / Ctrl+[\"],\n [\"Clear formatting\", \"Ctrl+\\\\\"],\n [\"Color text\", \"Ctrl+Shift+C\"],\n [\"New line in To/Cc/Bcc\", \"(Enter inserts a comma)\"],\n [\"Editor reference\", \"(? button on compose toolbar \u2014 shows Quill/tiptap differences)\"],\n ],\n },\n {\n id: \"viewer\", label: \"Viewer\",\n rows: [\n [\"Toggle preview fullscreen\", \"double-click on body\"],\n [\"Exit fullscreen\", \"Esc / \u2715 button / double-click\"],\n [\"Zoom in / out / reset\", \"Ctrl+= / Ctrl+- / Ctrl+0\"],\n [\"Right-click in body\", \"Copy / Search / Translate / Zoom\"],\n [\"Right-click a link\", \"Open / Save / Copy URL / Copy text\"],\n ],\n },\n ];\n panel.innerHTML = `\n <div class=\"mailx-modal-title\">\n <span class=\"mailx-modal-title-text\">Keyboard shortcuts</span>\n <button type=\"button\" class=\"mailx-modal-close\" id=\"sc-x\" title=\"Close (Esc)\" aria-label=\"Close\">×</button>\n </div>\n <div class=\"mailx-shortcuts-tabs\" role=\"tablist\" style=\"display:flex;gap:0;border-bottom:1px solid var(--color-border, #ddd);margin:0 -16px 12px -16px;padding:0 16px;\">\n ${tabs.map((t, i) => `<button type=\"button\" role=\"tab\" class=\"mailx-shortcuts-tab\" data-tab=\"${t.id}\" aria-selected=\"${i === 0}\" style=\"background:none;border:0;padding:8px 14px;cursor:pointer;font:inherit;border-bottom:2px solid ${i === 0 ? \"var(--color-accent, #3b82f6)\" : \"transparent\"};color:${i === 0 ? \"var(--color-text)\" : \"var(--color-text-muted, #888)\"};\">${t.label}</button>`).join(\"\")}\n </div>\n <div class=\"mailx-about\">\n ${tabs.map((t, i) => `<div class=\"mailx-shortcuts-pane\" data-pane=\"${t.id}\" ${i === 0 ? \"\" : \"hidden\"}>\n <dl class=\"mailx-about-dl\">\n ${t.rows.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join(\"\")}\n </dl>\n </div>`).join(\"\")}\n <div class=\"mailx-about-foot\">On Android most shortcuts need a Bluetooth/USB keyboard. Touch equivalents: tap a row to view, tap the rail icons for navigation, long-press for context menu, double-tap the message body for fullscreen.</div>\n </div>\n <div class=\"mailx-modal-buttons\">\n <span class=\"mailx-modal-spacer\"></span>\n <button type=\"button\" class=\"mailx-modal-btn mailx-modal-btn-primary\" data-action=\"close\">Close</button>\n </div>`;\n backdrop.appendChild(panel);\n document.body.appendChild(backdrop);\n // Wire tab clicks\n panel.querySelectorAll<HTMLButtonElement>(\".mailx-shortcuts-tab\").forEach(tab => {\n tab.addEventListener(\"click\", () => {\n const which = tab.dataset.tab;\n panel.querySelectorAll<HTMLButtonElement>(\".mailx-shortcuts-tab\").forEach(t => {\n const sel = t.dataset.tab === which;\n t.setAttribute(\"aria-selected\", String(sel));\n t.style.borderBottom = `2px solid ${sel ? \"var(--color-accent, #3b82f6)\" : \"transparent\"}`;\n t.style.color = sel ? \"var(--color-text)\" : \"var(--color-text-muted, #888)\";\n });\n panel.querySelectorAll<HTMLElement>(\".mailx-shortcuts-pane\").forEach(p => {\n p.hidden = p.dataset.pane !== which;\n });\n });\n });\n const close = () => { backdrop.remove(); document.removeEventListener(\"keydown\", onKey, true); };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") { e.stopPropagation(); e.preventDefault(); close(); }\n };\n document.addEventListener(\"keydown\", onKey, true);\n const closeBtn = panel.querySelector<HTMLButtonElement>(\"#sc-x\");\n closeBtn?.addEventListener(\"click\", close);\n backdrop.addEventListener(\"mousedown\", (e) => { if (e.target === backdrop) close(); });\n}\n\n// \u2500\u2500 View menu \u2500\u2500\n\nconst viewBtn = document.getElementById(\"btn-view\");\nconst viewDropdown = document.getElementById(\"view-dropdown\");\nconst optTwoLine = document.getElementById(\"opt-two-line\") as HTMLInputElement;\nconst optPreview = document.getElementById(\"opt-preview\") as HTMLInputElement;\nconst optSnippet = document.getElementById(\"opt-snippet\") as HTMLInputElement;\nconst optThreaded = document.getElementById(\"opt-threaded\") as HTMLInputElement;\nconst optFlagged = document.getElementById(\"opt-flagged\") as HTMLInputElement;\nconst optFolderCounts = document.getElementById(\"opt-folder-counts\") as HTMLInputElement;\nconst optCalendarSidebar = document.getElementById(\"opt-calendar-sidebar\") as HTMLInputElement;\nconst optThreadFilter = document.getElementById(\"opt-thread-filter\") as HTMLInputElement;\n\n// Toggle dropdown \u2014 also close any other open toolbar menu so they can't\n// overlap. Without this, opening View while Settings was already open left\n// both visible at once (user-reported screenshot).\nviewBtn?.addEventListener(\"click\", (e) => {\n e.stopPropagation();\n const settingsDd = document.getElementById(\"settings-dropdown\");\n if (settingsDd) settingsDd.hidden = true;\n const restartDd = document.getElementById(\"restart-dropdown\");\n if (restartDd) restartDd.hidden = true;\n // If the dropdown was previously opened via the rail (re-parented to\n // body with inline modal styles), restore it to its toolbar parent so\n // it renders in the expected position-absolute location.\n restoreToolbarDropdown(\"view-dropdown\", \"view-menu\");\n if (viewDropdown) viewDropdown.hidden = !viewDropdown.hidden;\n});\n// Capture-phase pointerdown so we run BEFORE any handler that calls\n// stopPropagation. The earlier bubble-phase click listener missed clicks\n// when an upstream handler swallowed propagation, and the menu stayed open.\n// pointerdown also feels snappier than waiting for click (which fires on\n// pointerup). The closest() checks let inside-clicks (radio buttons,\n// checkboxes, the input) keep the menu open.\ndocument.addEventListener(\"pointerdown\", (e) => {\n const target = e.target as HTMLElement | null;\n if (!target) return;\n // Don't close if the click is on the toolbar button that toggles us \u2014\n // its own handler will run after this and toggle, otherwise we'd\n // close-then-reopen-then-close.\n if (target.closest(\"#btn-view, #btn-settings, #rail-view, #rail-settings, #rail-menu-backdrop\")) return;\n if (viewDropdown && !viewDropdown.hidden && !target.closest(\"#view-menu\") && !target.closest(\"#view-dropdown\")) {\n viewDropdown.hidden = true;\n }\n if (settingsDropdown && !settingsDropdown.hidden && !target.closest(\"#settings-menu\") && !target.closest(\"#settings-dropdown\")) {\n settingsDropdown.hidden = true;\n }\n}, true);\n// Escape always closes any open dropdown/backdrop, regardless of how it was\n// opened or whether an outside-click handler missed firing. Last-resort\n// escape hatch for the \"stuck modal\" case.\ndocument.addEventListener(\"keydown\", (e) => {\n if (e.key !== \"Escape\") return;\n let closed = false;\n for (const id of [\"settings-dropdown\", \"view-dropdown\", \"restart-dropdown\"]) {\n const dd = document.getElementById(id);\n if (dd && !dd.hidden) { dd.hidden = true; closed = true; }\n }\n const bd = document.getElementById(\"rail-menu-backdrop\");\n if (bd) { bd.remove(); closed = true; }\n const themeSub = document.getElementById(\"theme-submenu\");\n if (themeSub && !themeSub.hidden) { themeSub.hidden = true; closed = true; }\n if (closed) e.preventDefault();\n});\n\n// Restore saved view settings\nconst savedTwoLine = localStorage.getItem(\"mailx-two-line\") === \"true\";\nconst savedPreview = localStorage.getItem(\"mailx-preview\") !== \"false\"; // default true\nconst savedSnippet = localStorage.getItem(\"mailx-snippet\") !== \"false\"; // default true\nconst savedThreaded = localStorage.getItem(\"mailx-threaded\") === \"true\";\nconst savedFlagged = localStorage.getItem(\"mailx-flagged\") === \"true\";\nconst savedFolderCounts = localStorage.getItem(\"mailx-folder-counts\") === \"true\";\nif (optTwoLine) optTwoLine.checked = savedTwoLine;\nif (optPreview) optPreview.checked = savedPreview;\nif (optSnippet) optSnippet.checked = savedSnippet;\nif (optThreaded) optThreaded.checked = savedThreaded;\nif (optFlagged) optFlagged.checked = savedFlagged;\nif (optFolderCounts) optFolderCounts.checked = savedFolderCounts;\nif (savedTwoLine) document.getElementById(\"message-list\")?.classList.add(\"two-line\");\nif (!savedPreview) document.querySelector(\".main-area\")?.classList.add(\"no-preview\");\nif (!savedSnippet) document.getElementById(\"message-list\")?.classList.add(\"no-snippets\");\nif (savedThreaded) document.getElementById(\"ml-body\")?.classList.add(\"threaded\");\nif (savedFlagged) document.getElementById(\"ml-body\")?.classList.add(\"flagged-only\");\nif (savedFolderCounts) document.getElementById(\"folder-tree\")?.classList.add(\"show-folder-counts\");\n\n// \"Only this conversation\" toggle \u2014 hides rows whose threadId differs from\n// the currently-selected message's threadId. Client-side only (no server\n// round-trip); toggling off restores the full list. Persisted per-session\n// but not across reloads (thread context is tied to current selection).\noptThreadFilter?.addEventListener(\"change\", () => {\n const body = document.getElementById(\"ml-body\");\n if (!body) return;\n body.classList.toggle(\"thread-filter-on\", optThreadFilter.checked);\n applyThreadFilter();\n});\n// Re-apply thread filter whenever the list contents change (load,\n// search, paged append, removal). The filter also depends on the\n// currently-focused row's threadId, so wire mailx-focus-changed too \u2014\n// a focus change without a list-content change still needs the filter\n// to recompute which sibling rows to hide/show.\nmessageState.subscribe(() => applyThreadFilter());\ndocument.addEventListener(\"mailx-focus-changed\", () => applyThreadFilter());\n\nfunction applyThreadFilter(): void {\n const body = document.getElementById(\"ml-body\");\n if (!body) return;\n if (!optThreadFilter?.checked) {\n body.querySelectorAll<HTMLElement>(\".ml-row.thread-filter-hidden\")\n .forEach(r => r.classList.remove(\"thread-filter-hidden\"));\n return;\n }\n const sel = getCurrentFocused() as any;\n const tid = sel?.threadId;\n if (!tid) return;\n body.querySelectorAll<HTMLElement>(\".ml-row\").forEach(r => {\n const rowTid = r.dataset.threadId;\n if (rowTid === tid || r.classList.contains(\"selected\")) {\n r.classList.remove(\"thread-filter-hidden\");\n } else {\n r.classList.add(\"thread-filter-hidden\");\n }\n });\n}\n\n// S51 \u2014 Calendar sidebar: View-menu toggle, restore from localStorage,\n// hide auto-magically on narrow screens (CSS handles that).\n(async () => {\n const { initCalendarSidebar, isCalendarSidebarOn, showCalendarSidebar, hideCalendarSidebar } =\n await import(\"./components/calendar-sidebar.js\");\n initCalendarSidebar();\n const on = isCalendarSidebarOn();\n if (optCalendarSidebar) optCalendarSidebar.checked = on;\n if (on) await showCalendarSidebar();\n optCalendarSidebar?.addEventListener(\"change\", () => {\n if (optCalendarSidebar.checked) showCalendarSidebar();\n else hideCalendarSidebar();\n });\n})();\n\n// P17 / Q104: alarm subsystem \u2014 Thunderbird/Outlook-style popup with\n// snooze + dismiss. Covers calendar events + tasks today; mail reminders\n// will slot in here when the mail-reminder feature lands.\n(async () => {\n try {\n const { startAlarmPoller } = await import(\"./components/alarms.js\");\n startAlarmPoller();\n } catch (e: any) {\n console.error(\"alarm poller init failed:\", e?.message || e);\n }\n})();\n\n// Pull-to-refresh: touch-drag down at the top of the message list fires a\n// sync, same as F5 / btn-sync. Standard Android / Thunderbird / mobile\n// pattern. The indicator slides down with the drag and rotates the chevron\n// once the user crosses the trigger threshold; releasing past the threshold\n// fires triggerSync(). Below threshold = snap back without action.\n//\n// Touch-only by design \u2014 desktop has the F5 / sync button. Pointer events\n// would also catch mouse-drag, which is more annoying than useful (people\n// drag the scrollbar). The `e.touches.length === 1` check skips two-finger\n// gestures (pinch-zoom, two-finger scroll).\n(() => {\n const PTR_THRESHOLD_PX = 80; // pull this far to arm the trigger\n const PTR_MAX_PX = 120; // cap the visual translate\n const mlBody = document.getElementById(\"ml-body\");\n if (!mlBody) return;\n const indicator = document.createElement(\"div\");\n indicator.className = \"ptr-indicator\";\n indicator.innerHTML = `<span class=\"ptr-chev\"></span><span class=\"ptr-label\">Pull to refresh</span>`;\n mlBody.parentElement?.insertBefore(indicator, mlBody);\n const labelEl = indicator.querySelector(\".ptr-label\") as HTMLElement;\n\n let startY = 0;\n let pullY = 0;\n let active = false;\n let refreshing = false;\n\n const setPull = (px: number, armed: boolean): void => {\n const clamped = Math.min(PTR_MAX_PX, Math.max(0, px));\n indicator.classList.toggle(\"armed\", armed);\n indicator.style.transform = `translateY(calc(-100% + ${clamped}px))`;\n };\n const reset = (): void => {\n indicator.classList.remove(\"dragging\", \"armed\");\n indicator.style.transform = \"\";\n };\n\n mlBody.addEventListener(\"touchstart\", (e: TouchEvent) => {\n if (refreshing) return;\n if (e.touches.length !== 1) return;\n if (mlBody.scrollTop > 0) return;\n startY = e.touches[0].clientY;\n pullY = 0;\n active = true;\n indicator.classList.add(\"dragging\");\n labelEl.textContent = \"Pull to refresh\";\n }, { passive: true });\n\n mlBody.addEventListener(\"touchmove\", (e: TouchEvent) => {\n if (!active || refreshing) return;\n const dy = e.touches[0].clientY - startY;\n if (dy <= 0) {\n // User scrolled up past the start \u2014 abandon, let normal scroll\n // take over without reverting (the touchend handler will reset).\n pullY = 0;\n setPull(0, false);\n return;\n }\n // Resist past 0 \u2014 pull \"stretches\" rather than scrolls. preventDefault\n // is necessary to keep the browser from interpreting the drag as\n // overscroll / browser-level pull-to-refresh.\n if (e.cancelable) e.preventDefault();\n pullY = dy * 0.5; // 0.5 = rubber-band damping\n const armed = pullY >= PTR_THRESHOLD_PX;\n labelEl.textContent = armed ? \"Release to refresh\" : \"Pull to refresh\";\n setPull(pullY, armed);\n }, { passive: false });\n\n const finish = async (): Promise<void> => {\n if (!active) return;\n active = false;\n if (pullY < PTR_THRESHOLD_PX) { reset(); return; }\n // Armed \u2014 show spinner, fire sync, clear when complete.\n refreshing = true;\n indicator.classList.remove(\"armed\", \"dragging\");\n indicator.classList.add(\"refreshing\");\n indicator.style.transform = `translateY(calc(-100% + 60px))`;\n labelEl.textContent = \"Refreshing...\";\n try {\n const { triggerSync } = await import(\"./lib/api-client.js\");\n await triggerSync();\n } catch (e: any) {\n console.error(\"pull-to-refresh failed:\", e?.message || e);\n }\n refreshing = false;\n indicator.classList.remove(\"refreshing\");\n reset();\n };\n mlBody.addEventListener(\"touchend\", () => { finish(); }, { passive: true });\n mlBody.addEventListener(\"touchcancel\", () => { active = false; reset(); }, { passive: true });\n})();\n\n// P115: pick up a pending mailto: drop file dropped by the CLI invocation\n// that spawned us (or by an earlier --mailto run that the user hasn't yet\n// acknowledged). The IPC call deletes the file as part of reading it, so a\n// race with the daemon's fs.watch is harmless \u2014 whichever fires first wins\n// and the other gets null. Subsequent live clicks during this session\n// arrive via the `openMailto` event handled in onEvent above.\n(async () => {\n try {\n const { consumePendingMailto } = await import(\"./lib/api-client.js\");\n const data = await consumePendingMailto();\n if (data && (data.to.length || data.subject || data.body)) {\n await openComposeFromMailto(data);\n }\n } catch (e: any) {\n console.error(\"pending mailto pickup failed:\", e?.message || e);\n }\n})();\n\n// Two-line toggle\noptTwoLine?.addEventListener(\"change\", () => {\n const list = document.getElementById(\"message-list\");\n if (optTwoLine.checked) {\n list?.classList.add(\"two-line\");\n } else {\n list?.classList.remove(\"two-line\");\n }\n localStorage.setItem(\"mailx-two-line\", String(optTwoLine.checked));\n});\n\n// Preview pane toggle\noptPreview?.addEventListener(\"change\", () => {\n const main = document.querySelector(\".main-area\");\n if (optPreview.checked) {\n main?.classList.remove(\"no-preview\");\n } else {\n main?.classList.add(\"no-preview\");\n }\n localStorage.setItem(\"mailx-preview\", String(optPreview.checked));\n});\n\n// Preview snippet toggle\noptSnippet?.addEventListener(\"change\", () => {\n const list = document.getElementById(\"message-list\");\n if (optSnippet.checked) {\n list?.classList.remove(\"no-snippets\");\n } else {\n list?.classList.add(\"no-snippets\");\n }\n localStorage.setItem(\"mailx-snippet\", String(optSnippet.checked));\n});\n\n// \u2500\u2500 Search help button (?) \u2500\u2500\n// Toggles a NON-modal panel with the search-syntax reference so it stays\n// visible while the user types into the search box \u2014 build a query\n// against it, then dismiss. The panel is anchored directly below the\n// search bar (no dark backdrop, no centered modal).\n//\n// The help content is HTML compiled into the app (client/help/search-help.ts),\n// NOT a docs/*.md file. Feature help is application content; the docs/*.md\n// files are only a stand-in for proper settings UI and stay out of this path.\n// The module is dynamically imported so its markup isn't in the cold-start\n// bundle.\ndocument.getElementById(\"search-help\")?.addEventListener(\"click\", async () => {\n const btn = document.getElementById(\"search-help\") as HTMLButtonElement | null;\n if (!btn) return;\n\n // Toggle: a second click on ? closes the open panel.\n const existing = document.getElementById(\"search-help-panel\");\n if (existing) { existing.remove(); btn.setAttribute(\"aria-expanded\", \"false\"); return; }\n\n const { SEARCH_HELP_HTML } = await import(\"./help/search-help.js\");\n\n const searchBar = document.querySelector(\"search.ml-search\") as HTMLElement | null;\n\n const panel = document.createElement(\"div\");\n panel.id = \"search-help-panel\";\n panel.className = \"search-help-panel\";\n panel.innerHTML = `\n <div class=\"search-help-head\">\n <span>Search syntax</span>\n <button type=\"button\" id=\"search-help-close\" title=\"Close (Esc)\" aria-label=\"Close\">×</button>\n </div>\n <div class=\"search-help-body\">${SEARCH_HELP_HTML}</div>\n `;\n document.body.appendChild(panel);\n btn.setAttribute(\"aria-expanded\", \"true\");\n\n // Anchor below the search bar, clamped to the viewport. Re-runs on\n // resize so the panel tracks the bar if the layout reflows.\n const position = (): void => {\n const anchor = searchBar || btn;\n const rect = anchor.getBoundingClientRect();\n const margin = 8;\n const top = rect.bottom + 2;\n const width = Math.min(640, Math.max(320, rect.width));\n let left = rect.left;\n if (left + width > window.innerWidth - margin) left = window.innerWidth - margin - width;\n if (left < margin) left = margin;\n panel.style.top = `${top}px`;\n panel.style.left = `${left}px`;\n panel.style.width = `${width}px`;\n panel.style.maxHeight = `${Math.max(160, window.innerHeight - top - margin)}px`;\n };\n position();\n\n const close = (): void => {\n panel.remove();\n btn.setAttribute(\"aria-expanded\", \"false\");\n window.removeEventListener(\"resize\", position);\n document.removeEventListener(\"keydown\", onKey, true);\n document.removeEventListener(\"mousedown\", onOutside);\n };\n // Esc always closes the help panel while it's open \u2014 including when\n // focus is in the search box. Capture phase + stopPropagation so the\n // search input doesn't also clear/blur on the same keystroke.\n const onKey = (e: KeyboardEvent): void => {\n if (e.key === \"Escape\") {\n e.preventDefault();\n e.stopPropagation();\n close();\n }\n };\n // Click outside dismisses \u2014 except clicks on the search bar itself\n // (typing a query must not close the reference) or the ? button\n // (its own handler toggles).\n const onOutside = (e: MouseEvent): void => {\n const t = e.target as Node;\n if (panel.contains(t) || btn.contains(t) || searchBar?.contains(t)) return;\n close();\n };\n panel.querySelector(\"#search-help-close\")?.addEventListener(\"click\", close);\n window.addEventListener(\"resize\", position);\n document.addEventListener(\"keydown\", onKey, true);\n document.addEventListener(\"mousedown\", onOutside);\n});\n\n// \u2500\u2500 JSONC config file editor \u2500\u2500\ndocument.getElementById(\"btn-edit-jsonc\")?.addEventListener(\"click\", async () => {\n const settingsDropdown = document.getElementById(\"settings-dropdown\");\n if (settingsDropdown) settingsDropdown.hidden = true;\n await openJsoncEditor(\"accounts.jsonc\");\n});\n// Allow other components (remote-content banner, etc.) to open the editor\n// pre-selected to a specific file.\ndocument.addEventListener(\"mailx-open-jsonc-editor\", async (ev: Event) => {\n const file = ((ev as CustomEvent).detail?.file as string) || \"accounts.jsonc\";\n await openJsoncEditor(file);\n});\n// Q61: open ~/.mailx in OS file explorer.\ndocument.getElementById(\"btn-open-mailx-dir\")?.addEventListener(\"click\", async () => {\n const settingsDropdown = document.getElementById(\"settings-dropdown\");\n if (settingsDropdown) settingsDropdown.hidden = true;\n try {\n const { openLocalPath } = await import(\"./lib/api-client.js\");\n await openLocalPath(\"config\");\n } catch (e: any) {\n alert(`Couldn't open folder: ${e?.message || e}`);\n }\n});\n// Q62: open today's log file.\ndocument.getElementById(\"btn-open-log\")?.addEventListener(\"click\", async () => {\n const settingsDropdown = document.getElementById(\"settings-dropdown\");\n if (settingsDropdown) settingsDropdown.hidden = true;\n try {\n const { openLocalPath } = await import(\"./lib/api-client.js\");\n await openLocalPath(\"log\");\n } catch (e: any) {\n alert(`Couldn't open log: ${e?.message || e}`);\n }\n});\n\nasync function openJsoncEditor(initialFile: string): Promise<void> {\n const { readJsoncFile, writeJsoncFile, readConfigHelp, formatJsonc, openLocalPath } = await import(\"./lib/api-client.js\");\n\n const backdrop = document.createElement(\"div\");\n backdrop.className = \"mailx-modal-backdrop\";\n const panel = document.createElement(\"div\");\n panel.className = \"mailx-modal mailx-modal-wide\";\n panel.innerHTML = `\n <div class=\"mailx-modal-title\">\n <span class=\"mailx-modal-title-text\">Edit config file</span>\n <button type=\"button\" class=\"mailx-modal-close\" id=\"jsonc-close\" title=\"Close (Esc)\" aria-label=\"Close\">×</button>\n </div>\n <label class=\"mailx-modal-label\">File\n <select class=\"mailx-modal-input\" id=\"jsonc-file\">\n <option value=\"accounts.jsonc\">accounts.jsonc \u2014 accounts (shared via Google Drive)</option>\n <option value=\"contacts.jsonc\">contacts.jsonc \u2014 preferred + denylist + discovered (shared)</option>\n <option value=\"allowlist.jsonc\">allowlist.jsonc \u2014 remote-content allowlist (shared)</option>\n <option value=\"clients.jsonc\">clients.jsonc \u2014 per-device registrations (shared)</option>\n <option value=\"config.jsonc\">config.jsonc \u2014 local per-machine overrides (not synced)</option>\n </select>\n </label>\n <div class=\"mailx-modal-split\">\n <label class=\"mailx-modal-label mailx-modal-split-left\">Contents (JSONC \u2014 comments and trailing commas allowed)\n <div class=\"jsonc-editor-wrap\">\n <div class=\"jsonc-gutter\" id=\"jsonc-gutter\" aria-hidden=\"true\"></div>\n <textarea class=\"mailx-modal-input mailx-modal-textarea jsonc-textarea\" id=\"jsonc-content\" spellcheck=\"false\"></textarea>\n </div>\n </label>\n <div class=\"mailx-modal-split-right mailx-help-panel\">\n <div class=\"mailx-help-title\">\n <button type=\"button\" class=\"mailx-help-toggle\" id=\"jsonc-help-toggle\" aria-expanded=\"true\" title=\"Hide/show help\">\u25BE Help</button>\n </div>\n <div class=\"mailx-help-body\" id=\"jsonc-help-body\"></div>\n </div>\n </div>\n <div class=\"mailx-modal-error\" id=\"jsonc-error\" hidden></div>\n <div class=\"mailx-modal-buttons\">\n <button type=\"button\" class=\"mailx-modal-btn\" data-action=\"format\" title=\"Reformat indentation while preserving comments and trailing commas\">Format</button>\n <button type=\"button\" class=\"mailx-modal-btn\" data-action=\"opensource\" id=\"jsonc-opensource\" title=\"Open the folder containing config.jsonc so you can edit it in a full editor\" hidden>Open source folder</button>\n <span class=\"mailx-modal-spacer\"></span>\n <button type=\"button\" class=\"mailx-modal-btn\" data-action=\"cancel\">Cancel</button>\n <button type=\"button\" class=\"mailx-modal-btn mailx-modal-btn-primary\" data-action=\"save\">Save</button>\n </div>`;\n backdrop.appendChild(panel);\n document.body.appendChild(backdrop);\n\n const fileSelect = panel.querySelector<HTMLSelectElement>(\"#jsonc-file\")!;\n const textarea = panel.querySelector<HTMLTextAreaElement>(\"#jsonc-content\")!;\n const gutter = panel.querySelector<HTMLElement>(\"#jsonc-gutter\")!;\n const errorEl = panel.querySelector<HTMLElement>(\"#jsonc-error\")!;\n const saveBtn = panel.querySelector<HTMLButtonElement>('[data-action=\"save\"]')!;\n const helpBody = panel.querySelector<HTMLElement>(\"#jsonc-help-body\")!;\n const helpToggle = panel.querySelector<HTMLButtonElement>(\"#jsonc-help-toggle\")!;\n const helpPanel = panel.querySelector<HTMLElement>(\".mailx-help-panel\")!;\n fileSelect.value = initialFile;\n\n // Line-number gutter \u2014 recomputed whenever the textarea content changes,\n // scroll-synced so numbers stay aligned. errorLine (1-based) is highlighted\n // red so the \"Line N, col M\" error message in the status bar points at a\n // visible marker in the gutter.\n let errorLine = 0;\n const renderGutter = () => {\n const lines = textarea.value.split(\"\\n\").length;\n let html = \"\";\n for (let i = 1; i <= lines; i++) {\n html += i === errorLine\n ? `<div class=\"jsonc-gutter-line jsonc-gutter-error\">${i}</div>`\n : `<div class=\"jsonc-gutter-line\">${i}</div>`;\n }\n gutter.innerHTML = html;\n };\n const syncScroll = () => { gutter.scrollTop = textarea.scrollTop; };\n textarea.addEventListener(\"scroll\", syncScroll);\n textarea.addEventListener(\"input\", renderGutter);\n\n helpToggle.addEventListener(\"click\", () => {\n const open = helpPanel.classList.toggle(\"mailx-help-collapsed\");\n helpToggle.textContent = open ? \"\u25B8 Help\" : \"\u25BE Help\";\n helpToggle.setAttribute(\"aria-expanded\", open ? \"false\" : \"true\");\n });\n\n const loadHelp = async () => {\n helpBody.textContent = \"Loading help\u2026\";\n try {\n const r = await readConfigHelp(fileSelect.value);\n const md = (r?.content || \"\").trim();\n helpBody.innerHTML = md ? renderMarkdown(md) : \"<em>No help available for this file.</em>\";\n } catch (e: any) {\n helpBody.textContent = `Help unavailable: ${e.message}`;\n }\n };\n\n const clearValidation = () => {\n errorEl.hidden = true;\n errorEl.textContent = \"\";\n textarea.classList.remove(\"mailx-modal-input-error\");\n saveBtn.disabled = false;\n errorLine = 0;\n renderGutter();\n };\n const showValidation = (err: { message: string; pos: number; line: number; col: number }) => {\n // CRITICAL: do NOT move the cursor here. Validation fires every 600ms\n // while the user types; auto-selecting the error position yanked the\n // cursor mid-edit and made fixing the error impossible (the user\n // reported this as a fatal bug \u2014 the very mechanism preventing a save\n // was preventing the fix). Location is shown via the gutter highlight\n // + the \"Line N, col M\" message, and the user can click \"Jump\" to\n // explicitly navigate.\n errorEl.innerHTML = \"\";\n const text = document.createElement(\"span\");\n text.textContent = `Line ${err.line}, col ${err.col}: ${err.message} `;\n const jumpBtn = document.createElement(\"button\");\n jumpBtn.type = \"button\";\n jumpBtn.className = \"mailx-modal-btn mailx-modal-btn-link\";\n jumpBtn.textContent = \"Jump to error\";\n jumpBtn.addEventListener(\"click\", () => {\n textarea.focus();\n try { textarea.setSelectionRange(err.pos, err.pos + 1); } catch { /* */ }\n });\n errorEl.appendChild(text);\n errorEl.appendChild(jumpBtn);\n errorEl.hidden = false;\n textarea.classList.add(\"mailx-modal-input-error\");\n saveBtn.disabled = true;\n errorLine = err.line;\n renderGutter();\n };\n\n let validateTimer: number | undefined;\n const scheduleValidate = () => {\n if (validateTimer) window.clearTimeout(validateTimer);\n validateTimer = window.setTimeout(() => {\n const err = validateJsonc(textarea.value);\n if (err) showValidation(err); else clearValidation();\n }, 600);\n };\n textarea.addEventListener(\"input\", scheduleValidate);\n\n const loadFile = async () => {\n textarea.value = \"Loading...\";\n clearValidation();\n renderGutter();\n try {\n const r = await readJsoncFile(fileSelect.value);\n textarea.value = r?.content || \"\";\n renderGutter();\n scheduleValidate();\n } catch (e: any) {\n textarea.value = \"\";\n renderGutter();\n errorEl.textContent = `Failed to load: ${e.message}`;\n errorEl.hidden = false;\n }\n };\n // \"Open source folder\" \u2014 only meaningful for config.jsonc, the one\n // file that lives on the local disk (~/.rmfmail/config.jsonc). The\n // others are Google Drive objects with no local path, so the button\n // is hidden for them. Lets the user edit config.jsonc in a real editor\n // instead of the modal textarea.\n const openSourceBtn = panel.querySelector<HTMLButtonElement>(\"#jsonc-opensource\")!;\n const updateOpenSource = () => { openSourceBtn.hidden = fileSelect.value !== \"config.jsonc\"; };\n updateOpenSource();\n\n await Promise.all([loadFile(), loadHelp()]);\n fileSelect.addEventListener(\"change\", () => { loadFile(); loadHelp(); updateOpenSource(); });\n\n const close = () => {\n if (validateTimer) window.clearTimeout(validateTimer);\n backdrop.remove();\n document.removeEventListener(\"keydown\", onKey, true);\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") { e.stopPropagation(); e.preventDefault(); close(); }\n };\n document.addEventListener(\"keydown\", onKey, true);\n panel.querySelector<HTMLButtonElement>(\"#jsonc-close\")!.addEventListener(\"click\", close);\n\n panel.querySelectorAll<HTMLButtonElement>(\".mailx-modal-btn\").forEach(btn => {\n btn.addEventListener(\"click\", async () => {\n const action = btn.dataset.action;\n if (action === \"cancel\") { close(); return; }\n if (action === \"opensource\") {\n // Reveal ~/.rmfmail/ in the OS file manager \u2014 config.jsonc\n // sits there; the user can then open it in a full editor.\n try { await openLocalPath(\"config\"); }\n catch (e: any) { errorEl.textContent = `Couldn't open folder: ${e?.message || e}`; errorEl.hidden = false; }\n return;\n }\n if (action === \"format\") {\n // Reformat via the service-side jsonc-parser format() \u2014 the\n // edits are whitespace-only, so `//` and `/* */` comments\n // survive intact (which JSON.stringify(parse(...)) does not).\n btn.disabled = true;\n const orig = btn.textContent;\n btn.textContent = \"Formatting\u2026\";\n try {\n const r = await formatJsonc(textarea.value);\n if (r?.content !== undefined) {\n textarea.value = r.content;\n renderGutter();\n scheduleValidate();\n }\n } catch (e: any) {\n errorEl.textContent = `Format failed: ${e.message}`;\n errorEl.hidden = false;\n } finally {\n btn.disabled = false;\n btn.textContent = orig || \"Format\";\n }\n return;\n }\n if (action === \"save\") {\n // Final sync-check; refuse to save if it doesn't parse\n const err = validateJsonc(textarea.value);\n if (err) { showValidation(err); return; }\n errorEl.hidden = true;\n btn.disabled = true;\n btn.textContent = \"Saving...\";\n // Auto-format before save: the service-side formatter does\n // whitespace-only edits via jsonc-parser, so // and /* */\n // comments survive. If the format call fails (network /\n // parse) we fall back to the raw text \u2014 better to save\n // something than lose the user's edit.\n let toWrite = textarea.value;\n try {\n const r = await formatJsonc(textarea.value);\n if (r?.content !== undefined) {\n toWrite = r.content;\n textarea.value = r.content;\n }\n } catch { /* keep raw text */ }\n try {\n await writeJsoncFile(fileSelect.value, toWrite);\n close();\n const statusSync = document.getElementById(\"status-sync\");\n if (statusSync) statusSync.textContent = `Saved ${fileSelect.value} \u2014 restart mailx to apply`;\n } catch (e: any) {\n errorEl.textContent = `${e.message}`;\n errorEl.hidden = false;\n btn.disabled = false;\n btn.textContent = \"Save\";\n }\n }\n });\n });\n backdrop.addEventListener(\"mousedown\", (e) => { if (e.target === backdrop) close(); });\n}\n\n// JSONC validator \u2014 strips comments + trailing commas (preserving source positions\n// by replacing stripped chars with spaces/newlines) and runs JSON.parse. Reports\n// only the *first* error; cascading errors are suppressed.\nfunction validateJsonc(src: string): { message: string; pos: number; line: number; col: number } | null {\n const stripped = stripJsoncPreservingPositions(src);\n if (stripped.error) {\n const { pos, line, col } = offsetToLineCol(src, stripped.error.pos);\n return { message: stripped.error.message, pos, line, col };\n }\n if (stripped.text.trim() === \"\") return null; // empty file: treat as valid (settings code handles)\n try {\n JSON.parse(stripped.text);\n return null;\n } catch (e: any) {\n const msg = String(e?.message || \"parse error\");\n const m = msg.match(/at position (\\d+)/i);\n const pos = m ? Math.min(parseInt(m[1], 10), src.length - 1) : 0;\n const lc = offsetToLineCol(src, pos);\n return { message: msg.replace(/\\s*at position \\d+/i, \"\"), pos: lc.pos, line: lc.line, col: lc.col };\n }\n}\n\nfunction stripJsoncPreservingPositions(src: string): { text: string; error?: { message: string; pos: number } } {\n const out: string[] = new Array(src.length);\n let i = 0;\n const n = src.length;\n while (i < n) {\n const c = src[i];\n const next = src[i + 1];\n if (c === '\"') {\n out[i] = c; i++;\n while (i < n) {\n const ch = src[i];\n out[i] = ch; i++;\n if (ch === \"\\\\\" && i < n) { out[i] = src[i]; i++; continue; }\n if (ch === '\"') break;\n if (ch === \"\\n\") return { text: out.join(\"\"), error: { message: \"unterminated string\", pos: i - 1 } };\n }\n } else if (c === \"/\" && next === \"/\") {\n while (i < n && src[i] !== \"\\n\") { out[i] = \" \"; i++; }\n } else if (c === \"/\" && next === \"*\") {\n const start = i;\n out[i] = \" \"; out[i + 1] = \" \"; i += 2;\n let closed = false;\n while (i < n) {\n if (src[i] === \"*\" && src[i + 1] === \"/\") { out[i] = \" \"; out[i + 1] = \" \"; i += 2; closed = true; break; }\n out[i] = src[i] === \"\\n\" ? \"\\n\" : \" \"; i++;\n }\n if (!closed) return { text: out.join(\"\"), error: { message: \"unterminated block comment\", pos: start } };\n } else if (c === \",\") {\n // trailing comma before } or ] \u2192 replace with space\n let j = i + 1;\n while (j < n && /\\s/.test(src[j])) j++;\n if (j < n && (src[j] === \"}\" || src[j] === \"]\")) { out[i] = \" \"; i++; }\n else { out[i] = c; i++; }\n } else {\n out[i] = c; i++;\n }\n }\n return { text: out.join(\"\") };\n}\n\nfunction offsetToLineCol(src: string, pos: number): { pos: number; line: number; col: number } {\n pos = Math.max(0, Math.min(pos, src.length));\n let line = 1, col = 1;\n for (let i = 0; i < pos; i++) {\n if (src[i] === \"\\n\") { line++; col = 1; } else col++;\n }\n return { pos, line, col };\n}\n\n// Minimal markdown renderer for the help panel. Supports: headings, fenced code blocks,\n// inline code, bold, italic, links, bullet lists, paragraphs. HTML is escaped first.\nfunction renderMarkdown(md: string): string {\n const esc = (s: string) => s.replace(/[&<>\"']/g, c =>\n ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c]!);\n\n // Pull fenced code blocks out first so their contents aren't processed as markdown.\n const blocks: string[] = [];\n let src = md.replace(/```(\\w*)\\n([\\s\\S]*?)```/g, (_m, _lang, code) => {\n const i = blocks.length;\n blocks.push(`<pre class=\"mailx-help-code\"><code>${esc(code)}</code></pre>`);\n return `\\u0000BLOCK${i}\\u0000`;\n });\n\n const lines = src.split(/\\r?\\n/);\n const out: string[] = [];\n let inList = false;\n let para: string[] = [];\n const flushPara = () => {\n if (para.length) { out.push(`<p>${inline(para.join(\" \"))}</p>`); para = []; }\n };\n const closeList = () => { if (inList) { out.push(\"</ul>\"); inList = false; } };\n\n function inline(s: string): string {\n s = esc(s);\n s = s.replace(/`([^`]+)`/g, (_m, c) => `<code>${c}</code>`);\n s = s.replace(/\\*\\*([^*]+)\\*\\*/g, \"<strong>$1</strong>\");\n s = s.replace(/(^|[^*])\\*([^*\\n]+)\\*/g, \"$1<em>$2</em>\");\n s = s.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href=\"$2\" target=\"_blank\" rel=\"noopener\">$1</a>');\n return s;\n }\n\n for (const raw of lines) {\n const blockMatch = /^\\u0000BLOCK(\\d+)\\u0000$/.exec(raw);\n if (blockMatch) { flushPara(); closeList(); out.push(blocks[parseInt(blockMatch[1], 10)]); continue; }\n const h = /^(#{1,6})\\s+(.+)$/.exec(raw);\n if (h) { flushPara(); closeList(); const lvl = h[1].length; out.push(`<h${lvl}>${inline(h[2])}</h${lvl}>`); continue; }\n const bullet = /^\\s*[-*]\\s+(.+)$/.exec(raw);\n if (bullet) {\n flushPara();\n if (!inList) { out.push(\"<ul>\"); inList = true; }\n out.push(`<li>${inline(bullet[1])}</li>`);\n continue;\n }\n if (raw.trim() === \"\") { flushPara(); closeList(); continue; }\n para.push(raw);\n }\n flushPara();\n closeList();\n return out.join(\"\\n\");\n}\n\n// \u2500\u2500 Keyboard shortcuts (Settings menu item) \u2500\u2500\ndocument.getElementById(\"btn-shortcuts\")?.addEventListener(\"click\", () => {\n const settingsDropdown = document.getElementById(\"settings-dropdown\");\n if (settingsDropdown) settingsDropdown.hidden = true;\n openShortcutsDialog();\n});\n\n// \u2500\u2500 Check for updates (Settings menu item) \u2500\u2500\n// On phone narrow tier the toolbar Update menu is collapsed away \u2014 give the\n// user an explicit, non-reloading path to trigger the update poll. The\n// AppUpdater banner appears at the bottom of the WebView if a newer version\n// is available; otherwise the status bar reports \"up to date\".\ndocument.getElementById(\"btn-settings-checkupdate\")?.addEventListener(\"click\", () => {\n const settingsDropdown = document.getElementById(\"settings-dropdown\");\n if (settingsDropdown) settingsDropdown.hidden = true;\n const statusSync = document.getElementById(\"status-sync\");\n if (statusSync) statusSync.textContent = \"Checking for updates\u2026\";\n const isAndroid = (window as any).mailxapi?.platform === \"android\";\n if (isAndroid) {\n // Bridge fires the C# AppUpdater.CheckForUpdate, which injects the\n // bottom banner if a newer version is on rmf39.aaz.lt/mailx.\n const f = document.createElement(\"iframe\");\n f.style.display = \"none\";\n f.src = \"mailxapi://checkUpdate\";\n document.body.appendChild(f);\n setTimeout(() => f.remove(), 100);\n // Status hint resets after a few seconds in case nothing happens\n // (already up to date, or fetch failed silently).\n setTimeout(() => {\n if (statusSync && statusSync.textContent === \"Checking for updates\u2026\") {\n statusSync.textContent = \"Up to date or check pending \u2014 see banner if available\";\n setTimeout(() => { if (statusSync.textContent?.startsWith(\"Up to date\")) statusSync.textContent = \"\"; }, 6000);\n }\n }, 4000);\n } else {\n // Desktop: same path as the toolbar btn-update.\n const ipc = (window as any).mailxapi || (window as any).opener?.mailxapi;\n if (ipc?.performUpdate) {\n if (statusSync) statusSync.textContent = \"Updating\u2026 mailx will restart when done\";\n ipc.performUpdate();\n } else if (statusSync) {\n statusSync.textContent = \"Update not available in this mode\";\n }\n }\n});\n\n// \u2500\u2500 About dialog \u2500\u2500\ndocument.getElementById(\"btn-about\")?.addEventListener(\"click\", () => {\n const settingsDropdown = document.getElementById(\"settings-dropdown\");\n if (settingsDropdown) settingsDropdown.hidden = true;\n openAboutDialog();\n});\n// Clicking the version string (toolbar in wide mode, status bar in narrow mode) also opens About\ndocument.querySelectorAll<HTMLElement>(\".app-version\").forEach(el => {\n el.style.cursor = \"pointer\";\n el.addEventListener(\"click\", openAboutDialog);\n});\n\nasync function openAboutDialog(): Promise<void> {\n const backdrop = document.createElement(\"div\");\n backdrop.className = \"mailx-modal-backdrop\";\n const panel = document.createElement(\"div\");\n panel.className = \"mailx-modal\";\n panel.innerHTML = `\n <div class=\"mailx-modal-title\">\n <span class=\"mailx-modal-title-text\">About ${APP_NAME}</span>\n <button type=\"button\" class=\"mailx-modal-close\" id=\"about-x\" title=\"Close (Esc)\" aria-label=\"Close\">×</button>\n </div>\n <div class=\"mailx-about\" id=\"about-body\">Loading...</div>\n <div class=\"mailx-modal-buttons\">\n <span class=\"mailx-modal-spacer\"></span>\n <button type=\"button\" class=\"mailx-modal-btn mailx-modal-btn-primary\" data-action=\"close\">Close</button>\n </div>`;\n backdrop.appendChild(panel);\n document.body.appendChild(panel.parentElement!);\n\n const body = panel.querySelector<HTMLElement>(\"#about-body\")!;\n const close = () => {\n backdrop.remove();\n document.removeEventListener(\"keydown\", onKey, true);\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") { e.stopPropagation(); e.preventDefault(); close(); }\n };\n document.addEventListener(\"keydown\", onKey, true);\n panel.querySelector<HTMLButtonElement>('[data-action=\"close\"]')!\n .addEventListener(\"click\", close);\n panel.querySelector<HTMLButtonElement>(\"#about-x\")!\n .addEventListener(\"click\", close);\n backdrop.addEventListener(\"mousedown\", (e) => { if (e.target === backdrop) close(); });\n\n try {\n const [v, accounts] = await Promise.all([\n getVersion().catch(() => ({} as any)),\n getAccounts().catch(() => [] as any[]),\n ]);\n const storage = v.storage || {};\n const isApp = typeof mailxapi !== \"undefined\" && mailxapi?.isApp;\n const platform = isApp ? (mailxapi?.platform || \"app\") : \"browser\";\n const versionText = v.version ? `v${v.version}` : \"unknown\";\n const versionHtml = v.version\n ? `<a href=\"https://github.com/BobFrankston/mailx/releases/tag/v${v.version}\" target=\"_blank\" rel=\"noopener\">${versionText}</a>`\n : versionText;\n const rows: [string, string][] = [\n [\"Version\", versionHtml],\n [\"Platform\", platform],\n [\"Storage\", storage.provider || \"local\"],\n ];\n if (storage.cloudPath) rows.push([\"Cloud path\", `My Drive/${storage.cloudPath}/`]);\n if (storage.mode) rows.push([\"Storage mode\", storage.mode]);\n if (storage.folderPath) rows.push([\"Drive path\", storage.folderPath]);\n else if (storage.folderName) rows.push([\"Drive folder\", storage.folderName]);\n if (storage.folderId) rows.push([\"Drive folderId\", storage.folderId]);\n if (storage.folderOwner) rows.push([\"Drive owner\", storage.folderOwner]);\n if (storage.configDir) rows.push([\"Config dir\", storage.configDir]);\n rows.push([\"Accounts\", String((accounts || []).length)]);\n rows.push([\"User agent\", navigator.userAgent]);\n rows.push([\"Screen\", `${screen.width}\u00D7${screen.height}`]);\n rows.push([\"Window\", `${window.innerWidth}\u00D7${window.innerHeight}`]);\n\n // Version row contains an anchor tag; all other rows are plain text\n // and must be escaped. Treat row[0]===\"Version\" as pre-formatted HTML.\n body.innerHTML = `\n <dl class=\"mailx-about-dl\">\n ${rows.map(([k, val]) => `<dt>${k}</dt><dd>${k === \"Version\" ? val : escapeHtml(val)}</dd>`).join(\"\")}\n </dl>\n ${(accounts || []).length ? `\n <div class=\"mailx-about-accounts\">\n <div class=\"mailx-about-section\">Accounts</div>\n <ul>\n ${(accounts as any[]).map(a => `<li>${escapeHtml(a.email || a.id)}${a.name ? ` \u2014 ${escapeHtml(a.name)}` : \"\"}</li>`).join(\"\")}\n </ul>\n </div>` : \"\"}\n <div class=\"mailx-about-foot\">${APP_NAME} \u2014 local-first mail client</div>`;\n } catch (e: any) {\n body.textContent = `Failed to load: ${e.message}`;\n }\n}\n\nfunction escapeHtml(s: string): string {\n return String(s).replace(/[&<>\"']/g, c =>\n ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c]!);\n}\n\n// Threaded view toggle\noptThreaded?.addEventListener(\"change\", () => {\n const body = document.getElementById(\"ml-body\");\n if (optThreaded.checked) {\n body?.classList.add(\"threaded\");\n } else {\n body?.classList.remove(\"threaded\");\n }\n localStorage.setItem(\"mailx-threaded\", String(optThreaded.checked));\n reloadCurrentFolder();\n});\n\n// Flagged-only filter \u2014 keeps the CSS-level hiding for instant feedback on\n// the current page AND re-queries the folder so flagged messages that live\n// outside the currently-loaded page show up.\noptFlagged?.addEventListener(\"change\", () => {\n const body = document.getElementById(\"ml-body\");\n if (optFlagged.checked) body?.classList.add(\"flagged-only\");\n else body?.classList.remove(\"flagged-only\");\n localStorage.setItem(\"mailx-flagged\", String(optFlagged.checked));\n reloadCurrentFolder();\n});\n\n// Priority-senders-only filter \u2014 same pattern as flagged-only. Adds\n// .priority-only to the list body; CSS hides any row without .priority.\nconst optPriorityOnly = document.getElementById(\"opt-priority-only\") as HTMLInputElement | null;\nif (optPriorityOnly) {\n optPriorityOnly.checked = localStorage.getItem(\"mailx-priority-only\") === \"true\";\n const body0 = document.getElementById(\"ml-body\");\n if (optPriorityOnly.checked) body0?.classList.add(\"priority-only\");\n optPriorityOnly.addEventListener(\"change\", () => {\n const body = document.getElementById(\"ml-body\");\n if (optPriorityOnly.checked) body?.classList.add(\"priority-only\");\n else body?.classList.remove(\"priority-only\");\n localStorage.setItem(\"mailx-priority-only\", String(optPriorityOnly.checked));\n });\n}\n\n// Folder counts toggle\noptFolderCounts?.addEventListener(\"change\", () => {\n const tree = document.getElementById(\"folder-tree\");\n if (optFolderCounts.checked) {\n tree?.classList.add(\"show-folder-counts\");\n } else {\n tree?.classList.remove(\"show-folder-counts\");\n }\n localStorage.setItem(\"mailx-folder-counts\", String(optFolderCounts.checked));\n});\n\n// Q52: Reset column widths \u2014 clears persisted list/viewer splitter and\n// restores the default CSS-var value. Currently only the list/viewer split\n// is user-resizable; if per-column drag-resize lands later, add its keys to\n// the cleanup list below.\ndocument.getElementById(\"btn-reset-widths\")?.addEventListener(\"click\", () => {\n localStorage.removeItem(\"mailx-split\");\n document.documentElement.style.removeProperty(\"--list-viewer-split\");\n if (viewDropdown) viewDropdown.hidden = true;\n});\n\n// \u2500\u2500 Settings menu \u2500\u2500\n\nconst settingsBtn = document.getElementById(\"btn-settings\");\nconst settingsDropdown = document.getElementById(\"settings-dropdown\");\nconst optEditorQuill = document.getElementById(\"opt-editor-quill\") as HTMLInputElement;\nconst optEditorTiptap = document.getElementById(\"opt-editor-tiptap\") as HTMLInputElement;\nconst optEditorTinymce = document.getElementById(\"opt-editor-tinymce\") as HTMLInputElement | null;\nconst optTinymceCdn = document.getElementById(\"opt-tinymce-cdn\") as HTMLInputElement | null;\n// Restore TinyMCE CDN URL (Android-only path) from localStorage; the\n// adapter reads it on demand. Persisted client-side because it's a\n// per-device setting (Android needs CDN; desktop has npm install).\ntry {\n if (optTinymceCdn) optTinymceCdn.value = localStorage.getItem(\"mailx-tinymce-cdn\") || \"\";\n} catch { /* */ }\noptTinymceCdn?.addEventListener(\"change\", () => {\n try { localStorage.setItem(\"mailx-tinymce-cdn\", optTinymceCdn.value.trim()); } catch { /* */ }\n});\n\nsettingsBtn?.addEventListener(\"click\", (e) => {\n e.stopPropagation();\n if (viewDropdown) viewDropdown.hidden = true;\n const restartDd = document.getElementById(\"restart-dropdown\");\n if (restartDd) restartDd.hidden = true;\n restoreToolbarDropdown(\"settings-dropdown\", \"settings-menu\");\n if (settingsDropdown) settingsDropdown.hidden = !settingsDropdown.hidden;\n});\n// Close handled by the shared document click handler above\n\n// Load current editor setting from server\ngetSettings().then((s: any) => {\n const ed = s.ui?.editor || \"quill\";\n if (optEditorQuill) optEditorQuill.checked = ed === \"quill\";\n if (optEditorTiptap) optEditorTiptap.checked = ed === \"tiptap\";\n if (optEditorTinymce) optEditorTinymce.checked = ed === \"tinymce\";\n}).catch(() => {});\n\n// Save editor choice to server settings\nfunction saveEditorSetting(editor: string): void {\n // Update the localStorage cache SYNCHRONOUSLY. compose.ts reads\n // `mailx-editor-type` from localStorage at module-load to pick the\n // editor \u2014 its async getSettings() refresh only runs when a compose\n // window opens, and reads localStorage FIRST. Without this write the\n // cache stays stale and the next compose keeps the old editor until a\n // full app restart (Bob 2026-05-21: \"changed to quill but got tinymce\n // until I restarted\"). With it, the very next compose-open is correct.\n try { localStorage.setItem(\"mailx-editor-type\", editor); } catch { /* private mode */ }\n getSettings().then((settings: any) => {\n settings.ui = { ...settings.ui, editor };\n saveSettings(settings);\n }).catch(() => {});\n}\n\noptEditorQuill?.addEventListener(\"change\", () => {\n if (optEditorQuill.checked) saveEditorSetting(\"quill\");\n});\noptEditorTiptap?.addEventListener(\"change\", () => {\n if (optEditorTiptap.checked) saveEditorSetting(\"tiptap\");\n});\noptEditorTinymce?.addEventListener(\"change\", () => {\n if (!optEditorTinymce.checked) return;\n saveEditorSetting(\"tinymce\");\n // Q133 + 2026-05-21 \u2014 warm the TinyMCE bundle into the HTTP cache so the\n // first compose-open doesn't stall downloading it. The old code used a\n // silent <link rel=prefetch> \u2014 no completion signal, so picking TinyMCE\n // looked like a frozen UI (\"hang while fetching\"). Now we do an explicit\n // fetch() (I/O, already off the UI thread \u2014 a Worker buys nothing for a\n // download) and surface a pending \u2192 ready indicator on the menu item.\n const cdnUrl = localStorage.getItem(\"mailx-tinymce-cdn\") || \"lib/tinymce/tinymce.min.js\";\n const label = optEditorTinymce.closest(\"label\");\n let status = document.getElementById(\"opt-editor-tinymce-status\");\n if (label && !status) {\n status = document.createElement(\"span\");\n status.id = \"opt-editor-tinymce-status\";\n status.className = \"tb-menu-status\";\n label.appendChild(status);\n }\n const setStatus = (text: string, state: string): void => {\n if (!status) return;\n status.textContent = text;\n status.dataset.state = state;\n };\n setStatus(\" loading\u2026\", \"pending\"); // CSS ::before draws the spinner\n fetch(cdnUrl, { cache: \"force-cache\" })\n .then(r => (r.ok ? r.arrayBuffer() : Promise.reject(new Error(`HTTP ${r.status}`))))\n .then(() => {\n setStatus(\" \u2713 ready\", \"ready\");\n // Clear the badge after a few seconds \u2014 but only if still \"ready\"\n // (don't wipe a later pending/error state from a re-pick).\n setTimeout(() => { if (status?.dataset.state === \"ready\") setStatus(\"\", \"idle\"); }, 4000);\n })\n .catch(e => {\n setStatus(\" \u26A0 load failed\", \"error\");\n console.error(\"[tinymce] pre-warm fetch failed:\", e?.message || e);\n });\n});\n\n// External editor preference (Edit-in-Word handoff target). Stored under\n// settings.externalEditor so the service can read it via loadSettings().\n// \"auto\" tries Word \u2192 LibreOffice \u2192 OS default; explicit values force\n// that editor (still falling back to OS default if it isn't installed).\nconst optExtEditAuto = document.getElementById(\"opt-extedit-auto\") as HTMLInputElement | null;\nconst optExtEditWord = document.getElementById(\"opt-extedit-word\") as HTMLInputElement | null;\nconst optExtEditLibre = document.getElementById(\"opt-extedit-libre\") as HTMLInputElement | null;\ngetSettings().then((s: any) => {\n const v = s.externalEditor || \"auto\";\n if (optExtEditAuto) optExtEditAuto.checked = v === \"auto\";\n if (optExtEditWord) optExtEditWord.checked = v === \"word\";\n if (optExtEditLibre) optExtEditLibre.checked = v === \"libreoffice\";\n}).catch(() => {});\nfunction saveExtEditor(v: \"auto\" | \"word\" | \"libreoffice\"): void {\n getSettings().then((settings: any) => {\n settings.externalEditor = v;\n saveSettings(settings);\n }).catch(() => {});\n}\noptExtEditAuto?.addEventListener(\"change\", () => { if (optExtEditAuto.checked) saveExtEditor(\"auto\"); });\noptExtEditWord?.addEventListener(\"change\", () => { if (optExtEditWord.checked) saveExtEditor(\"word\"); });\noptExtEditLibre?.addEventListener(\"change\", () => { if (optExtEditLibre.checked) saveExtEditor(\"libreoffice\"); });\n\n// \u2500\u2500 AI feature toggles \u2500\u2500\n// One umbrella settings record (AutocompleteSettings) holds the provider config\n// + per-feature on/off flags. All features default OFF \u2014 user must opt into\n// each AI behavior individually. Per user preference (2026-04-21).\nconst optAutocomplete = document.getElementById(\"opt-autocomplete\") as HTMLInputElement | null;\nconst optAiTranslate = document.getElementById(\"opt-ai-translate\") as HTMLInputElement | null;\nconst optAiProofread = document.getElementById(\"opt-ai-proofread\") as HTMLInputElement | null;\n\ngetAutocompleteSettings().then((ac: any) => {\n if (optAutocomplete) optAutocomplete.checked = !!ac.enabled;\n if (optAiTranslate) optAiTranslate.checked = !!ac.translateEnabled;\n if (optAiProofread) optAiProofread.checked = !!ac.proofreadEnabled;\n}).catch(() => {});\n\nfunction persistAi(mutator: (ac: any) => void): void {\n getAutocompleteSettings().then((ac: any) => {\n mutator(ac);\n saveAutocompleteSettings(ac);\n }).catch(() => {});\n}\noptAutocomplete?.addEventListener(\"change\", () => persistAi((ac) => { ac.enabled = optAutocomplete.checked; }));\noptAiTranslate?.addEventListener(\"change\", () => persistAi((ac) => { ac.translateEnabled = optAiTranslate.checked; }));\noptAiProofread?.addEventListener(\"change\", () => {\n persistAi((ac) => { ac.proofreadEnabled = optAiProofread.checked; });\n // Mirror to localStorage so the compose editor (separate page/iframe with\n // its own getSettings cycle) can read it synchronously.\n try { localStorage.setItem(\"mailx-ai-proofread-enabled\", String(optAiProofread.checked)); } catch { /* */ }\n});\n\n// Sender reputation check (Spamhaus DBL). Stored at top-level settings so\n// the service can read it cheaply without going through autocomplete config.\n// Off by default \u2014 enabling it leaks read-recipient domains to Spamhaus's\n// DNS infra, which the user should opt into knowingly.\nconst optCheckReputation = document.getElementById(\"opt-check-reputation\") as HTMLInputElement | null;\ngetSettings().then((s: any) => {\n if (optCheckReputation) optCheckReputation.checked = !!s.checkDomainReputation;\n}).catch(() => {});\noptCheckReputation?.addEventListener(\"change\", () => {\n getSettings().then((settings: any) => {\n settings.checkDomainReputation = !!optCheckReputation.checked;\n saveSettings(settings);\n }).catch(() => {});\n});\n\n// Auto mark-as-read settings (per-device localStorage; the viewer reads\n// these directly when showing a message). Default on with a 2s delay so\n// scrolling through a folder doesn't mark every glanced-at message as\n// read, but a deliberate read still gets recorded.\nconst optAutomarkRead = document.getElementById(\"opt-automark-read\") as HTMLInputElement | null;\nconst optAutomarkDelay = document.getElementById(\"opt-automark-delay\") as HTMLInputElement | null;\ntry {\n if (optAutomarkRead) optAutomarkRead.checked = localStorage.getItem(\"mailx-automark-read\") !== \"false\";\n if (optAutomarkDelay) optAutomarkDelay.value = localStorage.getItem(\"mailx-automark-delay\") || \"2\";\n} catch { /* private mode */ }\noptAutomarkRead?.addEventListener(\"change\", () => {\n try { localStorage.setItem(\"mailx-automark-read\", String(optAutomarkRead.checked)); } catch { /* */ }\n});\noptAutomarkDelay?.addEventListener(\"change\", () => {\n const v = parseFloat(optAutomarkDelay.value);\n if (Number.isFinite(v) && v >= 0) {\n try { localStorage.setItem(\"mailx-automark-delay\", String(v)); } catch { /* */ }\n }\n});\n\n// \u2500\u2500 Version display \u2500\u2500\ndeclare const mailxapi: { isApp: boolean; platform: string; ensureServer: () => Promise<boolean>; getVersion: () => Promise<any> } | undefined;\nconst isApp = typeof mailxapi !== \"undefined\" && mailxapi?.isApp;\n\n// Wait for server ready signal, then fetch version\nconst versionPromise = getVersion();\nversionPromise.then((d: any) => {\n const els = document.querySelectorAll<HTMLElement>(\".app-version\");\n const storage = d.storage || {};\n const storageLabel = storage.provider && storage.provider !== \"local\"\n ? ` [${storage.provider}]`\n : \"\";\n // Toolbar real estate is tight; the app icon already conveys identity.\n // Show just the version + storage tag (and \"[browser]\" tag when running\n // in a plain browser tab vs. msger / MAUI shell).\n const text = `v${d.version}${storageLabel}${isApp ? \"\" : \" [browser]\"}`;\n const tip = storage.provider && storage.provider !== \"local\"\n ? (storage.cloudPath ? `My Drive/${storage.cloudPath}/` : storage.provider)\n : \"\";\n for (const el of els) {\n el.textContent = text;\n if (tip) el.title = tip;\n }\n if (d.settingsError) {\n showAlert(d.settingsError, \"settings-error\");\n // Add repair button to the banner\n const banner = document.getElementById(\"alert-banner\");\n if (banner && !banner.querySelector(\".repair-btn\")) {\n const btn = document.createElement(\"button\");\n btn.className = \"repair-btn status-action\";\n btn.textContent = \"Repair: restore accounts from cache\";\n btn.style.cssText = \"margin-left:1rem;padding:0.25rem 0.75rem;background:#a6e3a1;color:#1e1e2e;border:none;border-radius:4px;cursor:pointer;font-weight:bold\";\n btn.onclick = async () => {\n btn.textContent = \"Restoring...\";\n btn.disabled = true;\n try {\n const data = await repairAccounts();\n if (data.ok) {\n hideAlert();\n setTimeout(() => location.reload(), 1000);\n } else {\n btn.textContent = `Failed: ${data.error}`;\n }\n } catch (e: any) {\n btn.textContent = `Error: ${e.message}`;\n }\n };\n banner.querySelector(\"#alert-text\")?.after(btn);\n }\n } else if (storage.cloudError) {\n showAlert(`Cloud storage error: ${storage.cloudError}`, \"cloud-error\");\n }\n}).catch((e: any) => {\n // Version fetch failed\n const els = document.querySelectorAll<HTMLElement>(\".app-version\");\n const text = isApp ? `[version error: ${e.message}]` : `[server offline]`;\n for (const el of els) el.textContent = text;\n});\n\n// \u2500\u2500 Sync pending indicator + server health check (HTTP mode only) \u2500\u2500\nlet serverDown = false;\nif (isApp) {\n // IPC mode: events come via push, no polling needed\n} else\nsetInterval(async () => {\n try {\n const data = await getSyncPending();\n const el = document.getElementById(\"status-pending\");\n if (el) {\n el.textContent = data.pending > 0 ? `\u21BB ${data.pending} pending` : \"\";\n el.style.color = data.pending > 0 ? \"oklch(0.75 0.15 60)\" : \"\";\n }\n // Server is back \u2014 reload if it was down\n if (serverDown) {\n serverDown = false;\n const statusEl = document.getElementById(\"status-sync\");\n if (statusEl) statusEl.textContent = \"Server reconnected\";\n location.reload();\n }\n } catch {\n if (!serverDown) {\n serverDown = true;\n const statusEl = document.getElementById(\"status-sync\");\n if (statusEl) {\n statusEl.textContent = \"SERVER OFFLINE\";\n statusEl.style.color = \"oklch(0.65 0.2 25)\";\n }\n }\n }\n}, 5000);\n\n// \u2500\u2500 Outbox queue indicator (status-queue span) \u2500\u2500\n// Event-driven in IPC mode (service pushes outboxStatus on every mutation).\n// Plus a 15s poll safety net for both modes so a missed event doesn't leave\n// the user staring at stale numbers. Idempotent \u2014 renderOutboxStatus just\n// overwrites the text.\nfunction renderOutboxStatus(s: any): void {\n // Feed the folder-tree synthesized \"Send-pending\" row. Idempotent \u2014\n // it no-ops when the presence state and count haven't changed.\n setOutboxTotal(s?.total || 0);\n const el = document.getElementById(\"status-queue\");\n if (!el) return;\n if (!s || !s.total || s.total === 0) {\n el.textContent = \"\";\n el.title = \"\";\n el.style.color = \"\";\n return;\n }\n const parts: string[] = [`\u2709 ${s.total} queued`];\n if (s.claimed > 0) parts.push(`${s.claimed} sending`);\n if (s.retrying > 0) parts.push(`${s.retrying} retrying (\u00D7${s.maxAttempts})`);\n if (s.oldestAgeSec >= 60) {\n const age = s.oldestAgeSec >= 3600\n ? `${Math.floor(s.oldestAgeSec / 3600)}h`\n : `${Math.floor(s.oldestAgeSec / 60)}m`;\n parts.push(`oldest ${age}`);\n }\n el.textContent = parts.join(\" \u00B7 \");\n const perAcct = s.perAccount || {};\n const detail = Object.keys(perAcct).sort().map(a =>\n `${a}: ${perAcct[a].total} total, ${perAcct[a].claimed} sending, ${perAcct[a].retrying} retrying`\n ).join(\"\\n\");\n el.title = detail || \"\";\n // Orange when retrying, red when stuck >5min, else muted.\n el.style.color = s.oldestAgeSec > 300 ? \"oklch(0.65 0.2 25)\"\n : s.retrying > 0 ? \"oklch(0.75 0.15 60)\"\n : \"\";\n}\n\nsetInterval(async () => {\n try {\n const { getOutboxStatus, getDiagnostics } = await import(\"./lib/api-client.js\");\n // Run in parallel \u2014 neither call depends on the other and each is a\n // separate IPC round-trip. Earlier code awaited them serially.\n const [outbox, diag] = await Promise.all([getOutboxStatus(), getDiagnostics()]);\n renderOutboxStatus(outbox);\n renderDiagnosticsBadge(diag);\n } catch { /* service unreachable */ }\n}, 15000);\n// First read on startup so the bar isn't blank.\n(async () => {\n try {\n const { getOutboxStatus, getDiagnostics } = await import(\"./lib/api-client.js\");\n const [outbox, diag] = await Promise.all([getOutboxStatus(), getDiagnostics()]);\n renderOutboxStatus(outbox);\n renderDiagnosticsBadge(diag);\n } catch { /* */ }\n})();\n\n/** Render the \u26A0 \"something's wrong\" badge next to status-sync. Shown when\n * any account has non-zero diagnostic counters (inactivity timeouts,\n * connection-cap hits, rate-limit waits). Tooltip breaks down per-account. */\nfunction renderDiagnosticsBadge(snapshot: any[]): void {\n const host = document.getElementById(\"status-diag\");\n if (!host) return;\n const issues = (snapshot || []).filter(d => d.inactivityTimeouts > 0 || d.connCapHits > 0 || d.rateLimitWaits > 0);\n if (issues.length === 0) {\n host.hidden = true;\n host.textContent = \"\";\n host.title = \"\";\n return;\n }\n host.hidden = false;\n host.textContent = \"\u26A0\";\n const totalTimeouts = issues.reduce((a, d) => a + d.inactivityTimeouts, 0);\n const totalCapHits = issues.reduce((a, d) => a + d.connCapHits, 0);\n const totalRateLimits = issues.reduce((a, d) => a + d.rateLimitWaits, 0);\n const summary = [\n totalTimeouts > 0 ? `${totalTimeouts} IMAP inactivity timeout${totalTimeouts === 1 ? \"\" : \"s\"}` : null,\n totalCapHits > 0 ? `${totalCapHits} conn-cap rejection${totalCapHits === 1 ? \"\" : \"s\"}` : null,\n totalRateLimits > 0 ? `${totalRateLimits} rate-limit wait${totalRateLimits === 1 ? \"\" : \"s\"}` : null,\n ].filter(Boolean).join(\"; \");\n const detail = issues.map(d => {\n const parts = [\n d.inactivityTimeouts > 0 ? `${d.inactivityTimeouts} timeout${d.inactivityTimeouts === 1 ? \"\" : \"s\"}` : null,\n d.connCapHits > 0 ? `${d.connCapHits} conn-cap` : null,\n d.rateLimitWaits > 0 ? `${d.rateLimitWaits} rate-limit` : null,\n ].filter(Boolean).join(\", \");\n const last = d.lastCommand ? `\\n last: ${d.lastCommand}` : \"\";\n return `${d.accountId}: ${parts}${last}`;\n }).join(\"\\n\");\n host.title = `Connection issues \u2014 ${summary}\\n\\n${detail}`;\n}\n// Q64: pop-out a message into a floating overlay (real-OS-window pending C44).\n// Action buttons on the message pop-out window \u2014 Reply / Reply All / Forward\n// act on the popped-out message specifically (not the main viewer's), Delete\n// removes it. The pop-out lives in message-viewer.ts and can't import\n// openCompose directly, so it fires this event.\ndocument.addEventListener(\"mailx-popout-action\", ((e: any) => {\n const { action, msg, accountId } = e.detail || {};\n if (!msg) return;\n if (action === \"reply\" || action === \"replyAll\" || action === \"forward\") {\n openCompose(action as ComposeMode, msg, accountId);\n } else if (action === \"delete\") {\n deleteMessage(accountId, msg.uid).catch((err: any) =>\n console.error(`[popout] delete failed: ${err?.message || err}`));\n }\n}) as EventListener);\n\ndocument.addEventListener(\"mailx-popout-message\", (async (e: any) => {\n const { accountId, uid, folderId, subject } = e.detail || {};\n if (!accountId || !uid) return;\n const { getMessage } = await import(\"./lib/api-client.js\");\n let msg: any;\n try {\n msg = await getMessage(accountId, uid, false, folderId);\n } catch (err: any) {\n alert(`Couldn't load message: ${err?.message || err}`);\n return;\n }\n // Drafts pop out into a COMPOSE window, not a read-only viewer popout.\n // Bob 2026-05-12: \"popping out a draft should default to that\" (edit\n // mode). Flag detection: server-side \\Draft flag, or the message is\n // currently sitting in the user's Drafts folder. The read-only popout\n // surface is missing every action button (Reply, Forward, Edit Draft,\n // \u2026) so dumping a draft into it is a worst-case dead-end.\n const isDraft = !!msg && draftOf(msg);\n if (isDraft) {\n const accts = await getAccounts();\n const init = {\n mode: \"draft\",\n accountId,\n to: msg.to || [],\n cc: msg.cc || [],\n subject: msg.subject || subject || \"\",\n bodyHtml: msg.bodyHtml || \"\",\n inReplyTo: msg.inReplyTo || \"\",\n references: msg.references || [],\n accounts: accts.map((a: any) => ({ id: a.id, name: a.name, email: a.email, signature: a.signature, sig: a.sig })),\n draftUid: msg.uid,\n draftFolderId: msg.folderId,\n };\n sessionStorage.setItem(\"composeInit\", JSON.stringify(init));\n showComposeOverlay(msg.subject ? `Edit: ${msg.subject}` : \"Edit draft\");\n return;\n }\n const wrapper = document.createElement(\"div\");\n wrapper.className = \"popout-overlay\";\n wrapper.style.cssText = \"position:fixed;top:5vh;right:5vw;width:min(900px,60vw);height:min(800px,80vh);z-index:1500;background:var(--color-bg);border:1px solid var(--color-border);border-radius:6px;box-shadow:0 8px 32px rgba(0,0,0,0.3);display:flex;flex-direction:column;resize:both;overflow:hidden;\";\n const header = document.createElement(\"div\");\n header.style.cssText = \"display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--color-bg-surface);border-bottom:1px solid var(--color-border);font-weight:600;cursor:move;\";\n const title = document.createElement(\"span\");\n title.textContent = subject || \"(no subject)\";\n title.style.cssText = \"flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;\";\n const closeBtn = document.createElement(\"button\");\n closeBtn.textContent = \"\u00D7\";\n closeBtn.style.cssText = \"background:none;border:none;font-size:1.4rem;cursor:pointer;padding:0 8px;\";\n closeBtn.addEventListener(\"click\", () => wrapper.remove());\n header.appendChild(title);\n header.appendChild(closeBtn);\n const meta = document.createElement(\"div\");\n meta.style.cssText = \"padding:8px 12px;border-bottom:1px solid var(--color-border);font-size:0.9rem;color:var(--color-text-muted);\";\n meta.innerHTML = `<div><b>From:</b> ${escapeHtmlBasic(msg.from?.name || \"\")} <${escapeHtmlBasic(msg.from?.address || \"\")}></div>\n <div><b>To:</b> ${(msg.to || []).map((a: any) => escapeHtmlBasic(`${a.name||\"\"} <${a.address}>`)).join(\", \")}</div>\n ${msg.cc?.length ? `<div><b>Cc:</b> ${msg.cc.map((a:any) => escapeHtmlBasic(`${a.name||\"\"} <${a.address}>`)).join(\", \")}</div>` : \"\"}\n <div><b>Date:</b> ${new Date(msg.date).toLocaleString()}</div>`;\n const body = document.createElement(\"iframe\");\n body.style.cssText = \"flex:1;border:none;width:100%;background:#fff;\";\n body.sandbox.add(\"allow-same-origin\");\n wrapper.appendChild(header);\n wrapper.appendChild(meta);\n wrapper.appendChild(body);\n document.body.appendChild(wrapper);\n // Use the same wrapHtmlBody styling as the inline preview pane so the\n // popout's typography matches the preview's typography. Earlier the\n // popout fed bodyHtml DIRECTLY into srcdoc with no CSS wrapping, so\n // the browser used its default font (Times serif) while the preview\n // pane used system-ui sans-serif (Bob 2026-05-12: \"double clicking on\n // the summary shows in a different font than in preview\").\n body.srcdoc = msg.bodyHtml\n ? wrapHtmlBody(msg.bodyHtml, !!msg.remoteAllowed)\n : `<!DOCTYPE html><html><head><style>body{font-family:system-ui,sans-serif;font-size:17.5px;line-height:1.5;color:#1a1a2e;padding:1rem;margin:0;}pre{white-space:pre-wrap;word-break:break-word;font-family:inherit;font-size:inherit;margin:0;}</style></head><body><pre>${escapeHtmlBasic(msg.bodyText || \"(no body)\")}</pre></body></html>`;\n // Drag-to-move.\n let dragX = 0, dragY = 0, dragging = false;\n header.addEventListener(\"mousedown\", (de: MouseEvent) => {\n if ((de.target as HTMLElement).tagName === \"BUTTON\") return;\n dragging = true;\n const rect = wrapper.getBoundingClientRect();\n dragX = de.clientX - rect.left;\n dragY = de.clientY - rect.top;\n de.preventDefault();\n });\n document.addEventListener(\"mousemove\", (de) => {\n if (!dragging) return;\n wrapper.style.left = `${de.clientX - dragX}px`;\n wrapper.style.top = `${de.clientY - dragY}px`;\n wrapper.style.right = \"auto\";\n });\n document.addEventListener(\"mouseup\", () => { dragging = false; });\n}) as EventListener);\nfunction escapeHtmlBasic(s: string): string {\n return (s || \"\").replace(/[&<>\"']/g, c => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", \"\\\"\": \""\", \"'\": \"'\" }[c]!));\n}\n\n// Click the status-queue pill to open the outbox view (pink-row list).\ndocument.getElementById(\"status-queue\")?.addEventListener(\"click\", async () => {\n try {\n const { openOutboxView } = await import(\"./components/outbox-view.js\");\n openOutboxView();\n } catch (e: any) {\n console.error(\"Outbox view failed:\", e);\n }\n});\n// Make it look clickable.\n(() => {\n const el = document.getElementById(\"status-queue\");\n if (el) { el.style.cursor = \"pointer\"; el.title = \"Click to view queued messages\"; }\n})();\n\nconsole.log(\"mailx client initialized, location:\", location.href);\nupdateNewMessageCount();\n\n// Offline indicator \u2014 show/hide based on navigator.onLine. Doesn't gate any\n// functionality (the store is local-first; edits queue and replay on\n// reconnect regardless) but tells the user their queued actions are stacking\n// up for a later push rather than hitting the server now.\nconst offlineEl = document.getElementById(\"status-offline\");\nfunction refreshOfflineIndicator(): void {\n if (!offlineEl) return;\n offlineEl.hidden = navigator.onLine;\n}\nwindow.addEventListener(\"online\", refreshOfflineIndicator);\nwindow.addEventListener(\"offline\", refreshOfflineIndicator);\nrefreshOfflineIndicator();\n\n// \u2500\u2500 Midnight refresh \u2014 update date display when day changes \u2500\u2500\nfunction scheduleMiddnightRefresh(): void {\n const now = new Date();\n const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);\n const ms = midnight.getTime() - now.getTime();\n setTimeout(() => {\n reloadCurrentFolder();\n scheduleMiddnightRefresh();\n }, ms + 1000); // 1s after midnight\n}\nscheduleMiddnightRefresh();\n\n// \u2500\u2500 Apply theme from settings \u2500\u2500\nversionPromise.then((d: any) => {\n if (d.theme === \"dark\") document.documentElement.classList.add(\"theme-dark\");\n else if (d.theme === \"light\") document.documentElement.classList.add(\"theme-light\");\n}).catch(() => {});\n\n// \u2500\u2500 Save window geometry on close (IPC mode only) \u2500\u2500\n// Sends window position and size so the next launch restores them.\nif (isApp) {\n const ipcApi = (window as unknown as Record<string, unknown>).mailxapi as\n { saveWindowGeometry?: (g: { x: number; y: number; width: number; height: number }) => Promise<unknown> } | undefined;\n\n function sendGeometry(): void {\n if (!ipcApi?.saveWindowGeometry) return;\n ipcApi.saveWindowGeometry({\n x: window.screenX,\n y: window.screenY,\n width: window.outerWidth,\n height: window.outerHeight,\n }).catch(() => { /* fire-and-forget */ });\n }\n\n // Save on unload (window close) and periodically as a safety net\n window.addEventListener(\"beforeunload\", sendGeometry);\n setInterval(sendGeometry, 60_000);\n}\n\n// Boot-snapshot writer \u2014 captures key DOM regions to localStorage so the\n// next cold start hydrates instantly (see index.html's hydrateFromBootSnapshot).\n// Saves every 30 s and on beforeunload; pages with no inbox open just save\n// empty strings, which the loader treats as \"skip this region.\"\nfunction saveBootSnapshot(): void {\n try {\n const folderTree = document.getElementById(\"folder-tree\");\n const messageList = document.getElementById(\"ml-body\");\n const folderTitle = document.getElementById(\"ml-folder-title\");\n // Only save when at least one region has content. An in-flight render\n // (e.g. user clicked a folder, list is empty for a tick) would\n // otherwise clobber the previous good snapshot.\n const ftHtml = folderTree?.innerHTML?.trim() || \"\";\n const mlHtml = messageList?.innerHTML?.trim() || \"\";\n if (!ftHtml && !mlHtml) return;\n const snap = {\n folderTree: ftHtml,\n messageList: mlHtml,\n folderTitle: folderTitle?.innerHTML?.trim() || \"\",\n savedAt: Date.now(),\n };\n localStorage.setItem(\"mailx-boot-snapshot\", JSON.stringify(snap));\n } catch { /* best-effort */ }\n}\nwindow.addEventListener(\"beforeunload\", saveBootSnapshot);\n// Also save when the user tabs away \u2014 a Windows kill of an inactive\n// app would otherwise lose the snapshot up to 30 s old.\ndocument.addEventListener(\"visibilitychange\", () => {\n if (document.visibilityState === \"hidden\") saveBootSnapshot();\n});\nsetInterval(saveBootSnapshot, 30_000);\n"],
|
|
4
|
+
"sourcesContent": ["/**\n * API client \u2014 all operations go through the IPC bridge (mailxapi).\n * mailxapi is injected by the launcher (msger on desktop, MAUI on Android).\n */\n\ndeclare const mailxapi: any;\n\nfunction getIpc(): any {\n if (typeof mailxapi !== \"undefined\" && mailxapi?.isApp) return mailxapi;\n if ((window as any).opener?.mailxapi?.isApp) return (window as any).opener.mailxapi;\n // Compose iframe \u2014 check parent\n if ((window as any).parent?.mailxapi?.isApp) return (window as any).parent.mailxapi;\n return null;\n}\n\n/** Build a proxy bridge that forwards every method call to the parent window\n * via postMessage. Used when the compose iframe's own attempt to reach\n * msger's IPC silently drops messages \u2014 empirically, `sendMessage` and\n * `saveDraft` both hit this (user-visible: \"Sending\u2026\" spinner forever;\n * \"Draft save failed: mailxapi timeout\"). The main window's bridge is\n * provably fine, so the iframe routes through it. */\nfunction buildRelayBridge(): any {\n const pending = new Map<string, { resolve: (v: any) => void; reject: (e: any) => void; timer: any }>();\n window.addEventListener(\"message\", (ev: MessageEvent) => {\n if (!ev.data || ev.data.type !== \"mailx-ipc-result\" || !ev.data.id) return;\n const entry = pending.get(ev.data.id);\n if (!entry) return;\n pending.delete(ev.data.id);\n clearTimeout(entry.timer);\n if (ev.data.ok) entry.resolve(ev.data.result);\n else entry.reject(new Error(ev.data.error || \"parent-relay ipc error\"));\n });\n const call = (method: string, args: any[]): Promise<any> => {\n const id = `ipc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;\n return new Promise((resolve, reject) => {\n const timer = setTimeout(() => {\n pending.delete(id);\n reject(new Error(`parent-relay timeout: ${method}`));\n }, 120000);\n pending.set(id, { resolve, reject, timer });\n try {\n (window.parent as any).postMessage({ type: \"mailx-ipc\", id, method, args }, \"*\");\n } catch (e) {\n clearTimeout(timer);\n pending.delete(id);\n reject(e);\n }\n });\n };\n // Proxy: any property access returns a function that forwards to parent.\n // `isApp` / `platform` / other non-function reads return sensible defaults\n // so existing getIpc-style checks still work.\n return new Proxy({}, {\n get(_t, prop: string) {\n if (prop === \"isApp\") return true;\n if (prop === \"platform\") return (window.parent as any)?.mailxapi?.platform || \"webview2\";\n if (prop === \"onEvent\") {\n // Event subscription can't be relayed simply \u2014 iframes that need\n // events are rare. Fall back to direct parent bridge for onEvent\n // since the subscription path doesn't hit the broken send path.\n return (handler: any) => (window.parent as any)?.mailxapi?.onEvent?.(handler);\n }\n return (...args: any[]) => call(prop, args);\n }\n });\n}\n\nlet cachedRelayBridge: any = null;\nfunction ipc(): any {\n // Direct bridge is fine for the top window (main mailx app). The iframe\n // (compose) can't trust its own bridge resolution because msger-routed\n // sendMessage / saveDraft IPCs disappear without trace. So when we're in\n // a child frame with a parent bridge, go through the parent.\n const inIframe = window.parent && window.parent !== window;\n if (inIframe && (window.parent as any)?.mailxapi?.isApp) {\n if (!cachedRelayBridge) cachedRelayBridge = buildRelayBridge();\n return cachedRelayBridge;\n }\n const bridge = getIpc();\n if (!bridge) throw new Error(\"IPC bridge not available\");\n return bridge;\n}\n\n// \u2500\u2500 Abort controller for message-list requests \u2500\u2500\n\nlet messageListAbort: AbortController | null = null;\n\nexport function abortMessageListRequests(): void {\n if (messageListAbort) {\n messageListAbort.abort();\n messageListAbort = null;\n }\n}\n\n// \u2500\u2500 API Methods \u2500\u2500\n\nexport function getAccounts() {\n return ipc().getAccounts();\n}\n\nexport function getFolders(accountId: string) {\n return ipc().getFolders(accountId);\n}\n\nexport function getMessages(accountId: string, folderId: number, page = 1, pageSize = 50, flaggedOnly = false, sort?: string, sortDir?: string) {\n abortMessageListRequests();\n return ipc().getMessages(accountId, folderId, page, pageSize, sort, sortDir, undefined, flaggedOnly);\n}\n\nexport function getUnifiedInbox(page = 1, pageSize = 50) {\n abortMessageListRequests();\n return ipc().getUnifiedInbox(page, pageSize);\n}\n\nexport function searchMessages(query: string, page = 1, pageSize = 50, scope = \"all\", accountId = \"\", folderId = 0, includeTrashSpam = false) {\n return ipc().searchMessages(query, page, pageSize, scope, accountId, folderId, includeTrashSpam);\n}\n\n/** Abort any in-flight server (IMAP) search. Fire-and-forget \u2014 the daemon\n * bumps a generation counter its per-folder loop checks between batches. */\nexport function cancelServerSearch() {\n return ipc().cancelServerSearch?.();\n}\n\nexport function getMessage(accountId: string, uid: number, allowRemote = false, folderId?: number) {\n return ipc().getMessage(accountId, uid, allowRemote, folderId);\n}\n\nexport function updateFlags(accountId: string, uid: number, flags: string[]) {\n return ipc().updateFlags(accountId, uid, flags);\n}\n\nexport function triggerSync() {\n return ipc().syncAll();\n}\n\nexport function syncAccount(accountId: string) {\n return ipc().syncAccount(accountId);\n}\n\nexport function reauthenticate(accountId: string) {\n return ipc().reauthenticate(accountId);\n}\n\nexport function reauthGoogleScopes(): Promise<{ cleared: number }> {\n return (ipc() as any).reauthGoogleScopes();\n}\n\nexport function getSyncPending() {\n return ipc().getSyncPending();\n}\n\nexport function getDiagnostics(): Promise<any> {\n return ipc().getDiagnostics?.() ?? Promise.resolve([]);\n}\n\n/** Account that supplies `feature` data (calendar / tasks / contacts).\n * Resolution: per-feature primary flag \u2192 catch-all `primary` \u2192 first account.\n * Pass e.g. \"calendar\" to honor `primaryCalendar:true` overrides; omit for\n * back-compat single-flag behavior. */\nexport function getPrimaryAccount(feature?: string): Promise<any> {\n return ipc().getPrimaryAccount?.(feature) ?? Promise.resolve(null);\n}\n\n// Calendar / Tasks: two-way cache. Reads return local-cached rows; writes\n// commit locally and queue a push to Google. Service layer handles drain.\nexport function getCalendarEvents(fromMs: number, toMs: number): Promise<any[]> {\n return ipc().getCalendarEvents?.(fromMs, toMs) ?? Promise.resolve([]);\n}\n/** List the user's selected Google calendars (id, name, color, primary) so\n * the sidebar can render a checkbox + icon per calendar. */\nexport function getCalendars(): Promise<Array<{ id: string; name: string; color: string; primary: boolean }>> {\n return ipc().getCalendars?.() ?? Promise.resolve([]);\n}\nexport function createCalendarEvent(ev: {\n title: string; startMs: number; endMs: number; allDay?: boolean;\n location?: string; notes?: string;\n}): Promise<{ uuid: string }> {\n return ipc().createCalendarEvent?.(ev);\n}\nexport function updateCalendarEvent(uuid: string, patch: any): Promise<{ ok: boolean }> {\n return ipc().updateCalendarEvent?.(uuid, patch);\n}\nexport function deleteCalendarEvent(uuid: string): Promise<{ ok: boolean }> {\n return ipc().deleteCalendarEvent?.(uuid);\n}\nexport function getTasks(includeCompleted = false): Promise<any[]> {\n return ipc().getTasks?.(includeCompleted) ?? Promise.resolve([]);\n}\nexport function createTask(t: { title: string; notes?: string; dueMs?: number }): Promise<{ uuid: string }> {\n return ipc().createTask?.(t);\n}\nexport function updateTask(uuid: string, patch: any): Promise<{ ok: boolean }> {\n return ipc().updateTask?.(uuid, patch);\n}\nexport function deleteTask(uuid: string): Promise<{ ok: boolean }> {\n return ipc().deleteTask?.(uuid);\n}\nexport function drainStoreSync(): Promise<{ ok: boolean }> {\n return ipc().drainStoreSync?.();\n}\n\n/** Report the currently-viewed message as spam \u2192 appends a row to\n * `~/.mailx/spam.csv`. Placeholder: no folder move, no flag change, no\n * auto-delete. Training data for a smarter pass later. */\nexport function recordSpamReport(accountId: string, uid: number, folderId: number): Promise<{ ok: boolean }> {\n return ipc().recordSpamReport?.(accountId, uid, folderId);\n}\n\nexport function getOutboxStatus() {\n return (ipc() as any).getOutboxStatus();\n}\n\nexport function listQueuedOutgoing() {\n return (ipc() as any).listQueuedOutgoing();\n}\n\nexport function cancelQueuedOutgoing(p: string) {\n return (ipc() as any).cancelQueuedOutgoing(p);\n}\n\nexport function searchContacts(query: string) {\n return ipc().searchContacts(query);\n}\n\nexport function hasCcHistoryTo(email: string): Promise<{ hasCc: boolean }> {\n return (ipc() as any).hasCcHistoryTo(email);\n}\n\nexport function hasBccHistoryTo(email: string): Promise<{ hasBcc: boolean }> {\n return (ipc() as any).hasBccHistoryTo(email);\n}\n\nexport function listContacts(query: string, page = 1, pageSize = 100) {\n return (ipc() as any).listContacts(query, page, pageSize);\n}\n\nexport function upsertContact(name: string, email: string) {\n return (ipc() as any).upsertContact(name, email);\n}\n\nexport function deleteContact(email: string) {\n return (ipc() as any).deleteContact(email);\n}\n\nexport function addPreferredContact(entry: { name: string; email: string; source?: string; organization?: string }) {\n return (ipc() as any).addPreferredContact(entry.name, entry.email, entry.source, entry.organization);\n}\n\nexport function getPriorityLists(): Promise<{ senders: string[]; domains: string[] }> {\n return (ipc() as any).getPriorityLists();\n}\n\nexport function setPrioritySender(email: string, value: boolean, name?: string): Promise<{ ok: boolean }> {\n return (ipc() as any).setPrioritySender(email, value, name);\n}\n\nexport function setPriorityDomain(domain: string, value: boolean): Promise<{ ok: boolean }> {\n return (ipc() as any).setPriorityDomain(domain, value);\n}\n\nexport function addToDenylist(email: string) {\n return (ipc() as any).addToDenylist(email);\n}\n\nexport function openLocalPath(which: \"config\" | \"log\") {\n return (ipc() as any).openLocalPath(which);\n}\n/** Open an absolute file path (under ~/.rmfmail) in the OS default\n * *text* editor \u2014 Notepad on Windows, TextEdit on Mac, $EDITOR or\n * xdg-open(.txt) on Linux. Distinct from the file's default app,\n * which for .eml is usually Outlook / a mail client. */\nexport function openInTextEditor(path: string): Promise<{ ok: boolean; opener: string; reason?: string }> {\n return (ipc() as any).openInTextEditor?.(path) ?? Promise.resolve({ ok: false, opener: \"none\", reason: \"no host\" });\n}\n\nexport function allowRemoteContent(type: string, value: string) {\n return ipc().allowRemoteContent(type, value);\n}\nexport function getUserDict(): Promise<string[]> {\n return (ipc() as any).getUserDict?.() ?? Promise.resolve([]);\n}\nexport function addUserDictWord(word: string): Promise<string[]> {\n return (ipc() as any).addUserDictWord?.(word) ?? Promise.resolve([]);\n}\nexport function addUserDictWords(words: string[]): Promise<string[]> {\n return (ipc() as any).addUserDictWords?.(words) ?? Promise.resolve([]);\n}\nexport function removeUserDictWord(word: string): Promise<string[]> {\n return (ipc() as any).removeUserDictWord?.(word) ?? Promise.resolve([]);\n}\nexport function flagSenderOrDomain(type: \"sender\" | \"domain\", value: string): Promise<{ flagged: boolean }> {\n return (ipc() as any).flagSenderOrDomain?.(type, value) ?? Promise.resolve({ flagged: false });\n}\n\nexport function deleteMessage(accountId: string, uid: number) {\n return ipc().deleteMessage?.(accountId, uid);\n}\n\nexport function deleteMessages(accountId: string, uids: number[]) {\n if (uids.length === 1) return deleteMessage(accountId, uids[0]);\n return ipc().deleteMessages?.(accountId, uids);\n}\n\nexport function moveMessages(accountId: string, uids: number[], targetFolderId: number, targetAccountId?: string) {\n if (uids.length === 1) return moveMessage(accountId, uids[0], targetFolderId, targetAccountId);\n return ipc().moveMessages?.(accountId, uids, targetFolderId, targetAccountId);\n}\n\nexport function markAsSpamMessages(accountId: string, uids: number[]): Promise<{ targetFolderId: number; moved: number }> {\n return ipc().markAsSpamMessages?.(accountId, uids);\n}\n\nexport function undeleteMessage(accountId: string, uid: number, folderId: number) {\n return ipc().undeleteMessage?.(accountId, uid, folderId);\n}\n\nexport function moveMessage(accountId: string, uid: number, targetFolderId: number, targetAccountId?: string) {\n return ipc().moveMessage?.(accountId, uid, targetFolderId, targetAccountId);\n}\n\nexport function restartServer() {\n return ipc().restart?.();\n}\n\nexport function markFolderRead(accountId: string, folderId: number) {\n return ipc().markFolderRead?.(accountId, folderId);\n}\n\nexport function createFolder(accountId: string, parentPath: string, name: string) {\n return ipc().createFolder?.(accountId, parentPath, name);\n}\n\nexport function renameFolder(accountId: string, folderId: number, newName: string) {\n return ipc().renameFolder?.(accountId, folderId, newName);\n}\n\nexport function deleteFolder(accountId: string, folderId: number) {\n return ipc().deleteFolder?.(accountId, folderId);\n}\n\nexport function moveFolderToTrash(accountId: string, folderId: number) {\n return ipc().moveFolderToTrash?.(accountId, folderId);\n}\n\nexport function emptyFolder(accountId: string, folderId: number) {\n return ipc().emptyFolder?.(accountId, folderId);\n}\n\n/** Ship a named event to the Node log as `[client] <tag> <data>`. Fire and\n * forget \u2014 never awaits, never throws, never blocks the caller. Tries two\n * paths so a broken primary channel can't swallow the trace:\n * 1. Direct bridge call (self / opener / parent mailxapi).\n * 2. parent.postMessage fallback \u2014 the main window listens and relays.\n * The fallback matters because the whole point of tracing is to diagnose\n * a broken iframe bridge; a single-path tracer that goes through that same\n * bridge is useless in exactly the case we need it. */\nexport function logClientEvent(tag: string, data?: any): void {\n let delivered = false;\n try {\n const bridge = typeof (globalThis as any).mailxapi !== \"undefined\" && (globalThis as any).mailxapi?.isApp ? (globalThis as any).mailxapi\n : (window as any).opener?.mailxapi?.isApp ? (window as any).opener.mailxapi\n : (window as any).parent?.mailxapi?.isApp ? (window as any).parent.mailxapi\n : null;\n if (bridge?.logClientEvent) {\n bridge.logClientEvent(tag, data);\n delivered = true;\n }\n } catch { /* never throw from tracing */ }\n try {\n if (window.parent && window.parent !== window) {\n (window.parent as any).postMessage({ type: \"mailx-trace\", tag, data, bridged: delivered }, \"*\");\n }\n } catch { /* */ }\n}\n\nexport function sendMessage(body: any) {\n return ipc().sendMessage?.(body);\n}\n\nexport function saveDraft(body: any) {\n return ipc().saveDraft?.(body);\n}\n\n// \u2500\u2500 Events \u2500\u2500\n\ntype EventHandler = (event: any) => void;\nconst eventHandlers: EventHandler[] = [];\n\nexport function onEvent(handler: EventHandler): () => void {\n eventHandlers.push(handler);\n return () => {\n const i = eventHandlers.indexOf(handler);\n if (i >= 0) eventHandlers.splice(i, 1);\n };\n}\n\n// \u2500\u2500 Store-bus subscriptions (mirrors packages/mailx-store/bus.ts) \u2500\u2500\n//\n// The service forwards every storeBus event over IPC as `{ _event: \"store\",\n// topic, kind, ... }`. Mirror the topic/wildcard semantics here so consumers\n// subscribe by topic instead of filtering inside a single onEvent callback.\n// Same shape as the server-side bus \u2014 that's the load-bearing property.\n\ntype StoreEvent = { topic: string; kind: string; [k: string]: any };\ntype StoreHandler = (event: StoreEvent) => void;\nconst storeSubs = new Map<string, Set<StoreHandler>>();\n\nexport function subscribeStore(topic: string, handler: StoreHandler): () => void {\n let set = storeSubs.get(topic);\n if (!set) { set = new Set(); storeSubs.set(topic, set); }\n set.add(handler);\n return () => {\n const s = storeSubs.get(topic);\n if (s) { s.delete(handler); if (s.size === 0) storeSubs.delete(topic); }\n };\n}\n\nfunction deliverStore(event: StoreEvent): void {\n const exact = storeSubs.get(event.topic);\n if (exact) for (const h of exact) { try { h(event); } catch (e) { console.error(\"[store-bus]\", e); } }\n const wild = storeSubs.get(\"*\");\n if (wild) for (const h of wild) { try { h(event); } catch (e) { console.error(\"[store-bus]\", e); } }\n}\n\nexport function connectEvents(): void {\n ipc().onEvent((event: any) => {\n if (event && event._event === \"store\") deliverStore(event as StoreEvent);\n for (const h of eventHandlers) h(event);\n });\n}\n\n// \u2500\u2500 Autocomplete \u2500\u2500\n\nexport function autocomplete(body: { subject: string; to: string; bodyText: string; cursorOffset: number }, signal?: AbortSignal) {\n return ipc().autocomplete?.(body);\n}\n\nexport function getAutocompleteSettings() {\n return ipc().getAutocompleteSettings?.();\n}\n\nexport function saveAutocompleteSettings(settings: any) {\n return ipc().saveAutocompleteSettings?.(settings);\n}\n\nexport function getVersion() {\n return ipc().getVersion();\n}\n\nexport function getSettings() {\n return ipc().getSettings();\n}\n\nexport function saveSettings(settings: any) {\n return ipc().saveSettingsData?.(settings);\n}\n\nexport function repairAccounts() {\n return ipc().repairAccounts?.();\n}\n\nexport function deleteDraft(accountId: string, draftUid: number, draftId?: string) {\n return ipc().deleteDraft?.(accountId, draftUid, draftId);\n}\n\nexport function addContact(name: string, email: string) {\n return ipc().addContact?.(name, email);\n}\n\nexport function getThreadMessages(accountId: string, threadId: string) {\n return ipc().getThreadMessages?.(accountId, threadId);\n}\n\nexport function readJsoncFile(name: string): Promise<{ content: string | null }> {\n return ipc().readJsoncFile?.(name);\n}\nexport function writeJsoncFile(name: string, content: string): Promise<{ ok: boolean }> {\n return ipc().writeJsoncFile?.(name, content);\n}\nexport function formatJsonc(content: string): Promise<{ content: string }> {\n return (ipc() as any).formatJsonc?.(content);\n}\nexport function readConfigHelp(name: string): Promise<{ content: string }> {\n return ipc().readConfigHelp?.(name) ?? Promise.resolve({ content: \"\" });\n}\nexport function unsubscribeOneClick(url: string): Promise<{ ok: boolean; status: number; statusText: string }> {\n return ipc().unsubscribeOneClick?.(url);\n}\nexport function openInWord(editId: string, html: string): Promise<{ ok: boolean; path: string; opener: string }> {\n return (ipc() as any).openInWord?.(editId, html) ?? Promise.resolve({ ok: false, path: \"\", opener: \"none\" });\n}\nexport function closeWordEdit(editId: string): Promise<void> {\n return (ipc() as any).closeWordEdit?.(editId) ?? Promise.resolve();\n}\n\n/** Show an OS-level always-on-top reminder popup via msger. Returns the\n * label of the button the user clicked. Empty string when the host\n * isn't available (browser fallback) \u2014 caller should fall back to an\n * in-WebView popup in that case. */\nexport function showReminderPopup(opts: {\n title: string;\n html: string;\n buttons: string[];\n size?: { width: number; height: number };\n pos?: { x: number; y: number };\n}): Promise<{ button: string; form?: any; reason?: string }> {\n return (ipc() as any).showReminderPopup?.(opts) ?? Promise.resolve({ button: \"\", reason: \"no host\" });\n}\n\n/** Read + delete a pending mailto: drop file, if any (P115). Used at app\n * startup so a `mailx --mailto <url>` that just spawned us doesn't lose\n * its compose payload to the daemon-fires-before-app-registers race\n * window. Subsequent live clicks arrive via the `openMailto` event. */\nexport function consumePendingMailto(): Promise<{\n to: string[]; cc: string[]; bcc: string[];\n subject: string; body: string; inReplyTo: string;\n} | null> {\n return ipc().consumePendingMailto?.() ?? Promise.resolve(null);\n}\n\n/** Run an AI text transform (translate / proofread / summarize). Returns\n * empty `text` with a `reason` when the feature is disabled or the provider\n * errors \u2014 caller should surface `reason` in a status bar, not throw. */\nexport function aiTransform(req: {\n action: \"translate\" | \"proofread\" | \"summarize\" | \"extractEvent\";\n text: string;\n targetLang?: string;\n nowISO?: string;\n}): Promise<{ text: string; reason?: string; event?: any }> {\n return ipc().aiTransform?.(req) ?? Promise.resolve({ text: \"\", reason: \"AI not available in this host\" });\n}\n\nexport function setupAccount(name: string, email: string, password: string) {\n return ipc().setupAccount?.(name, email, password);\n}\n\nexport async function getAttachment(accountId: string, uid: number, attachmentId: number, folderId?: number): Promise<{ content: string; contentType: string; filename: string }> {\n return ipc().getAttachment(accountId, uid, attachmentId, folderId);\n}\n\n/** Desktop: have the Node service save the attachment and open it with the\n * OS default app. Returns undefined when the host has no such method (real\n * browser / --server mode) so the caller can fall back to a blob download. */\nexport async function openAttachment(accountId: string, uid: number, attachmentId: number, folderId?: number): Promise<{ ok: boolean; path: string } | undefined> {\n const fn = (ipc() as any).openAttachment;\n return fn ? fn(accountId, uid, attachmentId, folderId) : undefined;\n}\n\nexport async function getDeviceAccounts(): Promise<{ email: string; name: string }[]> {\n return ipc().getDeviceAccounts?.() ?? [];\n}\n\n// Legacy exports for backward compatibility\nexport const connectWebSocket = connectEvents;\nexport const onWsEvent = onEvent;\n", "/**\n * Simple context menu component.\n * Shows a menu at a given position with clickable items.\n */\n\nlet activeMenu: HTMLElement | null = null;\nlet dismissListener: ((e: Event) => void) | null = null;\nlet escapeListener: ((e: KeyboardEvent) => void) | null = null;\n\nexport interface MenuItem {\n label: string;\n action: () => void;\n disabled?: boolean;\n separator?: boolean;\n /** Native browser tooltip shown on hover (title attribute). Use it\n * to explain non-obvious side effects of an action \u2014 e.g., \"skips\n * Trash, no undo\" so the user can tell two near-identical entries\n * apart without trial-and-error. */\n tooltip?: string;\n}\n\n/** Close any open context menu and remove dismiss listeners */\nexport function closeContextMenu(): void {\n if (activeMenu) {\n activeMenu.remove();\n activeMenu = null;\n }\n if (dismissListener) {\n document.removeEventListener(\"pointerdown\", dismissListener, true);\n dismissListener = null;\n }\n if (escapeListener) {\n document.removeEventListener(\"keydown\", escapeListener, true);\n escapeListener = null;\n }\n}\n\n/** Show a context menu at the given position */\nexport function showContextMenu(x: number, y: number, items: MenuItem[]): void {\n closeContextMenu();\n\n const menu = document.createElement(\"div\");\n menu.className = \"ctx-menu\";\n\n for (const item of items) {\n if (item.separator) {\n const sep = document.createElement(\"div\");\n sep.className = \"ctx-sep\";\n menu.appendChild(sep);\n continue;\n }\n const el = document.createElement(\"div\");\n el.className = \"ctx-item\" + (item.disabled ? \" ctx-disabled\" : \"\");\n el.textContent = item.label;\n if (item.tooltip) el.title = item.tooltip;\n if (!item.disabled) {\n el.addEventListener(\"click\", () => {\n closeContextMenu();\n item.action();\n });\n }\n menu.appendChild(el);\n }\n\n menu.style.left = `${x}px`;\n menu.style.top = `${y}px`;\n document.body.appendChild(menu);\n\n // Adjust if menu goes off-screen\n const rect = menu.getBoundingClientRect();\n if (rect.right > window.innerWidth) menu.style.left = `${x - rect.width}px`;\n if (rect.bottom > window.innerHeight) menu.style.top = `${y - rect.height}px`;\n\n activeMenu = menu;\n\n // Dismiss on click/tap outside the menu. Uses pointerdown in capture phase\n // so it fires before any child handler and catches both left- and right-clicks.\n // Deferred by one frame so the opening pointerdown doesn't immediately close it.\n requestAnimationFrame(() => {\n dismissListener = (e: Event) => {\n if (activeMenu && !activeMenu.contains(e.target as Node)) {\n closeContextMenu();\n }\n };\n document.addEventListener(\"pointerdown\", dismissListener, true);\n\n escapeListener = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") {\n e.preventDefault();\n e.stopPropagation();\n closeContextMenu();\n }\n };\n document.addEventListener(\"keydown\", escapeListener, true);\n });\n}\n\n// Scroll anywhere closes the menu (capture phase so nested scrollers trigger it)\ndocument.addEventListener(\"scroll\", closeContextMenu, true);\n// A new right-click that opens a different menu goes through showContextMenu\u2192closeContextMenu\ndocument.addEventListener(\"contextmenu\", () => { /* handled by showContextMenu */ });\n// Iframe pointerdown forwarded from preview/compose iframes \u2014 needed because\n// iframe events don't bubble to the parent document, so the dismissListener\n// (which hooks document.pointerdown) doesn't see clicks INSIDE iframes. The\n// message-viewer's inline iframe script posts {type:\"iframePointerDown\"} on\n// every non-right-click pointerdown.\nwindow.addEventListener(\"message\", (e: MessageEvent) => {\n if (e.data && (e.data as any).type === \"iframePointerDown\") closeContextMenu();\n});\n", "/**\n * Shared message store \u2014 the messages currently rendered in the list.\n *\n * Selection state used to live here too, with a broadcast-subscribe model\n * that let the viewer subscribe to \"selected changed\" events independently\n * of the list. That made drift between the highlighted row and the preview\n * pane possible: two subscribers, two render paths, two opportunities for\n * a missed update or stale read. Selection is now owned by message-list\n * (the only thing that has DOM rows in the first place); the viewer is a\n * passive `show(msg)` / `clear()` API the list calls directly. With one\n * code path, drift is structurally impossible.\n *\n * What remains here: the array of currently-loaded messages, plus the\n * removeMessages helper that filters the array and lets the caller decide\n * which row to focus next (if any) \u2014 that policy is the list's, not the\n * store's.\n */\n\nexport interface ListMessage {\n accountId: string;\n uid: number;\n folderId: number;\n subject: string;\n from: { name: string; address: string };\n to: { name: string; address: string }[];\n cc: { name: string; address: string }[];\n date: number;\n flags: string[];\n size: number;\n preview: string;\n hasAttachments: boolean;\n [key: string]: any;\n}\n\nlet messages: ListMessage[] = [];\n\n/** Subscribers fire when the messages array is replaced or filtered.\n * Selection state is NOT broadcast \u2014 that lives in message-list and\n * reaches the viewer through a single direct call, never through this\n * fan-out. Subscribers here are observers only (status bar, thread\n * filter, etc.); they don't drive the preview pane. */\ntype MessagesListener = () => void;\nconst listeners: MessagesListener[] = [];\n\nexport function subscribe(fn: MessagesListener): () => void {\n listeners.push(fn);\n return () => {\n const i = listeners.indexOf(fn);\n if (i >= 0) listeners.splice(i, 1);\n };\n}\nfunction notify(): void {\n for (const fn of listeners) {\n try { fn(); } catch { /* don't let one subscriber break others */ }\n }\n}\n\n/** Replace the entire message list (folder load, search, unified inbox). */\nexport function setMessages(msgs: ListMessage[]): void {\n messages = msgs;\n notify();\n}\n\nexport function getMessages(): ListMessage[] {\n return messages;\n}\n\n/** Result of a removeMessages call: the list-controller uses this to\n * decide what to focus after a delete/move. */\nexport interface RemovalOutcome {\n /** True if any of the removed messages was previously focused \u2014 caller\n * must transition the focus (to nextSurvivor or clear the viewer). */\n focusedWasRemoved: boolean;\n /** Identity of the message that should become the new focus, or null\n * when the list is now empty / no survivor exists. The list looks up\n * its row by this identity and calls its own focus-row method. */\n nextSurvivor: { accountId: string; uid: number; folderId: number } | null;\n}\n\n/**\n * Remove messages from the list (after move or delete).\n *\n * Returns a RemovalOutcome the caller uses to drive focus handoff. We\n * pre-capture the next-survivor *by identity* (not by index) before the\n * filter mutates the array, so subsequent index shifts don't matter \u2014 if\n * every deleted row had a survivor below it, the chosen survivor is the\n * same regardless of how many got deleted. Falls back to walking\n * backward when the deletion is at the bottom.\n */\nexport function removeMessages(\n uids: { accountId: string; uid: number }[],\n currentlyFocused: { accountId: string; uid: number } | null,\n): RemovalOutcome {\n const removeSet = new Set(uids.map(u => `${u.accountId}:${u.uid}`));\n const focusedKey = currentlyFocused ? `${currentlyFocused.accountId}:${currentlyFocused.uid}` : null;\n const focusedWasRemoved = focusedKey !== null && removeSet.has(focusedKey);\n\n let nextSurvivor: ListMessage | null = null;\n if (focusedWasRemoved) {\n let lastRemovedIdx = -1;\n for (let i = 0; i < messages.length; i++) {\n if (removeSet.has(`${messages[i].accountId}:${messages[i].uid}`)) lastRemovedIdx = i;\n }\n for (let i = lastRemovedIdx + 1; i < messages.length; i++) {\n if (!removeSet.has(`${messages[i].accountId}:${messages[i].uid}`)) {\n nextSurvivor = messages[i];\n break;\n }\n }\n if (!nextSurvivor) {\n for (let i = lastRemovedIdx - 1; i >= 0; i--) {\n if (!removeSet.has(`${messages[i].accountId}:${messages[i].uid}`)) {\n nextSurvivor = messages[i];\n break;\n }\n }\n }\n }\n\n // dupeCount fix-up: if we removed one half of a unified-inbox duplicate\n // pair, the surviving row's \u21C6 marker should drop now rather than wait\n // for the next server fetch. Decrement once per remaining row whose\n // messageId matches a removed one.\n const removedIds = new Set<string>();\n for (const m of messages) {\n if (removeSet.has(`${m.accountId}:${m.uid}`) && m.messageId) {\n removedIds.add(m.messageId);\n }\n }\n messages = messages.filter(m => !removeSet.has(`${m.accountId}:${m.uid}`));\n if (removedIds.size > 0) {\n for (const m of messages) {\n if (m.messageId && removedIds.has(m.messageId) && typeof (m as any).dupeCount === \"number\") {\n (m as any).dupeCount = Math.max(0, (m as any).dupeCount - 1);\n }\n }\n }\n notify();\n\n return {\n focusedWasRemoved,\n nextSurvivor: nextSurvivor\n ? { accountId: nextSurvivor.accountId, uid: nextSurvivor.uid, folderId: nextSurvivor.folderId }\n : null,\n };\n}\n\n/** Update flags on a message in the list. List re-renders the row's\n * flag/unread classes inline; no broadcast. */\nexport function updateMessageFlags(accountId: string, uid: number, flags: string[]): void {\n const msg = messages.find(m => m.uid === uid && m.accountId === accountId);\n if (msg) msg.flags = flags;\n}\n\nimport { subscribeStore } from \"./api-client.js\";\n\n/** Subscribe to Store-bus flag changes so any source \u2014 UI click, server-side\n * sync, another window \u2014 updates this in-memory list. The IPC click\u2192render\n * path was already optimistic (caller patches `msg.flags` before awaiting),\n * so this listener is the redundancy that catches the cases where the local\n * patch and the canonical Store row diverge (server pushed a different flag\n * set during the round trip, sync rebound the row, etc.). */\nsubscribeStore(\"*\", (event) => {\n if (event.kind !== \"flagsChanged\") return;\n const accountId = event.accountId as string | undefined;\n const uid = event.uid as number | undefined;\n const flags = event.flags as string[] | undefined;\n if (!accountId || typeof uid !== \"number\" || !Array.isArray(flags)) return;\n const msg = messages.find(m => m.uid === uid && m.accountId === accountId);\n if (!msg) return;\n const before = JSON.stringify(msg.flags);\n msg.flags = flags;\n if (before !== JSON.stringify(flags)) notify();\n});\n", "// AUTO-GENERATED from contact-rules.jsonc \u2014 do not edit.\n// Edit the .jsonc and run `node build-rules.js` (or `npm run build`).\n\nexport const CONTACT_RULES = {\n \"rulesVersion\": \"v3-domain-oneoff\",\n \"junk\": {\n \"localExact\": \"^(no-?reply|do-?not-?reply|noreply|mailer-daemon|postmaster|abuse|automated|bounce(s|d)?|list-?(server|admin|owner|manager)?|notification|notifications?|admin@.*automated|root|daemon|nobody|undisclosed)$\",\n \"localSuffix\": \"(-bounces|\\\\+bounces|-noreply|-no-reply|-notifications?|-mailer)$\",\n \"localPrefix\": \"^(no-?reply|noreply|do-?not-?reply|donotreply|notifications?|alerts?|bounces?|mailer)[-_+]\",\n \"localOneoff\": \"^[0-9a-f]{4}\\\\.[0-9a-f]{4}(\\\\.[0-9a-z]{6})?$\",\n \"domain\": \"^(txt\\\\.voice\\\\.google\\\\.com|reply\\\\.facebook\\\\.com|reply\\\\.linkedin\\\\.com)$\"\n }\n} as const;\n", "/**\n * Shared `contacts.jsonc` mutations \u2014 denylist + preferred.\n *\n * Pure logic with cloud I/O injected, so the desktop service\n * (`mailx-service`, Node) and the Android service (`mailx-store-web`, in the\n * WebView) run ONE implementation instead of each keeping its own copy.\n * `web-service.ts` used to stub `addToDenylist` / `addPreferredContact` as\n * `notImpl()`, which is why the phone's \u2298 / \u2605 buttons did nothing.\n *\n * Nothing here is platform-specific: it's a `cloudRead` \u2192 mutate \u2192 `cloudWrite`\n * round-trip. Each caller passes its own platform's cloud functions.\n */\n\nexport type CloudReadFn = (filename: string) => Promise<string | null>;\n// Return type is intentionally loose \u2014 desktop `cloudWrite` resolves void,\n// the Android one resolves a boolean; the shared code ignores the result.\nexport type CloudWriteFn = (filename: string, content: string) => Promise<unknown>;\n\nexport interface PreferredContactEntry {\n name: string;\n email: string;\n source?: string;\n organization?: string;\n}\n\n/** Loose-parse a JSONC blob. The file mailx writes is plain JSON\n * (`JSON.stringify`), but a hand-edit may add `//` comments or trailing\n * commas \u2014 strip those and retry rather than losing the whole file. */\nfunction parseContactsConfig(raw: string | null): Record<string, unknown> {\n if (!raw) return {};\n try { return JSON.parse(raw) || {}; } catch { /* fall through to loose */ }\n try {\n const stripped = raw\n .replace(/^\\s*\\/\\/.*$/gm, \"\") // line comments\n .replace(/,(\\s*[}\\]])/g, \"$1\"); // trailing commas\n return JSON.parse(stripped) || {};\n } catch { return {}; }\n}\n\n/** Append an email to `contacts.jsonc#denylist[]` \u2014 idempotent and\n * case-insensitive. Writes only when the entry is genuinely new. */\nexport async function addContactsDenylistEntry(\n email: string, cloudRead: CloudReadFn, cloudWrite: CloudWriteFn,\n): Promise<void> {\n const lower = (email || \"\").trim().toLowerCase();\n if (!lower) return;\n const cfg = parseContactsConfig(await cloudRead(\"contacts.jsonc\")) as any;\n if (!Array.isArray(cfg.denylist)) cfg.denylist = [];\n if (cfg.denylist.some((e: any) => (e || \"\").toLowerCase() === lower)) return;\n cfg.denylist.push(email);\n await cloudWrite(\"contacts.jsonc\", JSON.stringify(cfg, null, 2));\n}\n\n/** Append a preferred contact to `contacts.jsonc#preferred[]` \u2014 dedup by\n * source|email|name. Writes only when the entry is genuinely new. */\nexport async function addContactsPreferredEntry(\n entry: PreferredContactEntry, cloudRead: CloudReadFn, cloudWrite: CloudWriteFn,\n): Promise<void> {\n if (!entry?.email) return;\n const cfg = parseContactsConfig(await cloudRead(\"contacts.jsonc\")) as any;\n if (!Array.isArray(cfg.preferred)) cfg.preferred = [];\n const key = (s: string, e: string, n: string): string =>\n `${(s || \"preferred\").toLowerCase()}|${(e || \"\").toLowerCase()}|${(n || \"\").toLowerCase()}`;\n const dupKey = key(entry.source || \"\", entry.email, entry.name || \"\");\n if (cfg.preferred.some((e: any) => key(e?.source || \"\", e?.email || \"\", e?.name || \"\") === dupKey)) return;\n const row: Record<string, string> = { name: entry.name || \"\", email: entry.email };\n if (entry.source) row.source = entry.source;\n if (entry.organization) row.organization = entry.organization;\n cfg.preferred.push(row);\n await cloudWrite(\"contacts.jsonc\", JSON.stringify(cfg, null, 2));\n}\n", "/**\n * Group expansion \u2014 turns a recipient string with group names into a flat\n * deduplicated address list. Shared between desktop (mailx-service) and\n * Android (mailx-store-web) so send paths produce the same result.\n *\n * Group source: contacts.jsonc \u2192 groups: Record<string, string[]>. Each\n * value is an array of either email addresses (\"a@b\" or \"Name <a@b>\") or\n * names of other groups (recursive aliasing). Cycles are detected; depth\n * is capped at 10 nested levels.\n */\n\n/** Raw recipient string from a To/Cc/Bcc field; may contain group names. */\nexport type RecipientToken = string;\n\n/** Map of group name \u2192 member list (each entry: address or another group name). */\nexport type GroupMap = Record<string, string[]>;\n\n/** Result of expanding a recipient string. */\nexport interface ExpansionResult {\n /** Flat unique address list (preserving order of first occurrence). */\n addresses: string[];\n /** Group names that couldn't be resolved (typo / cycle) \u2014 surface in UI. */\n unresolved: string[];\n}\n\n/** Split a comma-or-semicolon recipient string into tokens. Respects\n * angle-bracket address blocks (\"Name, Suffix <a@b>\") so a comma inside\n * the display name doesn't split the entry. */\nexport function splitRecipients(raw: string): RecipientToken[] {\n const out: string[] = [];\n let buf = \"\";\n let depth = 0;\n for (let i = 0; i < raw.length; i++) {\n const c = raw[i];\n if (c === \"<\") depth++;\n else if (c === \">\") depth = Math.max(0, depth - 1);\n if ((c === \",\" || c === \";\") && depth === 0) {\n const t = buf.trim();\n if (t) out.push(t);\n buf = \"\";\n } else {\n buf += c;\n }\n }\n const t = buf.trim();\n if (t) out.push(t);\n return out;\n}\n\n/** Quick test: does the token look like an email address (with or without\n * a display name)? Anything containing \"@\" with non-whitespace on both\n * sides counts. */\nexport function isAddressToken(token: string): boolean {\n return /^[^@\\s][^@]*@[^@\\s]+(\\s|$)|<[^>]+@[^>]+>/.test(token) || /^[^\\s<>@]+@[^\\s<>@]+$/.test(token);\n}\n\n/** Extract the bare email address from a token. Returns the original token\n * lowercased if it has no angle-bracket form. */\nexport function extractAddress(token: string): string {\n const m = token.match(/<([^>]+)>/);\n if (m) return m[1].trim().toLowerCase();\n return token.trim().toLowerCase();\n}\n\n/** Expand a recipient string by resolving group names against `groups`.\n * Group names take precedence: if a token matches a group name AND looks\n * like an address, it's treated as a group. (In practice no one names a\n * group \"x@y.com\", so this is rarely a real conflict.) */\nexport function expandRecipients(raw: string, groups: GroupMap): ExpansionResult {\n const tokens = splitRecipients(raw);\n const seen = new Set<string>();\n const addresses: string[] = [];\n const unresolved: string[] = [];\n\n const visit = (token: string, depth: number, visited: Set<string>): void => {\n if (depth > 10) {\n unresolved.push(`${token} (depth limit)`);\n return;\n }\n // Group name match \u2014 case-insensitive\n const groupKey = Object.keys(groups).find(k => k.toLowerCase() === token.trim().toLowerCase());\n if (groupKey) {\n if (visited.has(groupKey.toLowerCase())) {\n unresolved.push(`${groupKey} (cycle)`);\n return;\n }\n const next = new Set(visited);\n next.add(groupKey.toLowerCase());\n for (const member of groups[groupKey] || []) {\n visit(member, depth + 1, next);\n }\n return;\n }\n // Address \u2014 keep the original (with display name if present), but\n // dedupe by lowercased bare address.\n if (isAddressToken(token)) {\n const key = extractAddress(token);\n if (!seen.has(key)) {\n seen.add(key);\n addresses.push(token.trim());\n }\n return;\n }\n // Neither group nor address \u2014 flag.\n unresolved.push(token);\n };\n\n for (const t of tokens) {\n visit(t, 0, new Set());\n }\n return { addresses, unresolved };\n}\n", "/**\n * @bobfrankston/mailx-types\n * Shared type definitions for the mailx email client.\n * This is the contract between client and server.\n */\n\n// Generated rule data \u2014 both desktop store and Android store import via\n// this barrel so a single source-of-truth (contact-rules.jsonc) drives\n// junk-contact filtering on every platform.\nexport { CONTACT_RULES } from \"./contact-rules.js\";\nexport type { MailxApi } from \"./mailx-api.js\";\n// Shared contacts.jsonc mutations \u2014 one implementation for desktop + Android.\nexport {\n addContactsDenylistEntry, addContactsPreferredEntry,\n type PreferredContactEntry, type CloudReadFn, type CloudWriteFn,\n} from \"./contacts-config.js\";\n\n// Group-name expansion for recipient fields. Lets users type a group name\n// (e.g. \"family\") in To/Cc/Bcc and have it expand to the address list at\n// send time. Both desktop and Android send paths consume this expander\n// against contacts.jsonc \u2192 groups.\nexport {\n expandRecipients, splitRecipients, isAddressToken, extractAddress,\n} from \"./groups.js\";\nexport type { GroupMap, RecipientToken, ExpansionResult } from \"./groups.js\";\n\n// \u2500\u2500 Account Configuration \u2500\u2500\n\n/** Supported authentication methods */\nexport type AuthMethod = \"password\" | \"oauth2\";\n\n/** Mail account configuration */\nexport interface AccountConfig {\n id: string; /** Unique account identifier (e.g., \"iecc\", \"gmail-bob\") */\n name: string; /** Sender name for From header (e.g., \"Bob Frankston\") */\n label?: string; /** UI label for account list (e.g., \"Gmail\"). Falls back to name if not set */\n email: string; /** Email address */\n imap: {\n host: string;\n port: number;\n tls: boolean;\n auth: AuthMethod;\n user: string;\n password?: string; /** For password auth */\n oauthClientId?: string; /** For OAuth2 */\n };\n smtp: {\n host: string;\n port: number;\n tls: boolean;\n auth: AuthMethod;\n user: string;\n password?: string;\n };\n enabled: boolean;\n primary?: boolean; /** Catch-all \"this is my main account\" \u2014 default source for Calendar / Tasks / Contacts when no per-feature override set. */\n primaryCalendar?: boolean; /** Per-feature override: use this account's Google Calendar. Falls back to `primary` if unset. */\n primaryTasks?: boolean; /** Per-feature override: use this account's Google Tasks. Falls back to `primary` if unset. */\n primaryContacts?: boolean; /** Per-feature override: use this account's Google Contacts. Falls back to `primary` if unset. */\n defaultSend?: boolean; /** Use this account's SMTP when From doesn't match any account */\n syncContacts?: boolean; /** Sync contacts even when account is disabled (contacts-only Gmail) */\n relayDomains?: string[]; /** Domains to skip in Delivered-To chain (e.g., [\"relay.aaz.lt\"]) */\n identityDomains?: string[]; /** Domains where Delivered-To address should become the reply From (e.g., [\"bob.ma\", \"bobf.frankston.com\"]) */\n spam?: string; /** IMAP folder path for \"Mark as spam\" button (e.g., \"_spam\"). Button hidden when not set. */\n signature?: string; /** Legacy: HTML signature appended to all outgoing messages (new + reply + forward). Plain text or HTML allowed. Superseded by `sig`. */\n sig?: AccountSignature; /** Per-account signature object. Initially appended only to NEW messages; later options will cover replies/forwards. */\n}\n\n/** Signature configuration in accounts.jsonc. Initial shape carries `text`\n * only; `html: true` reserved for future support of raw HTML signatures. */\nexport interface AccountSignature {\n text: string; /** Plain-text signature body. Newlines preserved. Appended to NEW messages with the standard \"-- \" RFC 3676 separator. */\n html?: boolean; /** Future flag: when true, `text` is treated as raw HTML rather than escaped plain text. Currently ignored. */\n}\n\n// \u2500\u2500 Folder Types \u2500\u2500\n\n/** Standard IMAP special-use folder types */\nexport type SpecialUse = \"inbox\" | \"sent\" | \"drafts\" | \"trash\" | \"junk\" | \"archive\" | \"all\";\n\n/** A mailbox folder */\nexport interface Folder {\n id: number;\n accountId: string;\n path: string; /** IMAP path e.g., \"INBOX\", \"INBOX/Projects\" */\n name: string; /** Display name */\n specialUse: SpecialUse; /** Standard folder type, or null for custom */\n delimiter: string; /** IMAP hierarchy delimiter */\n totalCount: number;\n unreadCount: number;\n children: Folder[]; /** Nested subfolders */\n}\n\n// \u2500\u2500 Message flag state \u2500\u2500\n//\n// External API surface for the IMAP system flags. The literal strings\n// (`\"\\\\Seen\"`, `\"\\\\Flagged\"`, etc.) live ONLY inside this module \u2014 every\n// caller speaks in terms of named predicates (`seenOf(msg)`) and verbs\n// (`setSeen(msg, true)`, `toggleSeen(msg)`). That keeps a typo like\n// `\"\\Seen\"` (single backslash) from silently bypassing flag checks\n// elsewhere, and removes the \"two-API leak\" of having both `FLAG.SEEN`\n// constants AND verb helpers exposed simultaneously.\n//\n// These work against any object with a mutable `flags: string[]`\n// property \u2014 `MessageEnvelope`, `Message`, and the row's `msg` reference\n// all qualify. The verbs return `void` and mutate `msg.flags` in place\n// (replacing the array with a fresh one so listeners observing\n// reference identity, e.g. message-state, still re-render). Pure\n// predicate calls (`seenOf`, `flaggedOf`) don't mutate.\n\nconst _SEEN = \"\\\\Seen\";\nconst _FLAGGED = \"\\\\Flagged\";\nconst _ANSWERED = \"\\\\Answered\";\nconst _DRAFT = \"\\\\Draft\";\nconst _DELETED = \"\\\\Deleted\";\n\ninterface FlagBearing { flags: string[]; }\n\nfunction _has(msg: FlagBearing | { flags?: readonly string[] }, flag: string): boolean {\n return !!msg.flags && msg.flags.includes(flag);\n}\nfunction _set(msg: FlagBearing, flag: string, state: boolean): void {\n const present = (msg.flags || []).includes(flag);\n if (state === present) return;\n const next = state\n ? [...(msg.flags || []), flag]\n : (msg.flags || []).filter(f => f !== flag);\n msg.flags = next;\n}\n\nexport const seenOf = (m: { flags?: readonly string[] }): boolean => _has(m, _SEEN);\nexport const flaggedOf = (m: { flags?: readonly string[] }): boolean => _has(m, _FLAGGED);\nexport const answeredOf = (m: { flags?: readonly string[] }): boolean => _has(m, _ANSWERED);\nexport const draftOf = (m: { flags?: readonly string[] }): boolean => _has(m, _DRAFT);\nexport const deletedOf = (m: { flags?: readonly string[] }): boolean => _has(m, _DELETED);\n\nexport const setSeen = (m: FlagBearing, state: boolean): void => _set(m, _SEEN, state);\nexport const setFlagged = (m: FlagBearing, state: boolean): void => _set(m, _FLAGGED, state);\nexport const setAnswered = (m: FlagBearing, state: boolean): void => _set(m, _ANSWERED, state);\nexport const setDraft = (m: FlagBearing, state: boolean): void => _set(m, _DRAFT, state);\n\nexport const toggleSeen = (m: FlagBearing): void => _set(m, _SEEN, !_has(m, _SEEN));\nexport const toggleFlagged = (m: FlagBearing): void => _set(m, _FLAGGED, !_has(m, _FLAGGED));\n\n// \u2500\u2500 Message Types \u2500\u2500\n\n/** Email address with optional display name */\nexport interface EmailAddress {\n name: string; /** Display name, may be empty */\n address: string; /** Email address */\n}\n\n/** Message envelope (headers only, for list display) */\nexport interface MessageEnvelope {\n id: number; /** Local store ID */\n accountId: string;\n folderId: number;\n folderName?: string; /** Leaf folder name; populated by cross-folder search so the UI can tag each hit */\n uid: number; /** IMAP UID (server-side identity; changes on move, UIDVALIDITY bump) */\n uuid?: string; /** Stable local identity, minted once at first-sight; never changes */\n messageId: string; /** RFC Message-ID header */\n inReplyTo: string; /** For threading */\n references: string[]; /** For threading */\n threadId?: string; /** Computed thread id (root Message-ID of the conversation) */\n date: number; /** Epoch ms */\n subject: string;\n from: EmailAddress;\n to: EmailAddress[];\n cc: EmailAddress[];\n flags: string[]; /** IMAP flags: [\"\\\\Seen\", \"\\\\Flagged\"] */\n size: number;\n hasAttachments: boolean;\n isReplied?: boolean; /** Local DB knows the user (or anyone in this account) replied \u2014 derived from In-Reply-To linkage, independent of \\Answered */\n preview: string; /** First ~200 chars of body text */\n bodyPath?: string; /** Local body location: \"idb:...\" or \"gmail:<id>\" */\n providerId?: string; /** Native server id (Gmail hex id, Outlook Graph id) \u2014 bypasses UID\u2192id pagination on body fetch */\n pending?: boolean; /** True when a queued local action (move/flag/delete) hasn't been ACK'd by the server yet \u2014 UI renders pink */\n}\n\n/** Full message with body content */\nexport interface Message extends MessageEnvelope {\n bodyHtml: string; /** HTML body */\n bodyText: string; /** Plain text body */\n attachments: Attachment[];\n}\n\n/** File attachment metadata */\nexport interface Attachment {\n id: number;\n filename: string;\n mimeType: string;\n size: number;\n contentId: string; /** For inline attachments (cid:) */\n}\n\n// \u2500\u2500 API Types \u2500\u2500\n\n/** Paginated list response */\nexport interface PagedResult<T> {\n items: T[];\n total: number;\n page: number;\n pageSize: number;\n}\n\n/** Message list query parameters */\nexport interface MessageQuery {\n accountId: string;\n folderId: number;\n page?: number;\n pageSize?: number;\n sort?: \"date\" | \"from\" | \"subject\";\n sortDir?: \"asc\" | \"desc\";\n search?: string;\n /** Restrict to messages with the \\Flagged flag set (whole-folder, not\n * just the currently-rendered page \u2014 lets the \"show flagged\" filter\n * find stars on messages that haven't been paged in yet). */\n flaggedOnly?: boolean;\n}\n\n/** Compose/send a message */\nexport interface ComposeMessage {\n from: string; /** Account ID to send from */\n to: EmailAddress[];\n cc?: EmailAddress[];\n bcc?: EmailAddress[];\n subject: string;\n bodyHtml: string;\n bodyText?: string;\n inReplyTo?: string; /** Message-ID of message being replied to */\n references?: string[];\n attachments?: { filename: string; data: string; mimeType: string }[];\n}\n\n// \u2500\u2500 Queue Types \u2500\u2500\n\nexport type QueueStatus = \"pending\" | \"sending\" | \"sent\" | \"failed\";\n\nexport interface QueueItem {\n id: number;\n status: QueueStatus;\n createdAt: number;\n sendAfter: number; /** Epoch ms, for deferred send */\n attempts: number;\n lastAttempt: number;\n error: string;\n subject: string;\n to: EmailAddress[];\n}\n\n// \u2500\u2500 WebSocket Event Types \u2500\u2500\n\nexport type WsEvent =\n | { type: \"newMessage\"; accountId: string; folderId: number; message: MessageEnvelope }\n | { type: \"messageDeleted\"; accountId: string; folderId: number; uid: number }\n | { type: \"messageMoved\"; accountId: string; fromFolderId: number; toFolderId: number; uid: number }\n | { type: \"folderCountsChanged\"; accountId: string; counts: Record<number, { total: number; unread: number }> }\n | { type: \"folderSynced\"; accountId: string; entries: { folderId: number; syncedAt: number }[] }\n | { type: \"syncProgress\"; accountId: string; phase: string; progress: number }\n | { type: \"queueUpdate\"; pending: number; sending: number; failed: number }\n | { type: \"connected\" }\n | { type: \"reload\" }\n | { type: \"error\"; message: string }\n | { type: \"accountError\"; accountId: string; error: string; hint: string; isOAuth: boolean };\n\n// \u2500\u2500 Settings Types \u2500\u2500\n\nexport interface MailxSettings {\n accounts: AccountConfig[];\n ui: {\n theme: \"system\" | \"dark\" | \"light\";\n editor: \"quill\" | \"tiptap\";\n folderWidth: number;\n listViewerSplit: number; /** Percentage for message list height */\n fontSize: number;\n };\n sync: {\n intervalMinutes: number;\n historyDays: number; /** 0 = all history */\n prefetch: boolean; /** Download message bodies during sync (default true) */\n };\n store: {\n basePath: string; /** Where message bodies are stored */\n compressionBoundaryDays: number; /** Messages older than this get compressed */\n };\n autocomplete?: AutocompleteSettings;\n}\n\n// \u2500\u2500 Autocomplete Types \u2500\u2500\n\nexport interface AutocompleteSettings {\n enabled: boolean;\n provider: \"ollama\" | \"claude\" | \"openai\" | \"off\";\n ollamaUrl: string;\n ollamaModel: string;\n cloudApiKey: string;\n cloudModel: string;\n debounceMs: number;\n maxTokens: number;\n /** Per-feature opt-in for non-autocomplete AI helpers. All default false\n * per user preference (2026-04-21): AI features should be controlled by\n * a flag, initially OFF in settings. Provider config is shared with\n * autocomplete (provider, cloudApiKey, cloudModel, etc.). */\n translateEnabled?: boolean;\n proofreadEnabled?: boolean;\n}\n\n/** AI provider API keys. Lives at the top of `accounts.jsonc` alongside\n * `accounts:` so all secrets are co-located in one cloud-synced file. The\n * keys field replaces `preferences.jsonc.autocomplete.cloudApiKey` (which\n * was tied to the *active* provider) so a user can set both Anthropic and\n * OpenAI keys once and switch providers without re-entering anything. */\nexport interface AiKeys {\n anthropic?: string;\n openai?: string;\n}\n\nexport interface AutocompleteRequest {\n subject: string;\n to: string;\n bodyText: string;\n cursorOffset: number;\n}\n\nexport interface AutocompleteResponse {\n suggestion: string;\n}\n\n// \u2500\u2500 AI Transform (translate / proofread / summarize) \u2500\u2500\n\nexport interface AiTransformRequest {\n /** translate = render in `targetLang`; proofread = corrected version\n * with grammar/spelling fixes; summarize = short paragraph summary;\n * extractEvent = parse natural-language event description into a\n * structured calendar event (title/start/end/location/notes). */\n action: \"translate\" | \"proofread\" | \"summarize\" | \"extractEvent\";\n text: string;\n /** ISO-639-1 (or BCP-47) language code for translate. Defaults to \"en\". */\n targetLang?: string;\n /** Caller's \"now\" hint, ISO 8601 (e.g. 2026-05-05T18:30:00). Used by\n * extractEvent to resolve relative dates (\"tomorrow at 3pm\"). The\n * service can't trust its own clock \u2014 the user might be on a phone\n * in a different timezone \u2014 so the caller supplies it. */\n nowISO?: string;\n}\n\n/** Structured calendar event fields parsed from a natural-language\n * description by aiTransform({ action: \"extractEvent\" }). All times are\n * ISO 8601 in the caller's local timezone (no `Z` suffix); the client\n * shapes them into Google Calendar's `dates=` URL parameter. */\nexport interface ExtractedEvent {\n title: string;\n startISO?: string;\n endISO?: string;\n allDay?: boolean;\n location?: string;\n notes?: string;\n}\n\nexport interface AiTransformResponse {\n /** Transformed text. Empty when AI is disabled / provider error / feature\n * not enabled \u2014 caller should treat empty as \"no result\". For\n * extractEvent this holds the JSON-stringified ExtractedEvent so the\n * response shape stays single-field across all actions. */\n text: string;\n /** Parsed ExtractedEvent, populated only for extractEvent. Lets the\n * client skip the JSON.parse step (the service already validated). */\n event?: ExtractedEvent;\n /** Optional reason for empty result, surfaced to UI status bar. */\n reason?: string;\n}\n\n// \u2500\u2500 Store Interface \u2500\u2500\n\n/** Body storage backend interface -- implementations are swappable */\nexport interface MessageStore {\n putMessage(accountId: string, folderId: number, uid: number, raw: Buffer): Promise<string>;\n getMessage(accountId: string, folderId: number, uid: number): Promise<Buffer>;\n deleteMessage(accountId: string, folderId: number, uid: number): Promise<void>;\n hasMessage(accountId: string, folderId: number, uid: number): Promise<boolean>;\n}\n\n// \u2500\u2500 Shared Utilities \u2500\u2500\n// Pure functions used by both desktop (mailx-service) and Android (web-service).\n// Kept here to avoid duplication \u2014 both platforms import from mailx-types.\n\n/** Sanitize HTML for safe display \u2014 strips scripts, inline handlers, remote images, forms, iframes. */\nexport function sanitizeHtml(html: string): { html: string; hasRemoteContent: boolean } {\n let hasRemoteContent = false;\n let clean = html.replace(/<script\\b[^>]*>[\\s\\S]*?<\\/script>/gi, \"\");\n clean = clean.replace(/\\s+on\\w+\\s*=\\s*(\"[^\"]*\"|'[^']*'|[^\\s>]+)/gi, \"\");\n clean = clean.replace(/<img\\b([^>]*)\\bsrc\\s*=\\s*(\"[^\"]*\"|'[^']*')/gi, (match, before, src) => {\n const url = src.slice(1, -1);\n if (url.startsWith(\"data:\") || url.startsWith(\"cid:\")) return match;\n hasRemoteContent = true;\n return `<img${before}src=\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Crect fill='%23888' width='20' height='20' rx='3'/%3E%3Ctext x='10' y='14' text-anchor='middle' fill='white' font-size='12'%3E\u2298%3C/text%3E%3C/svg%3E\" data-blocked-src=${src} title=\"Remote image blocked\"`;\n });\n clean = clean.replace(/<link\\b[^>]*rel\\s*=\\s*[\"']stylesheet[\"'][^>]*>/gi, (match) => {\n hasRemoteContent = true;\n return `<!-- blocked: ${match.replace(/--/g, \"\")} -->`;\n });\n clean = clean.replace(/url\\s*\\(\\s*(['\"]?)(https?:\\/\\/[^)]+)\\1\\s*\\)/gi, (_match, _q, url) => {\n hasRemoteContent = true;\n return `url(\"\") /* blocked: ${url} */`;\n });\n clean = clean.replace(/<\\/?form\\b[^>]*>/gi, \"\");\n // Strip every interactive form element. Even without the surrounding\n // <form>, an <input type=\"password\"> in an email body is dangerous \u2014\n // WebView2's password manager offers to autofill it, and the user\n // might type a real password into what looks like an in-email control.\n // Same for <textarea>, <select>/<option>, and <button>. Bob 2026-05-14:\n // observed a phishing-shaped \"CHANGE YOUR PASSWORD\" email rendering\n // with working input fields + the OS autofill chip. Replace with a\n // visible \"[blocked: input]\" marker so the user sees something was\n // stripped (a silent removal would hide the phishing attempt entirely\n // \u2014 they'd think \"this email is just text\" when it actually tried to\n // capture credentials).\n clean = clean.replace(/<input\\b[^>]*\\/?>/gi, \"[blocked: form input]\");\n clean = clean.replace(/<textarea\\b[^>]*>[\\s\\S]*?<\\/textarea>/gi, \"[blocked: textarea]\");\n clean = clean.replace(/<select\\b[^>]*>[\\s\\S]*?<\\/select>/gi, \"[blocked: select]\");\n clean = clean.replace(/<button\\b[^>]*>([\\s\\S]*?)<\\/button>/gi, \"$1\");\n clean = clean.replace(/<iframe\\b[^>]*>[\\s\\S]*?<\\/iframe>/gi, \"\");\n return { html: clean, hasRemoteContent };\n}\n\n/** Encode text as RFC 2045 quoted-printable. */\nexport function encodeQuotedPrintable(text: string): string {\n const encoder = new TextEncoder();\n const bytes = encoder.encode(text);\n let line = \"\";\n let result = \"\";\n for (let i = 0; i < bytes.length; i++) {\n const b = bytes[i];\n let encoded: string;\n if (b === 0x0D && bytes[i + 1] === 0x0A) {\n result += line + \"\\r\\n\";\n line = \"\";\n i++;\n continue;\n } else if (b === 0x0A) {\n result += line + \"\\r\\n\";\n line = \"\";\n continue;\n } else if ((b >= 33 && b <= 126 && b !== 61) || b === 9 || b === 32) {\n encoded = String.fromCharCode(b);\n } else {\n encoded = \"=\" + b.toString(16).toUpperCase().padStart(2, \"0\");\n }\n if (line.length + encoded.length > 75) {\n result += line + \"=\\r\\n\";\n line = \"\";\n }\n line += encoded;\n }\n result += line;\n return result;\n}\n\n/** Render an HTML document as a plain-text approximation suitable for the\n * text/plain alternative part of a multipart/alternative outgoing MIME\n * message. Not a full HTML-to-text engine \u2014 just enough to give non-HTML\n * clients (plain-text readers, spam filters scoring on text/plain, people\n * who turned HTML off) a readable fallback. Preserves line breaks for\n * `<br>` / `</p>` / `</div>` / `<li>`, strips all other tags, decodes the\n * common HTML entities, and collapses runs of whitespace.\n *\n * Spam filters (SpamAssassin, Rspamd) penalise HTML-only mail aggressively;\n * shipping a real text part typically drops the score by 1\u20132 points. Also\n * matches the behaviour of every other mainstream mail client \u2014 sending a\n * text/html part alone marks mailx as an outlier in mail logs. */\nexport function htmlToPlainText(html: string): string {\n if (!html) return \"\";\n let s = html;\n // Drop <style> / <script> entirely (their contents aren't readable text).\n s = s.replace(/<style\\b[^>]*>[\\s\\S]*?<\\/style>/gi, \"\");\n s = s.replace(/<script\\b[^>]*>[\\s\\S]*?<\\/script>/gi, \"\");\n // Block-level breaks \u2014 treat closing tags as line terminators so\n // paragraphs don't run together.\n s = s.replace(/<br\\s*\\/?\\s*>/gi, \"\\n\");\n s = s.replace(/<\\/(p|div|li|tr|h[1-6]|blockquote|pre|section|article)\\s*>/gi, \"\\n\");\n // List-item leading bullet (rough but readable).\n s = s.replace(/<li\\b[^>]*>/gi, \" \u2022 \");\n // Anchor: keep href in parens after the text so URLs survive.\n s = s.replace(/<a\\b[^>]*href\\s*=\\s*(['\"])([^'\"]*)\\1[^>]*>([\\s\\S]*?)<\\/a>/gi,\n (_m, _q, href, text) => {\n const t = text.replace(/<[^>]+>/g, \"\").trim();\n return t && t !== href ? `${t} (${href})` : href;\n });\n // Strip remaining tags.\n s = s.replace(/<[^>]+>/g, \"\");\n // Decode a pragmatic set of HTML entities \u2014 the rare ones survive as-is.\n s = s.replace(/ /gi, \" \")\n .replace(/&/gi, \"&\")\n .replace(/</gi, \"<\")\n .replace(/>/gi, \">\")\n .replace(/"/gi, \"\\\"\")\n .replace(/'/gi, \"'\")\n .replace(/'/gi, \"'\")\n .replace(/—/gi, \"\u2014\")\n .replace(/–/gi, \"\u2013\")\n .replace(/…/gi, \"\u2026\")\n .replace(/&#(\\d+);/g, (_m, n) => String.fromCodePoint(parseInt(n, 10)))\n .replace(/&#x([0-9a-f]+);/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16)));\n // Normalise whitespace: collapse runs of spaces/tabs, trim per-line,\n // cap consecutive blank lines at 2.\n s = s.replace(/[ \\t]+/g, \" \")\n .split(\"\\n\").map(l => l.replace(/^[ \\t]+|[ \\t]+$/g, \"\")).join(\"\\n\")\n .replace(/\\n{3,}/g, \"\\n\\n\")\n .trim();\n return s;\n}\n\n/** Parse search query into structured conditions.\n * Supports qualifiers: from:, to:, subject:, date:, has:attachment,\n * is:flagged, is:unread, is:read. Unqualified terms search across subject /\n * from / preview. Returns { conditions, params } for SQL WHERE clause with\n * LIKE plus structured predicates (flags_json LIKE, has_attachments=1, date\n * range comparisons).\n *\n * Date syntax (matches Gmail-ish conventions):\n * - date:2026-04-22 exact day\n * - date:2026-04 month\n * - date:>2026-04-01 after\n * - date:<2026-04-01 before\n * - date:2026-04-01..2026-04-30 range\n * - date:today / yesterday / last7 / last30\n */\nexport function parseSearchQuery(query: string): { conditions: string[]; params: (string | number)[] } {\n const parts = query.match(/(?:[^\\s\"]+|\"[^\"]*\")+/g) || [];\n const conditions: string[] = [];\n const params: (string | number)[] = [];\n\n const dayStart = (y: number, m: number, d: number) => new Date(y, m - 1, d).getTime();\n const parseDateSpec = (spec: string): { from?: number; to?: number } | null => {\n const now = new Date();\n const today0 = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();\n if (spec === \"today\") return { from: today0, to: today0 + 86400_000 };\n if (spec === \"yesterday\") return { from: today0 - 86400_000, to: today0 };\n const lastN = spec.match(/^last(\\d+)$/i);\n if (lastN) return { from: today0 - parseInt(lastN[1]) * 86400_000 };\n const rangeMatch = spec.match(/^(\\d{4})-(\\d{2})-(\\d{2})\\.\\.(\\d{4})-(\\d{2})-(\\d{2})$/);\n if (rangeMatch) return {\n from: dayStart(+rangeMatch[1], +rangeMatch[2], +rangeMatch[3]),\n to: dayStart(+rangeMatch[4], +rangeMatch[5], +rangeMatch[6]) + 86400_000,\n };\n const gtMatch = spec.match(/^>(\\d{4})-(\\d{2})-(\\d{2})$/);\n if (gtMatch) return { from: dayStart(+gtMatch[1], +gtMatch[2], +gtMatch[3]) + 86400_000 };\n const ltMatch = spec.match(/^<(\\d{4})-(\\d{2})-(\\d{2})$/);\n if (ltMatch) return { to: dayStart(+ltMatch[1], +ltMatch[2], +ltMatch[3]) };\n const monthMatch = spec.match(/^(\\d{4})-(\\d{2})$/);\n if (monthMatch) {\n const y = +monthMatch[1], m = +monthMatch[2];\n const from = dayStart(y, m, 1);\n const to = m === 12 ? dayStart(y + 1, 1, 1) : dayStart(y, m + 1, 1);\n return { from, to };\n }\n const dayMatch = spec.match(/^(\\d{4})-(\\d{2})-(\\d{2})$/);\n if (dayMatch) {\n const from = dayStart(+dayMatch[1], +dayMatch[2], +dayMatch[3]);\n return { from, to: from + 86400_000 };\n }\n return null;\n };\n\n for (const part of parts) {\n const fromMatch = part.match(/^from:(.+)$/i);\n const toMatch = part.match(/^to:(.+)$/i);\n const subjectMatch = part.match(/^subject:(.+)$/i);\n const hasMatch = part.match(/^has:(.+)$/i);\n const isMatch = part.match(/^is:(.+)$/i);\n const dateMatch = part.match(/^date:(.+)$/i);\n\n if (fromMatch) {\n const term = `%${fromMatch[1].replace(/\"/g, \"\")}%`;\n conditions.push(\"(from_name LIKE ? OR from_address LIKE ?)\");\n params.push(term, term);\n } else if (toMatch) {\n const term = `%${toMatch[1].replace(/\"/g, \"\")}%`;\n conditions.push(\"(to_json LIKE ? OR cc_json LIKE ?)\");\n params.push(term, term);\n } else if (subjectMatch) {\n const term = `%${subjectMatch[1].replace(/\"/g, \"\")}%`;\n conditions.push(\"subject LIKE ?\");\n params.push(term);\n } else if (hasMatch) {\n const v = hasMatch[1].toLowerCase();\n if (v === \"attachment\" || v === \"attachments\") {\n conditions.push(\"has_attachments = 1\");\n }\n // Unknown has: qualifier \u2014 silently drop; treating as a literal\n // search term would be confusing.\n } else if (isMatch) {\n const v = isMatch[1].toLowerCase();\n if (v === \"flagged\" || v === \"starred\") {\n conditions.push(\"flags_json LIKE ?\"); params.push(\"%\\\\\\\\Flagged%\");\n } else if (v === \"unread\") {\n conditions.push(\"flags_json NOT LIKE ?\"); params.push(\"%\\\\\\\\Seen%\");\n } else if (v === \"read\") {\n conditions.push(\"flags_json LIKE ?\"); params.push(\"%\\\\\\\\Seen%\");\n }\n } else if (dateMatch) {\n const spec = parseDateSpec(dateMatch[1]);\n if (spec) {\n if (spec.from !== undefined) {\n conditions.push(\"date >= ?\"); params.push(spec.from);\n }\n if (spec.to !== undefined) {\n conditions.push(\"date < ?\"); params.push(spec.to);\n }\n }\n } else {\n const term = `%${part}%`;\n conditions.push(\"(subject LIKE ? OR from_name LIKE ? OR from_address LIKE ? OR preview LIKE ?)\");\n params.push(term, term, term, term);\n }\n }\n\n return { conditions, params };\n}\n", "/**\n * Message viewer component -- displays full message in sandboxed iframe.\n *\n * Passive renderer. The list owns \"what's focused\"; this module exposes\n * `showMessage(accountId, uid, folderId, envelope?)` and `clearViewer()`\n * as the only ways content reaches the pane. There is no subscription \u2014\n * if the list never tells the viewer about a focus change, the viewer\n * simply doesn't update. That's the property that makes summary\u2194preview\n * drift impossible: there is exactly one path into this pane.\n */\n\nimport { getMessage, updateFlags, allowRemoteContent, flagSenderOrDomain, getAttachment, openAttachment, addContact, listContacts, upsertContact, unsubscribeOneClick, addPreferredContact, onEvent, subscribeStore } from \"../lib/api-client.js\";\nimport { showContextMenu } from \"./context-menu.js\";\nimport type { MenuItem } from \"./context-menu.js\";\nimport type { ListMessage } from \"../lib/message-state.js\";\nimport { updateMessageFlags as stateUpdateFlags } from \"../lib/message-state.js\";\nimport { setRowSeen } from \"./message-list.js\";\nimport { setSeen, seenOf } from \"@bobfrankston/mailx-types\";\n\n/** Currently displayed message (for reply/forward) */\nlet currentMessage: any = null;\nlet currentAccountId: string = \"\";\n// Track the most recent draft we auto-opened into compose so re-renders of\n// the same selection (e.g. a folder-counts event) don't spawn duplicate\n// compose windows. Reset implicitly when a different message is selected.\nlet lastAutoOpenedDraftUid: number = -1;\nlet showMessageGeneration = 0; // Cancel stale fetches\nlet retryCount = 0;\n/** Last envelope handed in by the list \u2014 used for envelope-first paint\n * on retry, where we don't have a fresh row click to pass it again. */\nlet lastEnvelope: ListMessage | null = null;\n\n/** Parsed-body LRU. Click \u2192 render must be synchronous; the only way to\n * honor that is to keep the parsed result in WebView memory keyed by\n * (accountId, uid). Bounded so a long session doesn't eat hundreds of\n * MB of decoded HTML. Hit = render now, no IPC, no spinner, no\n * \"Loading\u2026\". Miss = paint the envelope's preview as a transient body\n * and swap in the real one when the IPC returns (also caches). */\nconst PARSED_CACHE_LIMIT = 64;\nconst parsedCache = new Map<string, any>(); // key: `${accountId}:${uid}` \u2192 full message obj\nfunction parsedCacheGet(accountId: string, uid: number): any | null {\n const key = `${accountId}:${uid}`;\n const v = parsedCache.get(key);\n if (!v) return null;\n // Bump recency: re-insert moves to the end of Map iteration order.\n parsedCache.delete(key);\n parsedCache.set(key, v);\n return v;\n}\nfunction parsedCachePut(accountId: string, uid: number, msg: any): void {\n const key = `${accountId}:${uid}`;\n parsedCache.delete(key);\n parsedCache.set(key, msg);\n while (parsedCache.size > PARSED_CACHE_LIMIT) {\n const oldest = parsedCache.keys().next().value;\n if (oldest === undefined) break;\n parsedCache.delete(oldest);\n }\n}\n/** Drop one entry \u2014 called when flags change so re-render reads fresh state. */\nexport function invalidateParsedCache(accountId: string, uid: number): void {\n parsedCache.delete(`${accountId}:${uid}`);\n}\n\n/** Session-scoped \"user clicked Allow on this message\" set. Once a uuid\n * is in here, any subsequent showMessage call treats `hasRemoteContent`\n * as false regardless of what the daemon returned \u2014 covers the race\n * where the allowlist persist hasn't reached disk yet, the daemon\n * re-parses and reports remote content again, and a folder refresh\n * re-renders the banner on top of the unblocked iframe (Bob 2026-05-09:\n * \"still showing the blocked header\" while content was loaded). */\nconst sessionAllowedRemote = new Set<string>();\n\n/** Per-uid recent body-fetch errors. Populated by the bodyFetchError\n * listener so the \"No content\" code path can show the real reason\n * instead of a generic placeholder. Keyed `${accountId}:${uid}`. */\nconst recentFetchErrors = new Map<string, { error: string; transient: boolean; when: number }>();\nfunction markSessionAllowed(accountId: string, uid: number): void {\n sessionAllowedRemote.add(`${accountId}:${uid}`);\n}\nfunction isSessionAllowed(accountId: string, uid: number): boolean {\n return sessionAllowedRemote.has(`${accountId}:${uid}`);\n}\n\nexport function getCurrentMessage(): { accountId: string; message: any } | null {\n if (!currentMessage) return null;\n return { accountId: currentAccountId, message: currentMessage };\n}\n\n/** Build the body-context menu items (right-click outside a link in the\n * preview iframe). Called from app.ts's window message listener after the\n * iframe inline script postMessages a \"previewContextMenu\" event with the\n * current selection. Translate routes to the configured AI provider via\n * aiTransform; \"Translate via Google\" opens translate.google.com in the\n * default browser as a no-account-needed alternative. */\nexport function showPreviewBodyMenu(absX: number, absY: number, selectedText: string, sourceWindow: Window | null, linkUrl?: string, linkText?: string): void {\n const pct = Math.round(previewZoom * 100);\n const runSearch = (query: string): void => {\n const input = document.getElementById(\"search-input\") as HTMLInputElement | null;\n if (!input) return;\n input.value = query;\n input.dispatchEvent(new Event(\"input\"));\n input.dispatchEvent(new KeyboardEvent(\"keydown\", { key: \"Enter\", bubbles: true }));\n input.focus();\n };\n // Send a command back into the iframe (Copy, Select all) \u2014 the iframe\n // owns its own document and these only work in the iframe's context.\n const tellIframe = (cmd: string): void => {\n try { sourceWindow?.postMessage({ type: \"previewCommand\", cmd }, \"*\"); } catch { /* */ }\n };\n const fullBody = (): string => {\n try {\n for (const f of Array.from(document.querySelectorAll(\"iframe\"))) {\n if ((f as HTMLIFrameElement).contentWindow === sourceWindow) {\n return (f as HTMLIFrameElement).contentDocument?.body?.innerText || \"\";\n }\n }\n } catch { /* */ }\n return \"\";\n };\n const items: MenuItem[] = [];\n // Link actions first (when right-click was over a link). Quora-style\n // pages are wall-to-wall links \u2014 without this, body actions like\n // Translate were unreachable because every long-press hit a link.\n if (linkUrl) {\n const guessName = (() => {\n try { const u = new URL(linkUrl); const last = u.pathname.split(\"/\").pop() || \"\"; return last && last.includes(\".\") ? last : \"\"; }\n catch { return \"\"; }\n })();\n items.push(\n { label: \"Open link in browser\", action: () => window.open(linkUrl, \"_blank\", \"noopener,noreferrer\") },\n { label: guessName ? `Save link as \"${guessName}\"\u2026` : \"Save link as\u2026\", action: () => {\n const a = document.createElement(\"a\"); a.href = linkUrl;\n a.download = guessName || \"\"; a.style.display = \"none\";\n document.body.appendChild(a); a.click(); setTimeout(() => a.remove(), 1000);\n } },\n { label: \"Copy link URL\", action: async () => {\n try { await navigator.clipboard.writeText(linkUrl); } catch { prompt(\"URL:\", linkUrl); }\n } },\n { label: \"Copy link text\", action: async () => {\n try { await navigator.clipboard.writeText(linkText || linkUrl); } catch { prompt(\"Text:\", linkText || linkUrl); }\n } },\n { label: \"\", action: () => {}, separator: true },\n );\n }\n items.push(\n { label: \"Copy\", action: () => tellIframe(\"copy\") },\n { label: \"Select all\", action: () => tellIframe(\"selectAll\") },\n );\n if (selectedText) {\n items.push(\n { label: \"\", action: () => {}, separator: true },\n { label: `Search messages for \"${selectedText.length > 40 ? selectedText.slice(0, 40) + \"\u2026\" : selectedText}\"`,\n action: () => runSearch(selectedText) },\n { label: \"Copy as quoted (> prefix)\",\n action: async () => {\n const quoted = selectedText.split(/\\r?\\n/).map(l => \"> \" + l).join(\"\\n\");\n try { await navigator.clipboard.writeText(quoted); } catch { /* */ }\n } },\n );\n }\n const senderAddr = currentMessage?.from?.address || \"\";\n if (senderAddr) {\n items.push({ label: `Search messages from ${senderAddr}`, action: () => runSearch(`from:${senderAddr}`) });\n }\n items.push(\n { label: \"\", action: () => {}, separator: true },\n { label: selectedText ? \"Translate selection (AI)\" : \"Translate message (AI)\",\n action: () => translateAndShow(selectedText || fullBody()) },\n { label: selectedText ? \"Translate selection (Google Translate)\" : \"Translate message (Google Translate)\",\n action: () => {\n const text = (selectedText || fullBody()).slice(0, 4500); // GT URL has practical limits\n const url = `https://translate.google.com/?sl=auto&tl=en&op=translate&text=${encodeURIComponent(text)}`;\n window.open(url, \"_blank\", \"noopener,noreferrer\");\n } },\n { label: \"\", action: () => {}, separator: true },\n // Zoom group \u2014 current level (resets on click) first, then in / out.\n { label: `Zoom: ${pct}% \u2014 reset`, action: () => { for (const f of Array.from(document.querySelectorAll(\"iframe\"))) { const d = (f as HTMLIFrameElement).contentDocument; if (d) { setZoom(1, d); break; } } } },\n { label: \"Zoom in (+)\", action: () => { for (const f of Array.from(document.querySelectorAll(\"iframe\"))) { const d = (f as HTMLIFrameElement).contentDocument; if (d) { setZoom(previewZoom + ZOOM_STEP, d); break; } } } },\n { label: \"Zoom out (\u2212)\", action: () => { for (const f of Array.from(document.querySelectorAll(\"iframe\"))) { const d = (f as HTMLIFrameElement).contentDocument; if (d) { setZoom(previewZoom - ZOOM_STEP, d); break; } } } },\n { label: \"\", action: () => {}, separator: true },\n { label: \"Print\u2026 (Ctrl+P)\", action: () => printCurrentMessage() },\n );\n showContextMenu(absX, absY, items);\n}\n\n/** Toggle preview-fullscreen mode. CSS rule on body.fullscreen-preview hides\n * toolbar / rail / folder tree / list / statusbar and expands the viewer to\n * the full viewport. Exits via second toggle, Esc, or the floating \u2715. */\nexport function toggleFullscreenPreview(): void {\n const body = document.body;\n const on = !body.classList.contains(\"fullscreen-preview\");\n body.classList.toggle(\"fullscreen-preview\", on);\n let exit = document.getElementById(\"mv-exit-fullscreen\");\n if (on) {\n if (!exit) {\n exit = document.createElement(\"button\");\n exit.id = \"mv-exit-fullscreen\";\n exit.textContent = \"\u2715\";\n exit.title = \"Exit fullscreen (Esc or double-tap)\";\n exit.style.cssText = \"position:fixed;top:8px;right:8px;z-index:6000;width:36px;height:36px;border-radius:50%;border:none;background:rgba(0,0,0,0.55);color:#fff;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,0.4);\";\n exit.addEventListener(\"click\", () => toggleFullscreenPreview());\n document.body.appendChild(exit);\n }\n // Esc handler \u2014 installed once, reads the class on each press.\n if (!(window as any).__mvFullscreenEsc) {\n const onEsc = (e: KeyboardEvent) => {\n if (e.key === \"Escape\" && document.body.classList.contains(\"fullscreen-preview\")) {\n e.preventDefault();\n toggleFullscreenPreview();\n }\n };\n document.addEventListener(\"keydown\", onEsc, true);\n (window as any).__mvFullscreenEsc = onEsc;\n }\n } else {\n exit?.remove();\n }\n}\n\n/** Kept as an exported no-op for back-compat with app.ts wiring. The\n * state-subscribe model is gone; list now drives the viewer directly. */\nexport function initViewer(): void { /* no-op: list owns focus */ }\n\n// Zoom is persisted across messages via localStorage\nconst ZOOM_KEY = \"mailx-preview-zoom\";\nconst ZOOM_MIN = 0.5;\nconst ZOOM_MAX = 3.0;\nconst ZOOM_STEP = 0.1;\n// 1.15 default: smaller than browser-default text felt cramped per user\n// feedback 2026-05-20. Ctrl+= / Ctrl+- / Ctrl+wheel / right-click menu all\n// override and persist, so the default only matters on first-ever launch or\n// after a WebView2 profile wipe.\nlet previewZoom = clampZoom(parseFloat(localStorage.getItem(ZOOM_KEY) || \"1.15\"));\n\nfunction clampZoom(z: number): number {\n if (!Number.isFinite(z) || z <= 0) return 1;\n return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, Math.round(z * 100) / 100));\n}\n\nfunction applyZoom(doc: Document): void {\n // Zoom lives on <html>, not <body>, because WebView2/Chromium's scroll\n // container is the root element \u2014 body.style.zoom leaves the scroll\n // container out of sync with the zoomed content, breaking scrollbar\n // display and wheel scrolling. documentElement.style.zoom keeps them\n // aligned so the iframe scrolls normally at any zoom level.\n if (doc.documentElement) (doc.documentElement.style as any).zoom = String(previewZoom);\n}\n\nfunction setZoom(z: number, doc: Document): void {\n previewZoom = clampZoom(z);\n localStorage.setItem(ZOOM_KEY, String(previewZoom));\n applyZoom(doc);\n}\n\n/** Install preview iframe controls: key forwarding to parent, Ctrl+wheel zoom,\n * keyboard zoom shortcuts (Ctrl+= / Ctrl+- / Ctrl+0), and the right-click menu. */\n/** Run AI translate on `text` and show result in a small modal. Disabled\n * by default \u2014 user enables via Settings (translateEnabled in\n * AutocompleteSettings). When disabled, the modal explains how to enable. */\nasync function translateAndShow(text: string): Promise<void> {\n if (!text.trim()) return;\n const status = document.getElementById(\"status-sync\");\n if (status) status.textContent = \"Translating\u2026\";\n\n const overlay = document.createElement(\"div\");\n overlay.style.cssText = \"position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:10000;display:flex;align-items:center;justify-content:center;\";\n const modal = document.createElement(\"div\");\n modal.style.cssText = \"background:var(--bg, #fff);color:var(--fg, #000);border:1px solid var(--border, #ccc);border-radius:6px;width:560px;max-width:90vw;max-height:70vh;display:flex;flex-direction:column;box-shadow:0 4px 24px rgba(0,0,0,0.3);\";\n const header = document.createElement(\"div\");\n header.style.cssText = \"padding:10px 14px;border-bottom:1px solid var(--border, #ddd);font-weight:600;display:flex;justify-content:space-between;align-items:center;\";\n header.innerHTML = `<span>Translation</span><button style=\"cursor:pointer;border:0;background:transparent;font-size:16px;\" aria-label=\"Close\">\u00D7</button>`;\n const body = document.createElement(\"div\");\n body.style.cssText = \"flex:1;overflow:auto;padding:12px 14px;white-space:pre-wrap;font-size:13px;line-height:1.45;\";\n body.textContent = \"Working\u2026\";\n modal.appendChild(header);\n modal.appendChild(body);\n overlay.appendChild(modal);\n document.body.appendChild(overlay);\n const close = () => overlay.remove();\n header.querySelector(\"button\")?.addEventListener(\"click\", close);\n overlay.addEventListener(\"click\", (e) => { if (e.target === overlay) close(); });\n document.addEventListener(\"keydown\", function onKey(e: KeyboardEvent) {\n if (e.key === \"Escape\") { document.removeEventListener(\"keydown\", onKey); close(); }\n });\n\n try {\n const { aiTransform } = await import(\"../lib/api-client.js\");\n const r = await aiTransform({ action: \"translate\", text, targetLang: \"en\" });\n if (r.text) {\n body.textContent = r.text;\n if (status) status.textContent = \"\";\n } else {\n body.innerHTML = `<div style=\"color:var(--muted, #888);\">No result.</div>` +\n `<div style=\"margin-top:8px;font-size:12px;color:var(--muted, #888);\">${r.reason || \"\"}</div>` +\n `<div style=\"margin-top:14px;font-size:12px;\">Enable AI translate in Settings \u2192 AI features (off by default).</div>`;\n if (status) status.textContent = `Translate: ${r.reason || \"no result\"}`;\n }\n } catch (err: any) {\n body.textContent = `Error: ${err?.message || String(err)}`;\n if (status) status.textContent = `Translate error: ${err?.message || \"\"}`;\n }\n}\n\nfunction installPreviewControls(iframe: HTMLIFrameElement): void {\n const attach = () => {\n const doc = iframe.contentDocument;\n if (!doc) return;\n\n applyZoom(doc);\n\n doc.addEventListener(\"keydown\", (e) => {\n const target = e.target as HTMLElement | null;\n if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return;\n // Zoom is iframe-local \u2014 handle here, don't forward.\n if (e.ctrlKey && (e.key === \"=\" || e.key === \"+\")) { e.preventDefault(); setZoom(previewZoom + ZOOM_STEP, doc); return; }\n if (e.ctrlKey && e.key === \"-\") { e.preventDefault(); setZoom(previewZoom - ZOOM_STEP, doc); return; }\n if (e.ctrlKey && e.key === \"0\") { e.preventDefault(); setZoom(1, doc); return; }\n // Forward EVERY keydown to the parent \u2014 no duplicated hotkey list.\n // If the parent's handler calls preventDefault (because it owns the\n // shortcut), dispatchEvent returns false, and we preventDefault on\n // the iframe side too so the browser doesn't ALSO act on it\n // (Ctrl+N otherwise pops a new browser window in some hosts).\n // Single source of truth = app.ts hotkey handlers. Plain typing in\n // the email body \u2014 letters, etc. \u2014 propagates with no parent\n // handler matching, so dispatchEvent returns true and the iframe\n // event is left alone.\n const synth = new KeyboardEvent(\"keydown\", {\n key: e.key, code: e.code,\n ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, altKey: e.altKey, metaKey: e.metaKey,\n bubbles: true, cancelable: true,\n });\n const allowDefault = document.dispatchEvent(synth);\n if (!allowDefault) e.preventDefault();\n });\n\n doc.addEventListener(\"wheel\", (e) => {\n if (!e.ctrlKey) return;\n e.preventDefault();\n setZoom(previewZoom + (e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP), doc);\n }, { passive: false });\n\n // dblclick fullscreen toggle moved into the iframe's inline script\n // so it's wired by the time the document loads \u2014 the parent-side\n // contentDocument listener was racing with the load event and\n // sometimes never attached.\n\n // Link interception lives in the iframe's own inline <script> (see\n // wrapHtmlBody). That script runs under a CSP nonce so email-body\n // scripts stay blocked while ours forwards taps to the parent frame.\n\n // Body-context menu was previously built here on doc.contextmenu, but\n // the inline iframe script (wrapHtmlBody) now postMessages the request\n // to the parent unconditionally \u2014 see showPreviewContextMenu in app.ts\n // which calls showPreviewBodyMenu below. This route works on every\n // host; the doc-level handler missed cases where WebView2's native\n // menu fired before our parent listener got installed.\n };\n if (iframe.contentDocument?.readyState === \"complete\") attach();\n else iframe.addEventListener(\"load\", attach, { once: true });\n}\n\nexport function clearViewer(): void {\n currentMessage = null;\n currentAccountId = \"\";\n lastEnvelope = null;\n showMessageGeneration++;\n const headerEl = document.getElementById(\"mv-header\") as HTMLElement;\n const bodyEl = document.getElementById(\"mv-body\") as HTMLElement;\n const attEl = document.getElementById(\"mv-attachments\") as HTMLElement;\n if (bodyEl) bodyEl.innerHTML = `<div class=\"mv-empty\">Select a message to read</div>`;\n if (headerEl) headerEl.hidden = true;\n if (attEl) attEl.hidden = true;\n}\n\n/** Render the message identified by (accountId, uid, folderId). The\n * list passes the row's existing envelope as `envelope` so the header +\n * preview snippet paint *immediately*, before the body fetch returns \u2014\n * that's the \"no Fetching\u2026 empty pane\" guarantee. On retry, we reuse\n * the most recently provided envelope. */\nexport async function showMessage(accountId: string, uid: number, folderId?: number, specialUse?: string, isRetry = false, envelope?: ListMessage): Promise<void> {\n const gen = ++showMessageGeneration;\n if (!isRetry) retryCount = 0;\n const headerEl = document.getElementById(\"mv-header\") as HTMLElement;\n const bodyEl = document.getElementById(\"mv-body\") as HTMLElement;\n const attEl = document.getElementById(\"mv-attachments\") as HTMLElement;\n\n // Same-message re-show guard. The list rebuilds on folderCountsChanged\n // (sync events, IDLE, flag updates) and `restoreSelection()` then calls\n // `focusRow()` \u2192 `viewerShow()` on the row whose uid matches. If we\n // re-paint here, `bodyEl.innerHTML = \u2026` later in this function destroys\n // the iframe and resets the user's scroll position to top. Skip that\n // entirely when the message identity hasn't changed and we've got a\n // live iframe to keep. Header refresh is harmless (no scroll inside)\n // and keeps flag/read state visually current. Retry path bypasses the\n // guard so transient body fetches still get their delayed paint.\n //\n // Identity check: prefer the stable `uuid`. (uid, folderId) is NOT a\n // reliable identity \u2014 uid is per-folder, so a cross-folder search can\n // surface two different messages with the same uid, and a result whose\n // folderId is omitted made the guard mis-fire: the header refreshed to\n // the clicked message but the body kept the previously-shown message\n // (Bob 2026-05-18: the .eml shown \"does not match the header\"). Only\n // fall back to uid/folderId when a uuid isn't available on both sides\n // (e.g. thread-popup envelopes, which carry no uuid).\n const sameMessage = !!currentMessage && currentAccountId === accountId && (\n (envelope?.uuid && currentMessage.uuid)\n ? envelope.uuid === currentMessage.uuid\n : (currentMessage.uid === uid\n && (folderId === undefined || currentMessage.folderId === folderId))\n );\n if (!isRetry && sameMessage && bodyEl.querySelector(\"iframe\")) {\n if (envelope) {\n lastEnvelope = envelope;\n try { renderHeaderFromEnvelope(headerEl, envelope); headerEl.hidden = false; } catch { /* */ }\n }\n return;\n }\n\n // Click-to-preview timing \u2014 answers \"why is this slow?\" with numbers.\n // Marks: enter / cache-hit-or-miss / IPC-begin / IPC-done / iframe-set /\n // iframe-loaded. Without these the symptom \"previews very slow\" is\n // unmeasurable.\n const _previewT0 = performance.now();\n const _ptick = (label: string): void => {\n const line = `[preview ${(performance.now() - _previewT0).toFixed(0).padStart(5)} ms] ${label} ${accountId}/uid=${uid}`;\n console.log(\" \" + line);\n try { (window as any).mailxapi?.logClientEvent?.(line); } catch { /* */ }\n };\n _ptick(\"showMessage entry\");\n\n // Clear any \"current message\" identity at the START of a new render \u2014\n // before a single byte is fetched. Action buttons (Source, Edit Draft,\n // attachments) read from `currentMessage` and must NOT serve the\n // previous message's data while this one is still loading. The\n // closures they had pointed at the old message; clearing the shared\n // state forces them to read whatever this render eventually commits.\n currentMessage = null;\n currentAccountId = \"\";\n // Hide action buttons that depend on the message body \u2014 they'll be\n // restored when the new message data arrives.\n const srcBtn = document.getElementById(\"mv-view-source\") as HTMLButtonElement | null;\n if (srcBtn) srcBtn.hidden = true;\n const editBtn = document.getElementById(\"mv-edit-draft\") as HTMLButtonElement | null;\n if (editBtn) editBtn.hidden = true;\n\n // Envelope-first render: the row the user just clicked already has the\n // subject / from / to / cc / date / preview in the message-state. Use\n // that to populate the header + a snippet placeholder IMMEDIATELY so\n // tapping a message never shows just \"Fetching message body...\" with\n // nothing actionable. The full getMessage() call (which might block on\n // a slow IMAP body fetch) only fills in the body and attachments.\n const cached = (envelope && envelope.uid === uid && (envelope.accountId || accountId) === accountId)\n ? envelope\n : (lastEnvelope && lastEnvelope.uid === uid && (lastEnvelope.accountId || accountId) === accountId ? lastEnvelope : null);\n if (envelope) lastEnvelope = envelope;\n\n // Cache lookup BEFORE any placeholder paint. If this message's parsed\n // body is in WebView memory (recently viewed), skip the IPC entirely \u2014\n // render synchronously below.\n const cachedMsg = parsedCacheGet(accountId, uid);\n\n if (cached) {\n try { renderHeaderFromEnvelope(headerEl, cached); } catch { /* */ }\n // 2026-05-13: if the envelope has a non-empty `preview`, show it as\n // a miniature body until the parsed HTML arrives. If it's empty\n // (common for mailing-list / quoted-reply messages), show an\n // explicit \"Loading body\u2026\" so the user never sees a totally blank\n // pane and thinks the click didn't register. Earlier wording chose\n // silence on the theory that \"Loading\u2026\" is a lie when fast \u2014 but\n // an empty pane is a worse lie when the parse is slow.\n if (!cachedMsg) {\n const previewText = (cached.preview || \"\").trim();\n bodyEl.innerHTML = previewText\n ? `<div class=\"mv-preview-placeholder\">${escapeHtml(previewText)}</div>`\n : `<div class=\"mv-empty\">Loading body\u2026</div>`;\n }\n } else if (!cachedMsg) {\n // No envelope and no cache \u2014 show a loading indicator instead of\n // an empty pane. The IPC return will paint over it.\n bodyEl.innerHTML = `<div class=\"mv-empty\">Loading body\u2026</div>`;\n headerEl.hidden = true;\n }\n attEl.hidden = true;\n\n try {\n if (cachedMsg) _ptick(\"parsedCache HIT \u2014 sync render\");\n else _ptick(\"parsedCache MISS \u2014 IPC begin\");\n const msg = cachedMsg ? cachedMsg : await getMessage(accountId, uid, false, folderId);\n if (!cachedMsg) _ptick(`IPC done (cached=${(msg as any).cached !== false}, ${(msg as any).bodyHtml?.length || 0} bytes html)`);\n // Stale response \u2014 a newer showMessage was called while we were fetching\n if (gen !== showMessageGeneration) return;\n\n // User clicked Allow earlier this session \u2014 treat as unblocked\n // regardless of what the daemon currently reports. Covers the\n // race where the allowlist persist is still in flight.\n if (isSessionAllowed(accountId, uid) && (msg as any).hasRemoteContent) {\n (msg as any).hasRemoteContent = false;\n }\n\n // Body not yet on disk \u2014 service kicked off a background fetch and\n // will emit `bodyAvailable` when the bytes land. Show envelope-only\n // (header + preview snippet) and re-render on the event. No timer,\n // no client-side retry; the reconciler retries server-side.\n if ((msg as any).cached === false) {\n try { renderHeaderFromEnvelope(headerEl, msg as any); headerEl.hidden = false; } catch { /* */ }\n // Body genuinely missing from local store \u2014 the reconciler is\n // fetching it from IMAP/Gmail. Show preview as a miniature\n // body if available, otherwise a clear \"Fetching from server\u2026\"\n // so a slow fetch isn't indistinguishable from a hung pane.\n const previewText = ((msg as any).preview || cached?.preview || \"\").trim();\n bodyEl.innerHTML = previewText\n ? `<div class=\"mv-preview-placeholder\">${escapeHtml(previewText)}</div>`\n : `<div class=\"mv-empty\">Fetching body from server\u2026</div>`;\n const captureGen = gen;\n // Subscribe via Store bus (the post-refactor surface). The\n // reconciler publishes `bodyAvailable` on `message:<uuid>` (or\n // `folder:<id>` when uuid isn't available); the wildcard catches\n // both. Match by account+uid because the viewer keys off those.\n const off = subscribeStore(\"*\", (ev: any) => {\n if (ev.kind !== \"bodyAvailable\") return;\n if (ev.accountId !== accountId || ev.uid !== uid) return;\n if (captureGen !== showMessageGeneration) { off(); return; }\n off();\n showMessage(accountId, uid, folderId, specialUse, true, envelope || cached || undefined).catch(() => { /* */ });\n });\n return;\n }\n // Cache the fully-parsed result so re-clicking this message paints\n // synchronously next time (no IPC, no parse). Cache is bounded\n // (PARSED_CACHE_LIMIT) and skipped if we got here via cache hit.\n if (!cachedMsg) parsedCachePut(accountId, uid, msg);\n currentMessage = msg;\n currentAccountId = accountId;\n\n // Mark as read \u2014 gated by user prefs:\n // - mailx-automark-read (default \"true\"): if \"false\", never auto-mark\n // - mailx-automark-delay (default \"2\"): seconds to wait before\n // marking. Lets the user click through messages quickly without\n // marking ones they didn't actually read. The timer is tied to\n // showMessageGeneration; navigating to another message advances\n // the generation and cancels the pending mark.\n if (!seenOf(msg)) {\n let enabled = true;\n let delaySec = 2;\n try {\n enabled = localStorage.getItem(\"mailx-automark-read\") !== \"false\";\n const d = parseFloat(localStorage.getItem(\"mailx-automark-delay\") || \"2\");\n if (Number.isFinite(d) && d >= 0) delaySec = d;\n } catch { /* private mode \u2014 defaults */ }\n if (enabled) {\n const captureGen = gen;\n const apply = () => {\n // Stale: user moved on before the timer fired.\n if (captureGen !== showMessageGeneration) return;\n setSeen(msg, true);\n updateFlags(accountId, uid, msg.flags);\n // Mirror to message-state + row so list display flips.\n try { stateUpdateFlags(accountId, uid, msg.flags); } catch { /* */ }\n try { setRowSeen(accountId, uid, true); } catch { /* */ }\n };\n if (delaySec === 0) apply();\n else setTimeout(apply, delaySec * 1000);\n }\n }\n\n // Header\n headerEl.hidden = false;\n const fromEl = headerEl.querySelector(\".mv-from\")!;\n const toEl = headerEl.querySelector(\".mv-to\")!;\n fromEl.textContent = formatAddr(msg.from);\n let toLine = `To: ${msg.to.map(formatAddr).join(\", \")}`;\n if (msg.cc?.length) toLine += ` Cc: ${msg.cc.map(formatAddr).join(\", \")}`;\n // Always-visible Delivered-To line \u2014 shown when present and not already\n // covered by the To/Cc list. Critical for accounts with multiple aliases\n // where you need to see which one received the message at a glance.\n const toAddrs = (msg.to || []).map((a: { address: string }) => a.address.toLowerCase());\n const ccAddrs = (msg.cc || []).map((a: { address: string }) => a.address.toLowerCase());\n const dt = (msg.deliveredTo || \"\").toLowerCase();\n if (msg.deliveredTo && !toAddrs.includes(dt) && !ccAddrs.includes(dt)) {\n toLine += ` Delivered-To: ${msg.deliveredTo}`;\n }\n toEl.textContent = toLine;\n headerEl.querySelector(\".mv-subject\")!.textContent = msg.subject;\n document.dispatchEvent(new CustomEvent(\"mailx-message-shown\", { detail: { accountId } }));\n\n // Right-click on email addresses in header: copy name, copy address,\n // copy both, add to contacts, plus reply actions for the whole message.\n for (const el of [fromEl, toEl]) {\n el.addEventListener(\"contextmenu\", (e: Event) => {\n e.preventDefault();\n const me = e as MouseEvent;\n const items: MenuItem[] = [];\n const addrs = el === fromEl ? [msg.from] : [...(msg.to || []), ...(msg.cc || [])];\n for (const addr of addrs) {\n if (!addr?.address) continue;\n const name = addr.name || \"\";\n const both = name ? `${name} <${addr.address}>` : addr.address;\n if (name) {\n items.push({ label: `Copy name: ${name}`, action: () => navigator.clipboard.writeText(name) });\n }\n items.push({ label: `Copy address: ${addr.address}`, action: () => navigator.clipboard.writeText(addr.address) });\n if (name) {\n items.push({ label: `Copy both: ${both}`, action: () => navigator.clipboard.writeText(both) });\n }\n items.push({\n label: `Add to contacts: ${addr.address}`,\n action: async () => {\n await showAddContactDialog(name, addr.address);\n },\n });\n // \"Add to preferred\" \u2014 separate path: writes to\n // contacts.jsonc#preferred[] with an optional source tag.\n // Distinct from \"Add to contacts\" which goes into the DB +\n // pushes to Google. Preferred entries rank higher in\n // autocomplete and survive Google sync's churn.\n items.push({\n label: `Add to preferred: ${addr.address}`,\n action: async () => {\n const tag = prompt(\"Tag (e.g. work, family, vendor) \u2014 leave blank for default:\", \"\");\n if (tag === null) return; // user cancelled\n try {\n await addPreferredContact({ name, email: addr.address, source: tag.trim() || undefined });\n const status = document.getElementById(\"status-sync\");\n if (status) status.textContent = `Added to preferred: ${addr.address}${tag ? ` [${tag}]` : \"\"}`;\n } catch (e: any) {\n alert(`Couldn't add to preferred: ${e?.message || e}`);\n }\n },\n });\n // Mark / unmark sender as priority \u2014 visual highlight in\n // the message list (gold left bar + bold sender name);\n // strongest while unread, fades when read. Stored as\n // `priority: true` on the contacts.jsonc preferred[]\n // entry (auto-creates the entry if it doesn't exist).\n items.push({\n label: `\u2605 Mark sender as priority: ${addr.address}`,\n action: async () => {\n try {\n const { setPrioritySender } = await import(\"../lib/api-client.js\");\n await setPrioritySender(addr.address, true, name);\n const { refreshPriorityIndex } = await import(\"./message-list.js\");\n await refreshPriorityIndex();\n const status = document.getElementById(\"status-sync\");\n if (status) status.textContent = `Priority: ${addr.address}`;\n } catch (e: any) {\n alert(`Couldn't mark priority: ${e?.message || e}`);\n }\n },\n });\n items.push({ label: \"\", action: () => {}, separator: true });\n }\n items.push({ label: \"Reply\", action: () => document.dispatchEvent(new CustomEvent(\"mailx-compose\", { detail: { mode: \"reply\" } })) });\n items.push({ label: \"Reply All\", action: () => document.dispatchEvent(new CustomEvent(\"mailx-compose\", { detail: { mode: \"replyAll\" } })) });\n items.push({ label: \"Forward\", action: () => document.dispatchEvent(new CustomEvent(\"mailx-compose\", { detail: { mode: \"forward\" } })) });\n showContextMenu(me.clientX, me.clientY, items);\n });\n }\n headerEl.querySelector(\".mv-date\")!.textContent = new Date(msg.date).toLocaleString(undefined, { year: \"numeric\", month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\", hour12: false });\n\n // Unsubscribe button (upper right of header).\n // - One-Click (RFC 8058): POST via service; show result in status bar.\n // - Plain HTTPS URL: open externally for user confirmation.\n // - mailto: open a pre-filled compose so the reply uses the right account.\n const unsubBtn = document.getElementById(\"mv-unsubscribe\") as HTMLAnchorElement;\n const httpUrl = (msg as any).listUnsubscribeHttp || \"\";\n const mailUrl = (msg as any).listUnsubscribeMail || \"\";\n const oneClick = !!(msg as any).listUnsubscribeOneClick;\n const anyUrl = httpUrl || mailUrl || msg.listUnsubscribe || \"\";\n if (unsubBtn) {\n if (anyUrl) {\n unsubBtn.hidden = false;\n unsubBtn.textContent = \"Unsubscribe\";\n unsubBtn.removeAttribute(\"title\");\n unsubBtn.href = httpUrl || mailUrl || \"#\";\n unsubBtn.onclick = async (e) => {\n e.preventDefault();\n const status = document.getElementById(\"status-sync\");\n if (httpUrl && oneClick) {\n if (status) status.textContent = \"Unsubscribing\u2026\";\n try {\n const result = await unsubscribeOneClick(httpUrl);\n if (status) {\n status.textContent = result.ok\n ? `Unsubscribed (${result.status} ${result.statusText})`\n : `Unsubscribe failed: ${result.status} ${result.statusText}`;\n }\n } catch (err: any) {\n if (status) status.textContent = `Unsubscribe error: ${err?.message || err}`;\n }\n return;\n }\n if (httpUrl) {\n const api = (window as any).mailxapi;\n if (api?.openExternal) api.openExternal(httpUrl);\n else window.open(httpUrl, \"_blank\", \"noopener,noreferrer\");\n return;\n }\n if (mailUrl) {\n const m = mailUrl.match(/^mailto:([^?]*)(?:\\?(.*))?$/i);\n const to = m?.[1] ? decodeURIComponent(m[1]) : \"\";\n const qs = new URLSearchParams(m?.[2] || \"\");\n // 2026-05-13: when the unsubscribe-side specifies no\n // subject/body in the mailto, pre-fill from the\n // ORIGINAL message so the human (or filter) at the\n // other end sees what list this is for. Bare\n // mailto:unsub+<token>@... gives the recipient an\n // opaque token and no human-readable context.\n const origFrom = msg.from\n ? (msg.from.name ? `${msg.from.name} <${msg.from.address}>` : msg.from.address)\n : \"\";\n const origSubject = msg.subject || \"\";\n const origDate = msg.date\n ? new Date(msg.date).toLocaleString(undefined, { year: \"numeric\", month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\", hour12: false })\n : \"\";\n const subject = qs.get(\"subject\")\n || (origSubject ? `Unsubscribe: ${origSubject}` : \"Unsubscribe\");\n const inlineBody = qs.get(\"body\") || \"\";\n const escape = (s: string) => s\n .replace(/&/g, \"&\").replace(/</g, \"<\").replace(/>/g, \">\");\n const contextLines: string[] = [];\n if (origFrom) contextLines.push(`From: ${escape(origFrom)}`);\n if (origSubject) contextLines.push(`Subject: ${escape(origSubject)}`);\n if (origDate) contextLines.push(`Date: ${escape(origDate)}`);\n const contextBlock = contextLines.length\n ? `<p>\u2014<br>${contextLines.join(\"<br>\")}</p>`\n : \"\";\n const bodyHtml = inlineBody\n ? `<p>${escape(inlineBody)}</p>${contextBlock}`\n : `<p>Please unsubscribe me from this list.</p>${contextBlock}`;\n const init = {\n mode: \"new\",\n accountId: currentAccountId,\n to: to ? [{ name: \"\", address: to }] : [] as { name: string; address: string }[],\n cc: [] as { name: string; address: string }[],\n subject,\n bodyHtml,\n inReplyTo: \"\",\n references: [] as string[],\n accounts: [] as { id: string; name: string; email: string }[],\n };\n sessionStorage.setItem(\"composeInit\", JSON.stringify(init));\n document.dispatchEvent(new CustomEvent(\"mailx-compose\", { detail: { mode: \"new\" } }));\n }\n };\n } else {\n unsubBtn.hidden = true;\n }\n }\n\n // View Thread button \u2014 opens the thread popup from the message list\n // so the user can see all messages in the conversation. Works from\n // the viewer even when thread-grouping is off.\n const threadBtn = document.getElementById(\"mv-view-thread\") as HTMLButtonElement;\n if (threadBtn) {\n const tid = (msg as any).threadId || \"\";\n if (tid) {\n threadBtn.hidden = false;\n threadBtn.onclick = async () => {\n const { showThreadPopup } = await import(\"./message-list.js\");\n await showThreadPopup(threadBtn, { accountId, threadId: tid });\n };\n } else {\n threadBtn.hidden = true;\n }\n }\n\n // View Source button. CRITICAL: handler reads from the\n // `currentMessage` singleton, NOT a captured `msg` closure. If the\n // user clicks while a different message is mid-render the singleton\n // is null (cleared at top of showMessage) so the click is a no-op\n // \u2014 instead of returning the previous message's path. The principle\n // (per Bob): there is one source-of-truth identity for \"what's\n // shown\" and every action reads from it at click time.\n const srcBtn = document.getElementById(\"mv-view-source\") as HTMLButtonElement;\n if (srcBtn) {\n if (msg.emlPath) {\n srcBtn.hidden = false;\n srcBtn.title = `${msg.emlPath}\\n(click to copy path, double-click to open in default text editor)`;\n srcBtn.onclick = () => {\n const path = currentMessage?.emlPath;\n if (!path) return;\n navigator.clipboard.writeText(path).then(() => {\n const status = document.getElementById(\"status-sync\");\n if (status) status.textContent = `Path copied: ${path}`;\n }).catch(() => {\n prompt(\"EML file path:\", path);\n });\n };\n // Double-click opens the .eml in the OS default *text*\n // editor (Notepad / TextEdit / $EDITOR). Distinct from\n // the file's associated app, which for .eml is usually\n // Outlook \u2014 not what the user wants when inspecting the\n // raw MIME. Bob 2026-05-11: \"if anything it should open\n // up the default .txt editor.\"\n srcBtn.ondblclick = (e) => {\n e.preventDefault();\n const path = currentMessage?.emlPath;\n if (!path) return;\n import(\"../lib/api-client.js\").then(m => m.openInTextEditor(path)).then((r: any) => {\n const status = document.getElementById(\"status-sync\");\n if (status) {\n status.textContent = r?.ok\n ? `Opened in ${r.opener}: ${path}`\n : `Open failed: ${r?.reason || \"unknown\"}`;\n }\n }).catch((e: any) => {\n console.error(\"[mv] openInTextEditor failed:\", e);\n });\n };\n } else {\n srcBtn.hidden = true;\n srcBtn.onclick = null;\n srcBtn.ondblclick = null;\n }\n }\n\n // Drafts and Outbox messages open directly into compose. The Edit\n // Draft button stays as a fallback (and to keep the manual flow\n // discoverable), but selecting a draft no longer requires the user\n // to click it \u2014 opening a draft and editing it are the same action.\n // Guard: don't re-open compose for the SAME draft on every viewer\n // re-render (msg-state events fire on count updates etc.). And\n // don't auto-open while a compose overlay is already up \u2014 the user\n // is mid-edit on something and shouldn't lose focus to a new window.\n const editBtn = document.getElementById(\"mv-edit-draft\") as HTMLButtonElement;\n // Detect drafts by FLAG, not just folder. A message marked \\Draft\n // by the server is editable regardless of which folder the user\n // is currently viewing (e.g., clicking a draft from a thread in\n // INBOX, or from \"All Inboxes\" where currentSpecialUse is empty).\n // Bob 2026-05-12: \"How do I get into edit mode?\"\n const msgFlags: string[] = Array.isArray(msg?.flags) ? msg.flags : [];\n const flagged = msgFlags.includes(\"\\\\Draft\");\n const isDraft = flagged || specialUse === \"drafts\" || specialUse === \"outbox\";\n const openDraftInCompose = (cm: any) => {\n const init = {\n mode: \"draft\",\n accountId: currentAccountId,\n to: cm.to || [],\n cc: cm.cc || [],\n subject: cm.subject || \"\",\n bodyHtml: cm.bodyHtml || \"\",\n inReplyTo: cm.inReplyTo || \"\",\n references: cm.references || [],\n accounts: [] as { id: string; name: string; email: string }[],\n draftUid: cm.uid,\n draftFolderId: cm.folderId,\n };\n sessionStorage.setItem(\"composeInit\", JSON.stringify(init));\n document.dispatchEvent(new CustomEvent(\"mailx-compose\", { detail: { mode: \"draft\" } }));\n };\n if (editBtn) {\n if (isDraft) {\n editBtn.hidden = false;\n editBtn.textContent = specialUse === \"outbox\" ? \"Edit & Send\" : \"Edit Draft\";\n editBtn.onclick = () => { const cm = currentMessage; if (cm) openDraftInCompose(cm); };\n } else {\n editBtn.hidden = true;\n editBtn.onclick = null;\n }\n }\n if (isDraft && currentMessage\n && (currentMessage as any).uid !== lastAutoOpenedDraftUid\n && !document.querySelector(\".compose-overlay\")) {\n lastAutoOpenedDraftUid = (currentMessage as any).uid;\n openDraftInCompose(currentMessage);\n }\n\n // Details toggle \u2014 show extra headers (Delivered-To, Return-Path, Message-ID, etc.)\n const detailsEl = document.getElementById(\"mv-details\") as HTMLElement;\n const detailsBtn = document.getElementById(\"mv-toggle-details\") as HTMLButtonElement;\n if (detailsEl && detailsBtn) {\n // Q56: every row gets a Copy button so paths / IDs can be pasted\n // into accounts.jsonc hints or bug reports.\n const row = (label: string, value: string) =>\n `<div class=\"mv-details-row\"><span class=\"mv-details-label\">${label}:</span> <span class=\"mv-details-value\">${escapeText(value)}</span> <button type=\"button\" class=\"mv-details-copy\" data-copy=\"${escapeText(value).replace(/\"/g, \""\")}\" title=\"Copy\">\u29C9</button></div>`;\n const lines: string[] = [];\n if (msg.deliveredTo) lines.push(row(\"Delivered-To\", msg.deliveredTo));\n if (msg.returnPath) lines.push(row(\"Return-Path\", msg.returnPath));\n if (msg.messageId) lines.push(row(\"Message-ID\", msg.messageId));\n if (msg.listUnsubscribe) lines.push(row(\"Unsubscribe\", msg.listUnsubscribe));\n if (msg.emlPath) lines.push(row(\"EML file\", msg.emlPath));\n lines.push(row(\"Account\", accountId));\n lines.push(row(\"UID\", `${msg.uid} (folder ${msg.folderId})`));\n detailsEl.innerHTML = lines.join(\"\");\n detailsEl.hidden = true;\n detailsBtn.textContent = \"Details\";\n detailsBtn.onclick = () => {\n detailsEl.hidden = !detailsEl.hidden;\n detailsBtn.textContent = detailsEl.hidden ? \"Details\" : \"\\u2713 Details\";\n };\n // Wire copy buttons.\n detailsEl.querySelectorAll<HTMLButtonElement>(\".mv-details-copy\").forEach(btn => {\n btn.addEventListener(\"click\", async (e) => {\n e.stopPropagation();\n const val = btn.dataset.copy || \"\";\n try {\n await navigator.clipboard.writeText(val);\n btn.textContent = \"\u2713\";\n setTimeout(() => { btn.textContent = \"\u29C9\"; }, 1500);\n } catch {\n prompt(\"Copy:\", val);\n }\n });\n });\n }\n\n // Remote content banner (collapsible dropdown with sender/recipient details)\n bodyEl.innerHTML = \"\";\n if (msg.hasRemoteContent) {\n const senderAddr = msg.from?.address || \"\";\n const senderName = msg.from?.name || \"\";\n const senderDomain = senderAddr.split(\"@\")[1] || \"\";\n const deliveredTo = msg.deliveredTo || \"\";\n const toAddr = msg.to?.[0]?.address || \"\";\n const returnPath = msg.returnPath || \"\";\n const isFlagged = !!(msg as any).isFlagged;\n const reputation = (msg as any).reputation as {\n flagged: boolean; listedCount: number; checkedCount: number;\n sources: Array<{ service: string; verdict: string }>;\n verdict: string; service: string;\n } | null;\n const reputationFlagged = !!(reputation && reputation.flagged);\n const reputationText = reputationFlagged\n ? `\u26A0 ${reputation!.listedCount} of ${reputation!.checkedCount} reputation services flag <strong>${escapeText(senderDomain)}</strong> as <strong>${escapeText(reputation!.verdict)}</strong> (${escapeText(reputation!.sources.map(s => s.service).join(\", \"))})`\n : \"\";\n\n const banner = document.createElement(\"div\");\n banner.className = \"mv-remote-banner\"\n + (isFlagged ? \" mv-remote-banner-flagged\" : \"\")\n + (reputationFlagged ? \" mv-remote-banner-reputation\" : \"\");\n banner.innerHTML =\n (isFlagged\n ? `<div class=\"mv-rb-flagged\">\u26A0 Sender on your watch list (you marked this sender or domain suspicious)</div>`\n : \"\") +\n (reputationFlagged\n ? `<div class=\"mv-rb-reputation\">${reputationText}</div>`\n : \"\") +\n `<div class=\"mv-rb-summary\">` +\n `<span class=\"mv-rb-toggle\">▸</span>` +\n `<span>Remote content blocked</span>` +\n `<span class=\"mv-rb-buttons\">` +\n `<button id=\"btn-load-remote\">Load once</button>` +\n `<button id=\"btn-allow-sender\" title=\"${escapeText(senderAddr)}\">Always: ${escapeText(senderAddr)}</button>` +\n (senderDomain ? `<button id=\"btn-allow-domain\" title=\"*@${escapeText(senderDomain)}\">Always: *@${escapeText(senderDomain)}</button>` : \"\") +\n `</span>` +\n `</div>` +\n `<div class=\"mv-rb-details\" hidden>` +\n `<div class=\"mv-rb-info\">` +\n `<div><span class=\"mv-rb-label\">From:</span> ${escapeText(senderName ? `${senderName} <${senderAddr}>` : senderAddr)}</div>` +\n (deliveredTo ? `<div><span class=\"mv-rb-label\">Delivered-To:</span> ${escapeText(deliveredTo)}</div>` : \"\") +\n (toAddr && toAddr !== deliveredTo ? `<div><span class=\"mv-rb-label\">To:</span> ${escapeText(toAddr)}</div>` : \"\") +\n (returnPath && returnPath !== senderAddr ? `<div><span class=\"mv-rb-label\">Return-Path:</span> ${escapeText(returnPath)}</div>` : \"\") +\n `</div>` +\n (deliveredTo || toAddr ? `<div class=\"mv-rb-actions\"><button id=\"btn-allow-to\">Always allow to: ${escapeText(deliveredTo || toAddr)}</button></div>` : \"\") +\n `<div class=\"mv-rb-actions\">` +\n `<button id=\"btn-flag-sender\" class=\"mv-rb-flag-btn\" title=\"${escapeText(senderAddr)}\">${isFlagged ? \"Unwatch\" : \"Watch (mark suspicious)\"} sender</button>` +\n (senderDomain ? `<button id=\"btn-flag-domain\" class=\"mv-rb-flag-btn\" title=\"*@${escapeText(senderDomain)}\">Watch domain *@${escapeText(senderDomain)}</button>` : \"\") +\n `</div>` +\n `<div class=\"mv-rb-actions\"><button id=\"btn-edit-allowlist\" title=\"View / edit the full allowlist\">Edit allowlist\u2026</button></div>` +\n `</div>`;\n bodyEl.appendChild(banner);\n\n // Toggle dropdown \u2014 click arrow or text to expand details\n const summary = banner.querySelector(\".mv-rb-summary\")!;\n const details = banner.querySelector(\".mv-rb-details\") as HTMLElement;\n const toggle = banner.querySelector(\".mv-rb-toggle\")!;\n summary.addEventListener(\"click\", (e) => {\n if ((e.target as HTMLElement).tagName === \"BUTTON\") return;\n details.hidden = !details.hidden;\n toggle.textContent = details.hidden ? \"\\u25B8\" : \"\\u25BE\";\n });\n\n const loadRemote = async () => {\n banner.remove();\n const full = await getMessage(accountId, uid, true);\n if (full.bodyHtml) {\n const oldIframe = bodyEl.querySelector(\"iframe\");\n if (oldIframe) oldIframe.remove();\n const iframe = document.createElement(\"iframe\");\n iframe.srcdoc = wrapHtmlBody(full.bodyHtml, true);\n bodyEl.appendChild(iframe);\n installPreviewControls(iframe);\n }\n // Critical: refresh both `currentMessage` and the parsedCache\n // with the unblocked version. Otherwise any later showMessage\n // call (folder refresh, sync event, etc.) hits the stale\n // cached BLOCKED message, re-renders the banner, and the\n // user's click looks like it was forgotten \u2014 even though the\n // allowlist persist actually worked. Bob 2026-05-09:\n // \"you added it to allowed but don't know it!\"\n currentMessage = full;\n currentAccountId = accountId;\n parsedCachePut(accountId, uid, full);\n markSessionAllowed(accountId, uid);\n };\n\n banner.querySelector(\"#btn-load-remote\")!.addEventListener(\"click\", loadRemote);\n\n // Always-allow handlers: render the unblocked view IMMEDIATELY,\n // then persist the allowlist entry in the background. Pre-fix\n // these awaited the persist before re-rendering \u2014 if the persist\n // threw (GDrive mount unavailable, atomicWrite failure, IPC\n // error), the unhandled rejection silently aborted the handler\n // and the click looked like a no-op. Optimistic order matches\n // the rest of the local-first model: the user's action commits\n // visually, sync surfaces problems via the status bar.\n const persistAllow = async (type: \"sender\" | \"domain\" | \"recipient\", value: string): Promise<void> => {\n try {\n await allowRemoteContent(type, value);\n } catch (e: any) {\n const status = document.getElementById(\"status-sync\");\n if (status) {\n status.textContent = `Allowlist save failed (${type}: ${value}) \u2014 ${e?.message || e}`;\n status.style.color = \"oklch(0.65 0.2 25)\";\n }\n console.error(`[allowlist] ${type}=${value} save failed:`, e);\n }\n };\n\n banner.querySelector(\"#btn-allow-sender\")?.addEventListener(\"click\", () => {\n loadRemote();\n persistAllow(\"sender\", senderAddr);\n });\n\n banner.querySelector(\"#btn-allow-domain\")?.addEventListener(\"click\", () => {\n loadRemote();\n persistAllow(\"domain\", senderDomain);\n });\n\n banner.querySelector(\"#btn-allow-to\")?.addEventListener(\"click\", () => {\n const addr = deliveredTo || toAddr;\n if (!addr) return;\n loadRemote();\n persistAllow(\"recipient\", addr);\n });\n\n // Watch (or unwatch) sender / domain \u2014 toggles the allowlist's\n // flaggedSenders / flaggedDomains lists (kept under the legacy\n // JSONC field names for backwards compat; UI now uses \"watch /\n // suspicious\" wording to disambiguate from the per-message \u2691/\u2605\n // marker which means the OPPOSITE \u2014 \"important to me\"). Future\n // messages from this sender/domain show a \u26A0 banner at the top\n // of the viewer. Note: marking a single sender is unreliable\n // because spammers rotate addresses \u2014 see TODO for rules.jsonc\n // which will support pattern-based matching as a primary tool.\n const onFlagToggle = async (type: \"sender\" | \"domain\", value: string) => {\n if (!value) return;\n try {\n const result = await flagSenderOrDomain(type, value);\n const status = document.getElementById(\"status-sync\");\n if (status) status.textContent = result.flagged\n ? `Watching ${type}: ${value}`\n : `Unwatched ${type}: ${value}`;\n // Re-render this message so the banner picks up the new\n // flagged state without the user having to reselect.\n // `isRetry=true` bypasses the same-message guard at the\n // top of showMessage \u2014 we genuinely want a fresh paint\n // here, even though scroll position will reset (the\n // user just clicked a control, so scroll loss is\n // expected, not surprising).\n if (currentMessage) {\n invalidateParsedCache(currentAccountId, currentMessage.uid);\n showMessage(currentAccountId, currentMessage.uid, currentMessage.folderId, specialUse, true).catch(() => { /* */ });\n }\n } catch (e: any) {\n const status = document.getElementById(\"status-sync\");\n if (status) status.textContent = `Watch failed: ${e?.message || e}`;\n }\n };\n banner.querySelector(\"#btn-flag-sender\")?.addEventListener(\"click\", () => onFlagToggle(\"sender\", senderAddr));\n banner.querySelector(\"#btn-flag-domain\")?.addEventListener(\"click\", () => onFlagToggle(\"domain\", senderDomain));\n\n // \"Edit allowlist\u2026\" \u2014 fires a document-level event that app.ts\n // listens for and opens the JSONC editor pre-selected to\n // allowlist.jsonc. Keeps message-viewer free of the editor import.\n banner.querySelector(\"#btn-edit-allowlist\")?.addEventListener(\"click\", () => {\n document.dispatchEvent(new CustomEvent(\"mailx-open-jsonc-editor\", { detail: { file: \"allowlist.jsonc\" } }));\n });\n\n }\n\n // bodyError / bodyErrorTransient retired in the local-first refactor.\n // Background body fetches that fail emit `bodyFetchError`, surfaced\n // by the module-level listener as a banner \u2014 no inline retry.\n\n // Body in sandboxed iframe\n if (msg.bodyHtml) {\n const iframe = document.createElement(\"iframe\");\n iframe.sandbox.add(\"allow-same-origin\");\n iframe.sandbox.add(\"allow-popups\");\n iframe.sandbox.add(\"allow-popups-to-escape-sandbox\");\n iframe.sandbox.add(\"allow-top-navigation-by-user-activation\");\n // allow-scripts lets OUR injected <script> run (for Android link\n // interception \u2014 parent-side contentDocument listeners don't fire\n // reliably on Android WebView). CSP with a nonce restricts script\n // execution to our tag only; inline scripts in the email body are\n // still blocked.\n iframe.sandbox.add(\"allow-scripts\");\n iframe.srcdoc = wrapHtmlBody(msg.bodyHtml, msg.remoteAllowed);\n // Two timing marks: text-painted (DOMContentLoaded, which fires\n // as soon as the HTML is parsed \u2014 typically tens of ms) and\n // all-loaded (load, which waits for every <img> to resolve and\n // can be 10+ s on a marketing email with remote content). The\n // user can read the email at the DOMContentLoaded mark; the\n // earlier single `load` mark misled the timing story.\n iframe.addEventListener(\"load\", () => _ptick(\"iframe load (all resources)\"), { once: true });\n const onReady = () => {\n const doc = iframe.contentDocument;\n if (!doc) return;\n const fire = () => _ptick(\"iframe DOMContentLoaded (text painted)\");\n if (doc.readyState === \"interactive\" || doc.readyState === \"complete\") fire();\n else doc.addEventListener(\"DOMContentLoaded\", fire, { once: true });\n };\n // srcdoc-driven iframes go through a single `load` lifecycle, but\n // the contentDocument is observable as soon as the iframe is in\n // the DOM. Schedule the readiness check after append.\n bodyEl.appendChild(iframe);\n queueMicrotask(onReady);\n _ptick(\"iframe srcdoc set\");\n installPreviewControls(iframe);\n } else if (msg.bodyText) {\n const pre = document.createElement(\"pre\");\n // Match the HTML-body branch's typography: sans-serif system\n // font, same size + line-height. Default `<pre>` rendering is\n // monospace, which on some platforms substitutes to a serif\n // fallback (Bob 2026-05-12: \"why the font change\") and looks\n // like a different message from the same conversation.\n pre.style.cssText = \"padding: 1rem; white-space: pre-wrap; word-break: break-word; \"\n + \"font-family: system-ui, sans-serif; font-size: 17.5px; line-height: 1.5; \"\n + \"color: #1a1a2e; background: #fff; margin: 0;\";\n // Auto-linkify URLs in plain text\n pre.innerHTML = linkifyText(msg.bodyText);\n bodyEl.appendChild(pre);\n } else {\n // No bodyHtml AND no bodyText. The daemon thinks the body is\n // on disk (`cached === true`) but extracted nothing. Most\n // common: the body fetch errored earlier (network blip,\n // 401, 5xx), the row was marked cached anyway, and we have\n // an empty .eml. Surface the last known fetch error for\n // this uid if we have one \u2014 Bob 2026-05-09: \"if there is a\n // fail to fetch then report the problem rather than telling\n // me there is no content.\"\n const fetchErr = recentFetchErrors.get(`${accountId}:${uid}`);\n if (fetchErr) {\n bodyEl.innerHTML = `<div class=\"mv-system-message mv-system-error\">\n <div class=\"mv-system-tag\">mailx</div>\n <div class=\"mv-system-title\">Body fetch failed</div>\n <div class=\"mv-system-body\">${escapeHtml(fetchErr.error)}<br><span style=\"color:var(--color-text-muted);font-size:0.9em\">Recorded ${Math.round((Date.now() - fetchErr.when) / 1000)}s ago. ${fetchErr.transient ? \"Will retry automatically.\" : \"Permanent \u2014 server-side delete may have raced.\"}</span></div>\n </div>`;\n } else {\n // Empty parse result. Surface every diagnostic crumb the\n // user could use to tell \"real empty body\" from \"stub file\n // / parser bug / fetch incomplete\". Bob 2026-05-15: \"if\n // bodies are on disk per the blue dot then a 0-byte parse\n // is a bug \u2014 show as much useful information as possible.\"\n const emlPath = (msg as any).emlPath || \"\";\n const attCount = msg.attachments?.length || 0;\n const flags = Array.isArray((msg as any).flags) ? (msg as any).flags.join(\", \") : \"\";\n // App version on this diagnostic so a screenshot is traceable\n // to a build. Read from the version element the toolbar/status\n // bar already populates; falls back to \"?\" if not yet set.\n const appVer = (document.querySelector(\".app-version, #app-version, #status-version\")?.textContent || \"\").trim();\n bodyEl.innerHTML = `<div class=\"mv-system-message\">\n <div class=\"mv-system-tag\">mailx${appVer ? \" \" + escapeHtml(appVer) : \"\"}</div>\n <div class=\"mv-system-title\">Body parsed empty</div>\n <div class=\"mv-system-body\" style=\"white-space:pre-line\">${escapeHtml([\n `Parser produced 0 bytes for both bodyHtml and bodyText.`,\n emlPath ? `.eml file: ${emlPath}` : `.eml file: (no body_path in DB)`,\n `Attachments: ${attCount}`,\n flags ? `Flags: ${flags}` : \"\",\n `Message: ${accountId}/${uid}${folderId ? ` (folder ${folderId})` : \"\"}`,\n ``,\n `Likely causes: (a) sender included only attachments/images, (b) the fetched .eml is a stub (0 bytes on disk \u2014 fetch was incomplete), (c) the body is signed/encrypted in a way the parser doesn't unwrap.`,\n ].filter(Boolean).join(\"\\n\"))}</div>\n </div>`;\n }\n }\n\n // Attachments \u2014 always clear first to avoid stale chips from previous message\n attEl.innerHTML = \"\";\n attEl.hidden = true;\n if (msg.attachments?.length) {\n attEl.hidden = false;\n for (let i = 0; i < msg.attachments.length; i++) {\n const att = msg.attachments[i];\n // Button (not <a href=\"#\">): the browser/WebView2 native\n // context menu offered \"Open in new window\" which navigated\n // to msger.localhost/index.html# and 404'd. Switching to a\n // button removes that path; download happens via a\n // programmatic <a download> click which is reliable across\n // msger / WebView2 / Android WebView (window.open of a blob\n // URL is silently blocked in some hosts).\n const chip = document.createElement(\"button\");\n chip.type = \"button\";\n chip.className = \"mv-att-chip\";\n chip.textContent = `\\uD83D\\uDCCE ${att.filename} (${formatSize(att.size)})`;\n chip.title = `${att.filename} (${att.mimeType})`;\n chip.addEventListener(\"click\", async (e) => {\n e.preventDefault();\n // Click feedback + re-click guard. Opening hands off to the\n // OS viewer (~200ms) with no visible change in mailx, so the\n // user used to click 3-4 times. Show \"Opening\u2026\" for at least\n // 600ms and ignore clicks while it's in flight.\n if (chip.dataset.opening === \"1\") return;\n chip.dataset.opening = \"1\";\n const chipLabel = chip.textContent || \"\";\n chip.textContent = \"\u23F3 Opening\u2026\";\n const tStart = Date.now();\n const restoreChip = (): void => {\n const wait = Math.max(0, 600 - (Date.now() - tStart));\n setTimeout(() => { chip.textContent = chipLabel; chip.dataset.opening = \"\"; }, wait);\n };\n try {\n const bridge = (window as any)._nativeBridge;\n if (bridge?.openAttachment) {\n // Android: blob URLs don't work in WebView. Pass base64\n // to native bridge which saves to Downloads and opens\n // with the system viewer.\n const data = await getAttachment(accountId, uid, i, msg.folderId);\n await bridge.openAttachment(att.filename, data.contentType, data.content);\n return;\n }\n // Desktop (IPC): the Node service saves the file and opens\n // it with the OS default app. openAttachment() returns the\n // result object when an IPC host handled it, or `undefined`\n // only when the host has no such method (real browser /\n // --server mode). A genuine service failure throws \\u2014 we do\n // NOT blob-fall-back in that case (the service is the only\n // thing that can open a file on the desktop, and the blob\n // path just produced a misleading second error).\n const res = await openAttachment(accountId, uid, i, msg.folderId);\n if (res) return;\n // res === undefined \\u2192 real browser / --server mode only.\n const data = await getAttachment(accountId, uid, i, msg.folderId);\n const bytes = Uint8Array.from(atob(data.content), c => c.charCodeAt(0));\n const blob = new Blob([bytes], { type: data.contentType });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = att.filename || \"attachment\";\n a.style.display = \"none\";\n document.body.appendChild(a);\n a.click();\n // Defer revoke until the download has had time to start.\n setTimeout(() => { a.remove(); URL.revokeObjectURL(url); }, 5000);\n } catch (err: any) {\n // Non-blocking banner \\u2014 never alert(): a modal alert\n // freezes the WebView event loop, which stalls IPC event\n // delivery and makes sync look like it stopped.\n const m = `Couldn't open \"${att.filename}\": ${err?.message || err}`;\n console.error(m);\n window.dispatchEvent(new CustomEvent(\"mailx-alert\", { detail: { message: m, key: \"attachment-open\" } }));\n } finally {\n restoreChip();\n }\n });\n // Drag the chip to an external target (Explorer / Finder / Files app)\n // to drop the file there. Uses the Chromium `DownloadURL` dataTransfer\n // format: \"mime:filename:blob-url\". We fetch the attachment first so\n // the blob URL is valid by the time the drop lands.\n chip.draggable = true;\n chip.addEventListener(\"dragstart\", async (e) => {\n if (!e.dataTransfer) return;\n try {\n const data = await getAttachment(accountId, uid, i, msg.folderId);\n const bytes = Uint8Array.from(atob(data.content), c => c.charCodeAt(0));\n const blob = new Blob([bytes], { type: data.contentType || \"application/octet-stream\" });\n const url = URL.createObjectURL(blob);\n // Sanitize filename: no path separators, no newlines.\n const safeName = (att.filename || \"attachment\").replace(/[\\r\\n\"\\/\\\\]/g, \"_\");\n const downloadUrl = `${data.contentType || \"application/octet-stream\"}:${safeName}:${url}`;\n e.dataTransfer.setData(\"DownloadURL\", downloadUrl);\n e.dataTransfer.effectAllowed = \"copy\";\n } catch (err: any) {\n console.error(`Attachment drag-out failed: ${err.message || err}`);\n }\n });\n attEl.appendChild(chip);\n }\n }\n } catch (e: any) {\n const err = e.message || \"Unknown error\";\n console.error(\"showMessage error:\", e);\n // \"Message was deleted from the server\" \u2014 the service already dropped\n // the local row. Remove it from the list so the UI advances to the next\n // message instead of sitting on a stale error banner.\n const isNotFound = /deleted from the server|isNotFound|not found|Not Found|404/.test(err);\n if (isNotFound) {\n // Drop the stale row so the list auto-advances to the next message\n // (or clears the viewer). Leaves the user a way back on mobile where\n // the viewer takes the whole screen. The list owns focus handoff \u2014\n // we surface the removal as an event and let it run its own\n // removeMessages flow (which both filters the list and re-focuses\n // a survivor or clears this pane).\n document.dispatchEvent(new CustomEvent(\"mailx-remove-stale\", {\n detail: { accountId, uid },\n }));\n return;\n }\n if (retryCount < 3) {\n retryCount++;\n bodyEl.innerHTML = `<div class=\"mv-empty\">Loading failed: ${err} \u2014 retrying (${retryCount}/3)...</div>`;\n setTimeout(() => { if (gen === showMessageGeneration) showMessage(accountId, uid, folderId, specialUse, true); }, 3000);\n } else {\n bodyEl.innerHTML = `<div class=\"mv-empty\">Failed to load: ${err}</div>`;\n }\n }\n}\n\nfunction formatAddr(addr: { name: string; address: string }): string {\n if (addr.name) return `${addr.name} <${addr.address}>`;\n return addr.address;\n}\n\n/** Render the viewer header from a list-row envelope (instant \u2014 no body\n * fetch awaited). Used to populate the header pane the moment a message\n * is clicked so the user always sees something actionable; getMessage()\n * later overwrites the same fields with the authoritative values from the\n * body parse (which can add Cc, Delivered-To, etc. that the list row\n * doesn't track). */\nfunction renderHeaderFromEnvelope(headerEl: HTMLElement, env: any): void {\n headerEl.hidden = false;\n const fromEl = headerEl.querySelector(\".mv-from\");\n const toEl = headerEl.querySelector(\".mv-to\");\n const subjEl = headerEl.querySelector(\".mv-subject\");\n const dateEl = headerEl.querySelector(\".mv-date\");\n if (fromEl) fromEl.textContent = formatAddr(env.from);\n if (toEl) {\n let toLine = `To: ${(env.to || []).map(formatAddr).join(\", \")}`;\n if (env.cc?.length) toLine += ` Cc: ${env.cc.map(formatAddr).join(\", \")}`;\n toEl.textContent = toLine;\n }\n if (subjEl) subjEl.textContent = env.subject || \"\";\n if (dateEl) {\n try { dateEl.textContent = new Date(env.date).toLocaleString(); }\n catch { dateEl.textContent = \"\"; }\n }\n}\n\nfunction escapeHtml(s: string): string {\n return (s || \"\").replace(/[&<>\"']/g, c =>\n ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", \"\\\"\": \""\", \"'\": \"'\" }[c]!));\n}\n\n/** Convert plain text URLs into clickable links, escaping HTML */\nfunction linkifyText(text: string): string {\n // Escape HTML first\n const escaped = text.replace(/&/g, \"&\").replace(/</g, \"<\").replace(/>/g, \">\");\n // Then linkify URLs\n return escaped.replace(\n /(https?:\\/\\/[^\\s<>\"')\\]]+)/g,\n '<a href=\"$1\" target=\"_blank\" rel=\"noopener noreferrer\">$1</a>'\n );\n}\n\nfunction escapeText(s: string): string {\n const div = document.createElement(\"div\");\n div.textContent = s;\n return div.innerHTML;\n}\n\n/** Minimal add-contact modal: name + email + organization with a duplicate\n * check (checks the contacts DB for an existing row with the same email\n * and surfaces it so the user can update instead of creating a second\n * row with a different name). Future: AI-extracted fields from the letter\n * body populate the form before it opens. */\nasync function showAddContactDialog(nameIn: string, emailIn: string): Promise<void> {\n let dup: { name: string; email: string; source: string } | null = null;\n try {\n const existing = await listContacts(emailIn, 1, 10);\n const match = (existing?.items || []).find((c: any) => (c.email || \"\").toLowerCase() === emailIn.toLowerCase());\n if (match) dup = match;\n } catch { /* non-fatal \u2014 dialog still works without dup info */ }\n\n const backdrop = document.createElement(\"div\");\n backdrop.className = \"mailx-modal-backdrop\";\n const panel = document.createElement(\"div\");\n panel.className = \"mailx-modal\";\n panel.innerHTML = `\n <div class=\"mailx-modal-title\">\n <span class=\"mailx-modal-title-text\">${dup ? \"Update contact\" : \"Add contact\"}</span>\n <button type=\"button\" class=\"mailx-modal-close\" id=\"ac-close\" aria-label=\"Close\">×</button>\n </div>\n ${dup ? `<div class=\"mailx-modal-info\">Already in address book as <strong>${escapeText(dup.name || \"(no name)\")}</strong> (${escapeText(dup.source)}). Saving will update the name.</div>` : \"\"}\n <label class=\"mailx-modal-label\">Name\n <input class=\"mailx-modal-input\" id=\"ac-name\" type=\"text\" value=\"${escapeText(dup?.name || nameIn || \"\")}\" autofocus>\n </label>\n <label class=\"mailx-modal-label\">Email\n <input class=\"mailx-modal-input\" id=\"ac-email\" type=\"email\" value=\"${escapeText(emailIn)}\" readonly>\n </label>\n <label class=\"mailx-modal-label\">Organization <span style=\"color:var(--color-text-muted);font-size:0.85em\">(optional)</span>\n <input class=\"mailx-modal-input\" id=\"ac-org\" type=\"text\" placeholder=\"\">\n </label>\n <div class=\"mailx-modal-buttons\">\n <span class=\"mailx-modal-spacer\"></span>\n <button type=\"button\" class=\"mailx-modal-btn\" data-action=\"cancel\">Cancel</button>\n <button type=\"button\" class=\"mailx-modal-btn mailx-modal-btn-primary\" data-action=\"save\">${dup ? \"Update\" : \"Save\"}</button>\n </div>`;\n backdrop.appendChild(panel);\n document.body.appendChild(backdrop);\n\n const close = () => backdrop.remove();\n panel.querySelector<HTMLButtonElement>(\"#ac-close\")!.addEventListener(\"click\", close);\n panel.querySelectorAll<HTMLButtonElement>(\".mailx-modal-btn\").forEach(btn => {\n btn.addEventListener(\"click\", async () => {\n if (btn.dataset.action === \"cancel\") { close(); return; }\n const nameEl = panel.querySelector<HTMLInputElement>(\"#ac-name\")!;\n const emailEl = panel.querySelector<HTMLInputElement>(\"#ac-email\")!;\n btn.disabled = true;\n btn.textContent = \"Saving\u2026\";\n try {\n // upsertContact is the two-way cache path (enqueues a Google\n // People push); for pure local-first addContact would also\n // work but skips the Google sync. Use upsertContact so the\n // row propagates to Google Contacts next drain tick.\n await upsertContact(nameEl.value.trim(), emailEl.value.trim());\n close();\n } catch (e: any) {\n btn.disabled = false;\n btn.textContent = dup ? \"Update\" : \"Save\";\n alert(`Couldn't save: ${e?.message || e}`);\n }\n });\n });\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") { close(); document.removeEventListener(\"keydown\", onKey, true); }\n };\n document.addEventListener(\"keydown\", onKey, true);\n // addContact is kept as a legacy silent path (no-form) for any caller\n // that still invokes it \u2014 currently none after this refactor.\n void addContact;\n}\n\nfunction formatSize(bytes: number): string {\n if (bytes < 1024) return `${bytes} B`;\n if (bytes < 1048576) return `${(bytes / 1024).toFixed(0)} KB`;\n return `${(bytes / 1048576).toFixed(1)} MB`;\n}\n\nexport function wrapHtmlBody(html: string, allowRemote = false): string {\n // CSP blocks remote resources (tracking pixels, external CSS). Inline\n // scripts are allowed via 'unsafe-inline' so our injected link-tap handler\n // runs; email-body <script> tags and on* handlers are stripped server-side\n // by sanitizeHtml() in mailx-types, so this doesn't actually widen the\n // attack surface. (A per-render nonce would be tidier, but meta-CSP with\n // nonces isn't reliably honored across older WebViews \u2014 and when a nonce\n // is present, 'unsafe-inline' is ignored, so our script fell back to\n // blocked on those WebViews.)\n const csp = allowRemote\n ? \"\"\n : `<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: cid:; form-action 'none';\">`;\n return `<!DOCTYPE html>\n<html><head>\n<meta charset=\"UTF-8\">\n${csp}\n<style>\n html, body { touch-action: pan-y pinch-zoom; }\n html { height: 100%; overflow-y: auto; overflow-x: hidden; }\n body {\n font-family: system-ui, sans-serif;\n font-size: 17.5px;\n line-height: 1.5;\n color: #1a1a2e;\n background: #fff;\n padding: 1rem;\n margin: 0;\n min-height: 100%;\n word-break: break-word;\n color-scheme: dark light;\n }\n img { max-width: 100%; height: auto; }\n a { color: #1a6dd4; }\n pre, code { white-space: pre-wrap; }\n blockquote { border-left: 3px solid #ccc; padding-left: 1rem; margin-left: 0; color: #666; }\n @media (prefers-color-scheme: dark) {\n body { color: #cdd6f4; background: #282840; }\n a { color: #89b4fa; }\n blockquote { border-color: #45475a; color: #6c7086; }\n }\n</style>\n<base target=\"_blank\">\n<script>\n// Link interception \u2014 Android WebView doesn't fire the default <a target=\"_blank\">\n// new-window handler, so we postMessage to the parent which routes through the\n// native bridge (mailxapi://openurl) to Launcher.OpenAsync.\n(function () {\n function handleLinkTap(e) {\n var a = e.target && e.target.closest ? e.target.closest(\"a[href]\") : null;\n if (!a) return;\n var url = a.href;\n if (!url || url.indexOf(\"javascript:\") === 0 || url.charAt(0) === \"#\") return;\n e.preventDefault();\n e.stopPropagation();\n window.parent.postMessage({ type: \"linkClick\", url: url }, \"*\");\n }\n document.addEventListener(\"click\", handleLinkTap, true);\n // Android WebView fallback: some builds drop the synthetic click after\n // touchend, so treat a stationary touchstart\u2192touchend on the same link\n // as a tap. Anything that moves more than TAP_SLOP pixels is a scroll\n // and must NOT activate the link.\n var TAP_SLOP = 10;\n var lastTouchTarget = null;\n var lastTouchX = 0;\n var lastTouchY = 0;\n var touchMoved = false;\n // All touch listeners are passive so Android WebView can compositor-scroll\n // the iframe without waiting on our JS. handleLinkTap's preventDefault only\n // matters for the \"click\" path (which is non-passive by default).\n document.addEventListener(\"touchstart\", function (e) {\n var t0 = e.touches && e.touches[0];\n lastTouchX = t0 ? t0.clientX : 0;\n lastTouchY = t0 ? t0.clientY : 0;\n touchMoved = false;\n lastTouchTarget = (e.target && e.target.closest) ? e.target.closest(\"a[href]\") || e.target : e.target;\n }, { passive: true, capture: true });\n document.addEventListener(\"touchmove\", function (e) {\n if (touchMoved) return;\n var t = e.touches && e.touches[0];\n if (!t) return;\n if (Math.abs(t.clientX - lastTouchX) > TAP_SLOP || Math.abs(t.clientY - lastTouchY) > TAP_SLOP) {\n touchMoved = true;\n lastTouchTarget = null;\n }\n }, { passive: true, capture: true });\n document.addEventListener(\"touchend\", function (e) {\n if (touchMoved) { lastTouchTarget = null; touchMoved = false; return; }\n var t = (e.target && e.target.closest) ? e.target.closest(\"a[href]\") || e.target : e.target;\n if (lastTouchTarget && lastTouchTarget === t) handleLinkTap(e);\n lastTouchTarget = null;\n }, { passive: true, capture: true });\n document.addEventListener(\"touchcancel\", function () {\n lastTouchTarget = null; touchMoved = false;\n }, { passive: true, capture: true });\n // Link hover preview \u2014 posts the link URL to the parent on hover so\n // the parent can show a tooltip after a 500 ms dwell. Earlier removal\n // (2026-04-24) was because dismissers in the parent (mousedown / scroll\n // / blur) don't fire while the mouse is inside the iframe, leaving the\n // popover stuck. Restored 2026-05-09 with the proper fix: the iframe\n // explicitly posts an empty-URL message on link mouseout AND on body\n // mousemove-away, so the parent reliably hides regardless of which\n // dismissers it sees. The parent's debounced 500 ms show-timer still\n // suppresses flicker.\n var lastHoveredHref = \"\";\n function postLinkHover(href, rect) {\n // Only post on transitions to avoid spamming the parent on every\n // mousemove inside a link.\n if (href === lastHoveredHref) return;\n lastHoveredHref = href;\n window.parent.postMessage({\n type: \"linkHover\",\n url: href,\n rect: rect ? { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom } : null,\n }, \"*\");\n }\n document.addEventListener(\"mouseover\", function (e) {\n var a = e.target && e.target.closest ? e.target.closest(\"a[href]\") : null;\n if (!a) return;\n var href = a.getAttribute(\"href\") || \"\";\n if (!href || href.charAt(0) === \"#\" || href.indexOf(\"javascript:\") === 0) return;\n var r = a.getBoundingClientRect();\n postLinkHover(a.href || href, r);\n }, true);\n document.addEventListener(\"mouseout\", function (e) {\n var a = e.target && e.target.closest ? e.target.closest(\"a[href]\") : null;\n if (!a) return;\n // Only fire when leaving the link entirely (not into a child element).\n var to = e.relatedTarget;\n if (to && to.closest && to.closest(\"a[href]\") === a) return;\n postLinkHover(\"\", null);\n }, true);\n // Mouseleave on body covers the case where the cursor exits the\n // iframe entirely without firing mouseout on the link (Edge / older\n // WebView2 builds). Belt-and-suspenders dismissal.\n document.addEventListener(\"mouseleave\", function () {\n if (lastHoveredHref) postLinkHover(\"\", null);\n }, true);\n // Note: iframe-level dblclick fullscreen toggle REMOVED \u2014 it hijacked\n // word-selection (Bob 2026-05-11). Double-clicking text inside the\n // preview now selects the word like any other browser surface; the\n // fullscreen toggle lives on the viewer chrome (mv-header) handler\n // in app.ts, which explicitly excludes interactive controls but\n // operates on chrome, not iframe content.\n\n // Receive commands from the parent (Copy / Select all menu actions).\n // Browsers restrict execCommand(\"copy\") to the document where focus\n // lives \u2014 running it in the parent against the iframe's selection is\n // a no-op, so the parent posts the command in and we run it here.\n window.addEventListener(\"message\", function (e) {\n var d = e.data;\n if (!d || d.type !== \"previewCommand\") return;\n if (d.cmd === \"copy\") {\n try { document.execCommand(\"copy\"); } catch (_) {}\n } else if (d.cmd === \"selectAll\") {\n try {\n var s = window.getSelection && window.getSelection();\n if (s) {\n var r = document.createRange();\n r.selectNodeContents(document.body);\n s.removeAllRanges();\n s.addRange(r);\n }\n } catch (_) {}\n }\n });\n\n // Right-click \u2192 unified context menu. Always send 'previewContextMenu'\n // with both link info (when over a link) AND selection / body access.\n // Quora-style pages are wall-to-wall links \u2014 branching on target meant\n // body actions (Translate / Zoom) were unreachable. Combined menu\n // shows link actions first when applicable, body actions always.\n // Suppresses the native WebView2 menu unconditionally \u2014 without that,\n // its Back/Refresh/Save-as/Inspect menu fires before the parent\n // contentDocument listener attached.\n document.addEventListener(\"contextmenu\", function (e) {\n e.preventDefault();\n e.stopPropagation();\n var rect = { left: 0, top: 0 };\n try { rect = e.target.getBoundingClientRect ? e.target.getBoundingClientRect() : rect; } catch (_) {}\n var a = e.target && e.target.closest ? e.target.closest(\"a[href]\") : null;\n var sel = (window.getSelection && window.getSelection()) ? window.getSelection().toString() : \"\";\n window.parent.postMessage({\n type: \"previewContextMenu\",\n selectedText: sel,\n linkUrl: a ? a.href : \"\",\n linkText: a ? (a.textContent || \"\").slice(0, 100) : \"\",\n x: e.clientX, y: e.clientY,\n iframeLeft: rect.left, iframeTop: rect.top\n }, \"*\");\n });\n // Iframe pointerdown \u2192 tell the parent to dismiss any open context menu.\n // The parent's dismissListener listens to its own document's pointerdown,\n // but iframe events don't bubble across the boundary; without this, a\n // click in the message body to dismiss the menu would be invisible to\n // the parent and the menu would stick open.\n document.addEventListener(\"pointerdown\", function (e) {\n // The contextmenu handler above ALSO fires pointerdown on right-click;\n // that one opens a fresh menu, which already closes the previous.\n // Skip right-click (button 2) to avoid a publish/close race.\n if (e.button === 2) return;\n try { window.parent.postMessage({ type: \"iframePointerDown\" }, \"*\"); } catch (_) {}\n }, true);\n // Key forwarding \u2014 Delete, Ctrl+D, arrow keys, etc. need to reach app.ts\n // even when focus is inside the sandboxed iframe. Parent-side\n // contentDocument listeners (see installPreviewControls) work on\n // desktop WebView2 but not Android WebView, so we post every keydown\n // that isn't plain typing.\n document.addEventListener(\"keydown\", function (e) {\n var t = e.target;\n if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;\n // Zoom keys handled by parent-side installPreviewControls; don't double-send.\n if (e.ctrlKey && (e.key === \"=\" || e.key === \"+\" || e.key === \"-\" || e.key === \"0\")) return;\n // Preventing the iframe's default for keys we forward to the parent\n // is essential \u2014 the parent's preventDefault on the synthetic\n // keydown can't suppress the browser's reaction to the ORIGINAL\n // event (Ctrl+R reload, Ctrl+F find, etc.). Suppress here so the\n // browser doesn't act before the parent processes the action.\n var k = (e.key || \"\").toLowerCase();\n var isShortcut = e.ctrlKey && !e.altKey && !e.metaKey && (\n k === \"r\" || k === \"f\" || k === \"n\" || k === \"a\" || k === \"d\" ||\n k === \"z\" || k === \"y\" || k === \"k\"\n );\n if (isShortcut) e.preventDefault();\n window.parent.postMessage({\n type: \"previewKey\",\n key: e.key, code: e.code,\n ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, altKey: e.altKey, metaKey: e.metaKey,\n }, \"*\");\n });\n})();\n</script>\n</head><body>${html}</body></html>`;\n}\n\n/** Open the current message in a separate view: floating draggable overlay\n * on desktop (multiple at once, like compose), full-screen mode on mobile.\n * Threshold matches the layout.css responsive breakpoint so the experience\n * is consistent with other narrow-mode behavior. Snapshot in time \u2014 the\n * pop-out doesn't auto-update if the user clicks another message. */\nexport function popOutCurrentMessage(): void {\n if (!currentMessage) return;\n const isNarrow = window.innerWidth <= 768;\n if (isNarrow) {\n document.body.classList.toggle(\"viewer-fullscreen\");\n return;\n }\n // Drafts: popout means \"open in compose for editing\", not \"open as\n // read-only window\". The read-only popout has no Reply / Edit / Send\n // buttons, so handing a draft to it is a dead end. Same routing as\n // the double-click \u2192 mailx-popout-message handler in app.ts.\n const flags: string[] = Array.isArray((currentMessage as any).flags) ? (currentMessage as any).flags : [];\n if (flags.includes(\"\\\\Draft\")) {\n document.dispatchEvent(new CustomEvent(\"mailx-popout-message\", {\n detail: {\n accountId: currentAccountId,\n uid: (currentMessage as any).uid,\n folderId: (currentMessage as any).folderId,\n subject: (currentMessage as any).subject,\n },\n }));\n return;\n }\n spawnDesktopPopout(currentMessage, currentAccountId);\n}\n\n/** Print the currently-displayed message. Builds a self-contained printable\n * document \u2014 a header block (From/To/Cc/Subject/Date) plus the message body\n * \u2014 renders it in an off-screen iframe, and fires the print dialog. There\n * was no way to print a letter before (Bob 2026-05-21). */\nexport function printCurrentMessage(): void {\n if (!currentMessage) return;\n const m = currentMessage;\n const fmt = (a: { name?: string; address?: string }): string =>\n a?.name ? `${a.name} <${a.address || \"\"}>` : (a?.address || \"\");\n const esc = escapeHtmlLocal;\n const row = (label: string, value: string): string =>\n value ? `<div><strong>${label}:</strong> ${esc(value)}</div>` : \"\";\n const headerHtml =\n `<div style=\"font-family:system-ui,Segoe UI,sans-serif;font-size:11pt;` +\n `border-bottom:1px solid #999;padding-bottom:8px;margin-bottom:14px;line-height:1.5;\">` +\n row(\"From\", fmt(m.from || {})) +\n row(\"To\", (m.to || []).map(fmt).join(\", \")) +\n (m.cc?.length ? row(\"Cc\", m.cc.map(fmt).join(\", \")) : \"\") +\n row(\"Subject\", m.subject || \"\") +\n row(\"Date\", m.date ? new Date(m.date).toLocaleString() : \"\") +\n `</div>`;\n const bodyHtml = m.bodyHtml\n ? m.bodyHtml\n : `<pre style=\"white-space:pre-wrap;word-break:break-word;font-family:system-ui,sans-serif;\">${esc(m.bodyText || \"\")}</pre>`;\n const doc =\n `<!DOCTYPE html><html><head><meta charset=\"utf-8\">` +\n `<title>${esc(m.subject || \"Message\")}</title>` +\n `<style>@media print{body{margin:0;}}body{margin:16px;}img{max-width:100%;}</style>` +\n `</head><body>${headerHtml}${bodyHtml}</body></html>`;\n\n const iframe = document.createElement(\"iframe\");\n iframe.style.cssText = \"position:fixed;left:-9999px;width:1px;height:1px;border:0;\";\n iframe.srcdoc = doc;\n iframe.addEventListener(\"load\", () => {\n const win = iframe.contentWindow;\n if (!win) { iframe.remove(); return; }\n // afterprint fires when the dialog closes (Chromium/WebView2). A\n // fallback timeout covers hosts that don't emit it so the hidden\n // iframe never leaks.\n let removed = false;\n const cleanup = (): void => { if (!removed) { removed = true; iframe.remove(); } };\n win.addEventListener(\"afterprint\", cleanup, { once: true });\n setTimeout(cleanup, 120_000);\n try { win.focus(); win.print(); }\n catch (e) { console.error(\"[print] failed:\", e); cleanup(); }\n }, { once: true });\n document.body.appendChild(iframe);\n}\n\n/** Build a floating overlay carrying a snapshot of the message: header\n * (subject, from, to, date) + sandboxed body iframe + attachment chips.\n * Reuses the compose-overlay drag/resize/close pattern. Independent of the\n * main viewer \u2014 opening pop-out for message A then switching the main pane\n * to message B leaves A visible in its overlay. */\nfunction spawnDesktopPopout(msg: any, accountId: string): void {\n const wrapper = document.createElement(\"div\");\n wrapper.className = \"compose-overlay viewer-popout\";\n wrapper.style.cssText = \"position:fixed;top:60px;right:20px;width:min(720px,55vw);height:min(800px,80vh);z-index:1000;border:1px solid var(--color-border, #ccc);border-radius:8px;box-shadow:0 8px 32px rgba(0,0,0,0.3);display:flex;flex-direction:column;background:var(--color-bg, #fff);resize:both;overflow:hidden;\";\n\n const titleBar = document.createElement(\"div\");\n titleBar.style.cssText = \"display:flex;align-items:center;justify-content:space-between;padding:6px 10px;background:var(--color-bg-alt, #e8ecf0);color:var(--color-text, #000);border-radius:8px 8px 0 0;cursor:move;user-select:none;flex-shrink:0;font-size:13px;\";\n const titleText = document.createElement(\"span\");\n titleText.style.cssText = \"overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;font-weight:600;\";\n titleText.textContent = msg.subject || \"(no subject)\";\n titleBar.appendChild(titleText);\n\n const closeBtn = document.createElement(\"button\");\n closeBtn.textContent = \"\u2715\";\n closeBtn.title = \"Close pop-out\";\n closeBtn.style.cssText = \"background:none;border:none;font-size:16px;cursor:pointer;color:#666;padding:2px 8px;border-radius:4px;flex-shrink:0;\";\n closeBtn.addEventListener(\"mouseenter\", () => closeBtn.style.color = \"#c00\");\n closeBtn.addEventListener(\"mouseleave\", () => closeBtn.style.color = \"#666\");\n closeBtn.addEventListener(\"click\", () => wrapper.remove());\n titleBar.appendChild(closeBtn);\n\n const headerInfo = document.createElement(\"div\");\n headerInfo.style.cssText = \"padding:8px 12px;border-bottom:1px solid var(--color-border, #ddd);font-size:13px;line-height:1.4;flex-shrink:0;\";\n const formatAddrLocal = (a: { name?: string; address: string }) =>\n a.name ? `${a.name} <${a.address}>` : a.address;\n const fromStr = formatAddrLocal(msg.from || { address: \"\" });\n const toStr = (msg.to || []).map(formatAddrLocal).join(\", \");\n const ccStr = msg.cc?.length ? ` Cc: ${msg.cc.map(formatAddrLocal).join(\", \")}` : \"\";\n const dateStr = msg.date ? new Date(msg.date).toLocaleString() : \"\";\n headerInfo.innerHTML =\n `<div><strong>${escapeHtmlLocal(fromStr)}</strong></div>` +\n `<div style=\"color:var(--color-text-muted, #666)\">To: ${escapeHtmlLocal(toStr)}${escapeHtmlLocal(ccStr)}</div>` +\n `<div style=\"color:var(--color-text-muted, #666);font-size:12px\">${escapeHtmlLocal(dateStr)}</div>`;\n\n const bodyContainer = document.createElement(\"div\");\n bodyContainer.style.cssText = \"flex:1;overflow:hidden;display:flex;\";\n if (msg.bodyHtml) {\n const iframe = document.createElement(\"iframe\");\n iframe.sandbox.add(\"allow-same-origin\");\n iframe.sandbox.add(\"allow-popups\");\n iframe.sandbox.add(\"allow-popups-to-escape-sandbox\");\n iframe.sandbox.add(\"allow-top-navigation-by-user-activation\");\n iframe.sandbox.add(\"allow-scripts\");\n iframe.srcdoc = wrapHtmlBody(msg.bodyHtml, msg.remoteAllowed);\n iframe.style.cssText = \"flex:1;border:none;width:100%;background:#fff;\";\n bodyContainer.appendChild(iframe);\n // Same zoom + key-forwarding controls as the main viewer iframe \u2014\n // Ctrl+wheel / Ctrl\u00B1 / Ctrl+0 and the persisted `mailx-preview-zoom`.\n // Without this the pop-out had no zoom at all (Bob 2026-05-21).\n installPreviewControls(iframe);\n } else {\n const pre = document.createElement(\"pre\");\n pre.style.cssText = \"padding:12px;white-space:pre-wrap;word-break:break-word;margin:0;flex:1;overflow:auto;\";\n pre.textContent = msg.bodyText || \"(no content)\";\n bodyContainer.appendChild(pre);\n }\n\n // Drag \u2014 same pattern as compose-overlay: pointer-events:none on the\n // iframe so cursor crossing into it doesn't lose drag events.\n let dragX = 0, dragY = 0;\n titleBar.addEventListener(\"mousedown\", (e: MouseEvent) => {\n if (e.target === closeBtn) return;\n e.preventDefault();\n const rect = wrapper.getBoundingClientRect();\n dragX = e.clientX - rect.left;\n dragY = e.clientY - rect.top;\n const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));\n bodyContainer.style.pointerEvents = \"none\";\n document.body.style.userSelect = \"none\";\n const onMove = (ev: MouseEvent) => {\n ev.preventDefault();\n const left = clamp(ev.clientX - dragX, 0, window.innerWidth - 40);\n const top = clamp(ev.clientY - dragY, 0, window.innerHeight - 40);\n wrapper.style.left = `${left}px`;\n wrapper.style.top = `${top}px`;\n wrapper.style.right = \"auto\";\n wrapper.style.bottom = \"auto\";\n };\n const onUp = () => {\n bodyContainer.style.pointerEvents = \"\";\n document.body.style.userSelect = \"\";\n document.removeEventListener(\"mousemove\", onMove);\n document.removeEventListener(\"mouseup\", onUp);\n };\n document.addEventListener(\"mousemove\", onMove);\n document.addEventListener(\"mouseup\", onUp);\n });\n\n // Bring to front on click \u2014 shared with compose-overlay so they all\n // restack uniformly.\n wrapper.addEventListener(\"mousedown\", () => {\n document.querySelectorAll(\".compose-overlay\").forEach(el => (el as HTMLElement).style.zIndex = \"1000\");\n wrapper.style.zIndex = \"1001\";\n });\n\n // Cascade pop-outs so they don't all stack at the same coords.\n const existing = document.querySelectorAll(\".viewer-popout\").length;\n if (existing > 0) {\n wrapper.style.top = `${60 + existing * 28}px`;\n wrapper.style.right = `${20 + existing * 28}px`;\n }\n\n // Action toolbar \u2014 Reply / Reply All / Forward / Delete, acting on THIS\n // popped-out message. The pop-out can't reach app.ts's openCompose\n // directly, so each button fires `mailx-popout-action`; app.ts routes it.\n const toolbar = document.createElement(\"div\");\n toolbar.style.cssText = \"display:flex;gap:6px;padding:6px 12px;border-bottom:1px solid var(--color-border, #ddd);flex-shrink:0;\";\n const mkPopoutBtn = (label: string, title: string, onClick: () => void): HTMLButtonElement => {\n const b = document.createElement(\"button\");\n b.textContent = label;\n b.title = title;\n b.style.cssText = \"padding:4px 10px;font-size:13px;cursor:pointer;border:1px solid var(--color-border, #ccc);border-radius:4px;background:var(--color-bg, #fff);color:var(--color-text, #000);\";\n b.addEventListener(\"click\", onClick);\n return b;\n };\n const firePopoutAction = (action: string): void => {\n document.dispatchEvent(new CustomEvent(\"mailx-popout-action\", { detail: { action, msg, accountId } }));\n };\n toolbar.appendChild(mkPopoutBtn(\"Reply\", \"Reply\", () => firePopoutAction(\"reply\")));\n toolbar.appendChild(mkPopoutBtn(\"Reply All\", \"Reply to all\", () => firePopoutAction(\"replyAll\")));\n toolbar.appendChild(mkPopoutBtn(\"Forward\", \"Forward\", () => firePopoutAction(\"forward\")));\n toolbar.appendChild(mkPopoutBtn(\"Delete\", \"Delete this message\", () => {\n firePopoutAction(\"delete\");\n wrapper.remove(); // the message is gone \u2014 close its window too\n }));\n\n wrapper.appendChild(titleBar);\n wrapper.appendChild(headerInfo);\n wrapper.appendChild(toolbar);\n wrapper.appendChild(bodyContainer);\n document.body.appendChild(wrapper);\n}\n\nfunction escapeHtmlLocal(s: string): string {\n return (s || \"\").replace(/&/g, \"&\").replace(/</g, \"<\").replace(/>/g, \">\");\n}\n\n// Background body-fetch failure \u2014 when the user clicked an uncached row\n// and the reconciler's IMAP fetch failed (rate limit, network drop, auth),\n// surface a banner above the (empty) body for the *currently-shown* message\n// so the user knows the spinner isn't going to resolve. Other windows /\n// stale rows are ignored \u2014 no banner clutter for messages they walked away\n// from. The reconciler retries on its own cadence; no client-side retry.\nsubscribeStore(\"*\", (ev: any) => {\n if (ev.kind !== \"bodyFetchError\") return;\n // Always record \u2014 even if the failed message isn't the current\n // viewer target \u2014 so a later click on that row can surface the\n // reason instead of \"No content\".\n recentFetchErrors.set(`${ev.accountId}:${ev.uid}`, {\n error: String(ev.error || \"fetch failed\"),\n transient: !!ev.transient,\n when: Date.now(),\n });\n if (!currentMessage || currentAccountId !== ev.accountId) return;\n if (currentMessage.uid !== ev.uid) return;\n const bodyEl = document.getElementById(\"mv-body\");\n if (!bodyEl) return;\n if (bodyEl.querySelector(\".mv-body-fetch-error\")) return; // already shown\n const banner = document.createElement(\"div\");\n banner.className = \"mv-system-message mv-system-error mv-body-fetch-error\";\n const transientNote = ev.transient\n ? \"Will retry automatically.\"\n : \"Move/delete may have raced with the server.\";\n banner.innerHTML = `\n <div class=\"mv-system-tag\">mailx</div>\n <div class=\"mv-system-title\">Couldn't fetch message body</div>\n <div class=\"mv-system-body\">${escapeHtmlLocal(String(ev.error || \"fetch failed\"))}<br><span style=\"color:var(--color-text-muted);font-size:0.9em\">${transientNote}</span></div>`;\n const placeholder = bodyEl.querySelector(\".mv-empty\");\n if (placeholder) placeholder.replaceWith(banner);\n else bodyEl.prepend(banner);\n});\n\n// `draftSaved` arrives when the compose window's autosave or Ctrl+S lands\n// new body content on disk for the message currently displayed in the\n// preview pane. Without re-rendering, the preview shows the BEFORE-save\n// body until the user clicks the row again. Match by `previousDraftUid` \u2014\n// that's the UID currently displayed; the daemon will eventually IMAP\n// APPEND a fresh UID and delete the old one, but until that round-trip\n// lands `currentMessage.uid` still refers to the old UID.\nonEvent((ev: any) => {\n if (!ev || ev.type !== \"draftSaved\") return;\n if (!currentMessage || currentAccountId !== ev.accountId) return;\n if (currentMessage.uid !== ev.previousDraftUid) return;\n const newBody = String(ev.bodyHtml || \"\");\n currentMessage.bodyHtml = newBody;\n if (ev.subject) currentMessage.subject = ev.subject;\n const bodyEl = document.getElementById(\"mv-body\");\n const iframe = bodyEl?.querySelector(\"iframe\") as HTMLIFrameElement | null;\n if (iframe) {\n iframe.srcdoc = wrapHtmlBody(newBody, !!(currentMessage as any).remoteAllowed);\n }\n const subjEl = document.querySelector(\".mv-subject\");\n if (subjEl && ev.subject) subjEl.textContent = String(ev.subject);\n});\n", "/**\n * Folder picker \u2014 small modal for choosing a destination folder.\n * Used by the message-list right-click \"Move to folder\u2026\" item and any\n * other UI that needs the user to pick a folder.\n *\n * Reads folders from the local DB via getFolders() (local-first \u2014 no\n * server round-trip). Filters by typed text. Returns the selected\n * folder, or null if the user dismissed.\n */\n\nimport { getFolders } from \"../lib/api-client.js\";\n\nexport interface FolderPickResult {\n accountId: string;\n folderId: number;\n folderPath: string;\n folderName: string;\n}\n\n/** Show a modal folder picker. Returns a promise resolving to the picked\n * folder, or null if dismissed. The list is restricted to one account\n * (the current message's account) so it doesn't get cluttered with\n * unrelated folders; cross-account moves can be added later via an\n * account selector at the top of the picker. */\nexport function pickFolder(accountId: string, opts?: { excludeFolderIds?: number[]; title?: string }): Promise<FolderPickResult | null> {\n return new Promise(async (resolve) => {\n const overlay = document.createElement(\"div\");\n overlay.className = \"folder-picker-overlay\";\n overlay.style.cssText = \"position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:10000;display:flex;align-items:center;justify-content:center;\";\n\n const modal = document.createElement(\"div\");\n modal.className = \"folder-picker-modal\";\n modal.style.cssText = \"background:var(--bg, #fff);color:var(--fg, #000);border:1px solid var(--border, #ccc);border-radius:6px;box-shadow:0 4px 24px rgba(0,0,0,0.3);width:380px;max-width:90vw;max-height:70vh;display:flex;flex-direction:column;overflow:hidden;\";\n\n const header = document.createElement(\"div\");\n header.style.cssText = \"padding:10px 14px;border-bottom:1px solid var(--border, #ddd);font-weight:600;\";\n header.textContent = opts?.title || \"Move to folder\u2026\";\n modal.appendChild(header);\n\n const search = document.createElement(\"input\");\n search.type = \"text\";\n search.placeholder = \"Filter folders\u2026\";\n search.style.cssText = \"margin:8px 12px;padding:6px 10px;border:1px solid var(--border, #ccc);border-radius:4px;font-size:13px;\";\n modal.appendChild(search);\n\n const listEl = document.createElement(\"div\");\n listEl.style.cssText = \"flex:1;overflow-y:auto;padding:4px 0;\";\n modal.appendChild(listEl);\n\n const footer = document.createElement(\"div\");\n footer.style.cssText = \"padding:8px 12px;border-top:1px solid var(--border, #ddd);display:flex;justify-content:flex-end;gap:8px;\";\n const cancelBtn = document.createElement(\"button\");\n cancelBtn.textContent = \"Cancel\";\n cancelBtn.style.cssText = \"padding:6px 14px;cursor:pointer;\";\n footer.appendChild(cancelBtn);\n modal.appendChild(footer);\n\n overlay.appendChild(modal);\n document.body.appendChild(overlay);\n\n const dismiss = (result: FolderPickResult | null) => {\n overlay.remove();\n document.removeEventListener(\"keydown\", onKey);\n resolve(result);\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") { e.preventDefault(); dismiss(null); }\n if (e.key === \"Enter\") {\n const first = listEl.querySelector(\".folder-picker-row.match\") as HTMLElement | null;\n if (first) first.click();\n }\n };\n document.addEventListener(\"keydown\", onKey);\n overlay.addEventListener(\"click\", (e) => { if (e.target === overlay) dismiss(null); });\n cancelBtn.addEventListener(\"click\", () => dismiss(null));\n\n // Local-first: load from DB synchronously-ish (one IPC round-trip).\n let folders: any[] = [];\n try {\n folders = (await getFolders(accountId)) || [];\n } catch (e) {\n listEl.textContent = \"Failed to load folders\";\n return;\n }\n\n // Hide special-use that don't make sense as targets (Outbox).\n // Allow Trash / Junk so users can manually file into them.\n const excluded = new Set(opts?.excludeFolderIds || []);\n const targets = folders\n .filter((f: any) => !excluded.has(f.id))\n .filter((f: any) => f.specialUse !== \"outbox\")\n .sort((a: any, b: any) => a.path.localeCompare(b.path));\n\n function render(filter: string): void {\n listEl.innerHTML = \"\";\n const lc = filter.toLowerCase().trim();\n let firstMatchSet = false;\n for (const f of targets) {\n const row = document.createElement(\"div\");\n row.className = \"folder-picker-row\";\n row.style.cssText = \"padding:6px 14px;cursor:pointer;font-size:13px;display:flex;justify-content:space-between;gap:8px;\";\n const name = document.createElement(\"span\");\n name.textContent = f.path;\n const tag = document.createElement(\"span\");\n tag.style.cssText = \"color:var(--muted, #888);font-size:11px;\";\n tag.textContent = f.specialUse || \"\";\n row.appendChild(name);\n row.appendChild(tag);\n const matches = !lc || f.path.toLowerCase().includes(lc);\n if (matches) {\n row.classList.add(\"match\");\n if (!firstMatchSet) { row.style.background = \"var(--hover, #eee)\"; firstMatchSet = true; }\n }\n row.addEventListener(\"mouseenter\", () => row.style.background = \"var(--hover, #eee)\");\n row.addEventListener(\"mouseleave\", () => row.style.background = \"\");\n row.addEventListener(\"click\", () => {\n dismiss({ accountId, folderId: f.id, folderPath: f.path, folderName: f.path.split(/[./]/).pop() || f.path });\n });\n if (!matches) row.style.display = \"none\";\n listEl.appendChild(row);\n }\n }\n render(\"\");\n search.addEventListener(\"input\", () => render(search.value));\n setTimeout(() => search.focus(), 0);\n });\n}\n", "/**\n * Message list component \u2014 renders paginated message rows.\n * Reads from message-state; operations mutate state, list reacts.\n */\n\nimport { getMessages as apiGetMessages, getUnifiedInbox as apiGetUnifiedInbox, searchMessages, abortMessageListRequests, updateFlags, getThreadMessages, moveMessages as apiMoveMessages, getPriorityLists, onEvent } from \"../lib/api-client.js\";\nimport * as state from \"../lib/message-state.js\";\nimport type { ListMessage } from \"../lib/message-state.js\";\nimport { showMessage as viewerShow, clearViewer as viewerClear } from \"./message-viewer.js\";\nimport { showContextMenu, type MenuItem } from \"./context-menu.js\";\nimport { pickFolder } from \"./folder-picker.js\";\nimport { seenOf, flaggedOf, draftOf, setSeen, setFlagged } from \"@bobfrankston/mailx-types\";\n\ntype MessageSelectHandler = (accountId: string, uid: number, folderId: number) => void;\n\nlet onMessageSelect: MessageSelectHandler;\nlet currentAccountId: string;\nlet currentFolderId: number;\nlet currentSpecialUse = \"\"; // Cached for reloads \u2014 an empty value on reload used to reset Sent/Drafts/Outbox to \"From\"\nlet lastClickedRow: HTMLElement | null = null;\nlet currentPage: number;\nlet totalMessages: number;\nlet loading = false;\nlet unifiedMode = false;\nlet searchMode = false;\n\nlet currentSearchQuery = \"\";\n// Remember the pre-search view mode so clearSearchMode() can restore it.\n// Without this, searching from \"All Inboxes\" loses the unified-mode flag\n// (loadSearchResults sets unifiedMode=false), and clearing the search\n// drops into reloadCurrentFolder() which finds no folder and no unified\n// mode and renders nothing \u2014 the empty-list / spurious-timeout symptom\n// the user saw 2026-05-05.\nlet wasUnifiedBeforeSearch = false;\nlet showToInsteadOfFrom = false;\nlet touchWasScroll = false;\n// Current sort column/direction \u2014 cycled by clicking the ml-header columns.\n// \"date desc\" is the default (newest first). Clicking a column flips direction\n// if it's already active, or switches to that column with its own default dir\n// (text columns default asc, date defaults desc).\nlet currentSort = \"date\";\nlet currentSortDir: \"asc\" | \"desc\" = \"desc\";\n\n/** Generation counter \u2014 incremented on every load* call (loadMessages,\n * loadUnifiedInbox, loadSearchResults). Each load captures the current\n * value at the top, then checks before rendering \u2014 if the captured gen\n * no longer matches `loadGen`, the user has switched to a different\n * view in the meantime and this stale response should be silently\n * dropped instead of overwriting the new view. Bob 2026-05-08:\n * rapid-fire folder clicks were producing \"list shows folder X but\n * preview shows folder Y\" because folder X's getMessages eventually\n * resolved AFTER folder Y's render and clobbered it. */\nlet loadGen = 0;\n\n/** In-memory message cache for instant folder switches. Keyed by view \u2014\n * `unified`, `account:folder:flagged?`, `search:query`. The first click\n * on a folder paints from this cache (~1 ms) BEFORE the IPC round-trip\n * even fires; the IPC runs in background and only re-renders if the\n * result diverges from the cached version. Bob 2026-05-09: clicking\n * \"All Inboxes\" felt slow even though IPC returned in 18 ms because\n * the round-trip + DOM build was the user-visible pause. With the\n * cache, second-and-later clicks are instant. */\ninterface CachedListResult {\n items: any[];\n total: number;\n timestamp: number;\n}\nconst listCache = new Map<string, CachedListResult>();\nconst CACHE_KEY_UNIFIED = \"unified\";\nfunction cacheKey(mode: \"folder\" | \"search\", a?: string, f?: number, flagged?: boolean, q?: string): string {\n if (mode === \"folder\") return `folder:${a}:${f}:${flagged ? \"flag\" : \"\"}`;\n return `search:${q}`;\n}\n\n/** Per-view position memory \u2014 remembered selected UID + scroll position\n * per folder / unified inbox / saved search. Keyed by the same string\n * `cacheKey()` uses so each view has its own slot.\n *\n * Restore rule (Bob 2026-05-12): on return to a view, focus the saved\n * uid if it still exists; otherwise focus the first row whose uid is\n * less than the saved uid (next-older entry in a date-desc list \u2014 uid\n * is roughly monotonic with arrival time on most IMAP servers and on\n * Gmail's hash-derived ids). If no smaller uid exists either, fall\n * back to whatever row sits at the same numeric index. Survives a\n * session reload via sessionStorage. */\ninterface ViewPosition { uid: number; uuid?: string; scroll: number; }\nconst positionMemory = new Map<string, ViewPosition>();\nconst POSITION_STORAGE_KEY = \"mailx-list-positions\";\ntry {\n const raw = sessionStorage.getItem(POSITION_STORAGE_KEY);\n if (raw) {\n const parsed = JSON.parse(raw) as Record<string, ViewPosition>;\n for (const [k, v] of Object.entries(parsed || {})) {\n if (typeof v?.uid === \"number\") positionMemory.set(k, v);\n }\n }\n} catch { /* */ }\nfunction persistPositions(): void {\n try {\n const obj: Record<string, ViewPosition> = {};\n for (const [k, v] of positionMemory) obj[k] = v;\n sessionStorage.setItem(POSITION_STORAGE_KEY, JSON.stringify(obj));\n } catch { /* */ }\n}\nfunction currentViewKey(): string | null {\n if (searchMode) return cacheKey(\"search\", undefined, undefined, undefined, currentSearchQuery);\n if (unifiedMode) return CACHE_KEY_UNIFIED;\n if (!currentAccountId || currentFolderId == null) return null;\n const flaggedOnly = document.getElementById(\"ml-body\")?.classList.contains(\"flagged-only\") || false;\n return cacheKey(\"folder\", currentAccountId, currentFolderId, flaggedOnly);\n}\nfunction rememberPosition(): void {\n const key = currentViewKey();\n if (!key) return;\n const body = document.getElementById(\"ml-body\");\n if (!body) return;\n const sel = body.querySelector(\".ml-row.selected\") as HTMLElement | null;\n if (!sel) return;\n const uid = Number(sel.dataset.uid);\n if (!Number.isFinite(uid)) return;\n // Remember the stable `uuid` as the primary identity \u2014 bare `uid` is only\n // unique within (account, folder), so restoring by it can rebind the\n // viewer to a different message that happens to share the uid number.\n // `uid` is kept only for the next-older-neighbor fallback when the\n // remembered message has since been deleted.\n positionMemory.set(key, { uid, uuid: sel.dataset.uuid || \"\", scroll: body.scrollTop });\n persistPositions();\n}\n/** Choose the row to focus when re-entering a view with saved position.\n * Returns the uid to focus, or null to fall back to selectFirst. */\nfunction pickRestoreUid(items: any[], saved: ViewPosition): string | null {\n if (!items.length) return null;\n // Exact restore by stable uuid \u2014 globally unique, so this can never\n // rebind the viewer to a same-uid-number message in another folder\n // (the bug: viewer silently jumped to a different letter mid-cleanup).\n if (saved.uuid) {\n const exact = items.find(m => m.uuid && m.uuid === saved.uuid);\n if (exact?.uuid) return exact.uuid;\n }\n // Saved message is gone (deleted). Pick the next-older entry by uid \u2014\n // uid is roughly monotonic with arrival on IMAP/Gmail \u2014 and return ITS\n // uuid so the restore stays uuid-keyed end to end.\n let best: any = null;\n for (const m of items) {\n if (typeof m.uid !== \"number\" || !m.uuid) continue;\n if (m.uid < saved.uid && (!best || m.uid > best.uid)) best = m;\n }\n if (best) return best.uuid;\n // No older entry \u2014 saved was the bottom. Snap to the top of the list.\n return items[0]?.uuid || null;\n}\n\n/** Quick equality check \u2014 same UID set, same flag pattern, same total.\n * Skip re-render when this returns true to avoid DOM churn / scroll-\n * jump on a refresh that didn't change anything. */\nfunction listResultsEqual(a: any[] | undefined, b: any[]): boolean {\n if (!a || a.length !== b.length) return false;\n for (let i = 0; i < a.length; i++) {\n if (a[i].uid !== b[i].uid) return false;\n if (a[i].accountId !== b[i].accountId) return false;\n // Flags-changed \u2192 must re-render (read/unread, flagged).\n if ((a[i].flags || []).join(\",\") !== (b[i].flags || []).join(\",\")) return false;\n }\n return true;\n}\n\n/** Single source of truth for \"which row is focused\" in the list.\n *\n * Each rendered row is a `MessageRow` instance owning its DOM element,\n * its message envelope, and its event handlers. `focusRow(row)` runs\n * the atomic transition: unfocus the previous row, mark this one\n * `.selected`, drive the viewer with the row's envelope, dispatch\n * `mailx-focus-changed`. There is no \"select state\" anywhere else; the\n * viewer has no subscriptions. If `focusRow` isn't called, the preview\n * pane doesn't update \u2014 drift between the highlighted row and the\n * preview is structurally impossible.\n *\n * When the focused row's data leaves the list (delete, move, search\n * reload, folder switch), the controller hands focus to a survivor\n * via `focusByIdentity` or, if no survivor exists, calls\n * `releaseFocus()` which clears highlight + viewer in the same call. */\nlet focusedRow: MessageRow | null = null;\nconst rowByKey = new Map<string, MessageRow>();\n\n/** Priority sender / domain index, populated on app load and refreshed when\n * the user marks/unmarks a sender. Module-scoped so MessageRow can hit it\n * during construction without a service round-trip per row. */\nlet prioritySenders: Set<string> = new Set();\nlet priorityDomains: Set<string> = new Set();\nfunction isPriorityAddr(addr: string): boolean {\n const lower = (addr || \"\").trim().toLowerCase();\n if (!lower) return false;\n if (prioritySenders.has(lower)) return true;\n const at = lower.indexOf(\"@\");\n const domain = at >= 0 ? lower.slice(at + 1) : \"\";\n return !!domain && priorityDomains.has(domain);\n}\n/** Refresh the priority index from the service. Call on app load, after\n * any user action that changes priority (mark/unmark), or whenever\n * contacts.jsonc reloads. After refresh, every visible row's `.priority`\n * class is recomputed without a full re-render. */\nexport async function refreshPriorityIndex(): Promise<void> {\n try {\n const r = await getPriorityLists();\n prioritySenders = new Set((r?.senders || []).map(s => (s || \"\").toLowerCase()));\n priorityDomains = new Set((r?.domains || []).map(d => (d || \"\").toLowerCase()));\n // Re-tag any currently rendered rows.\n for (const row of rowByKey.values()) {\n row.el.classList.toggle(\"priority\", isPriorityAddr(row.msg?.from?.address || \"\"));\n }\n } catch { /* service may not be ready yet */ }\n}\n\nfunction rowKey(accountId: string, uid: number | string): string {\n return `${accountId}:${uid}`;\n}\n\nfunction focusRow(row: MessageRow, opts: { scroll?: boolean } = {}): void {\n // Single-select invariant: this row is the ONLY highlight, and it is the\n // row the viewer shows. Sweep any stray .selected so the list can never\n // present a highlighted row that doesn't match the viewer (Bob\n // 2026-05-18: \"the message display and the highlight are supposed to be\n // tied together\"). Multi-select keeps its own highlights \u2014 focusRow is\n // also called there for the just-toggled row, so skip the sweep then.\n const body = row.el.parentElement as HTMLElement | null;\n if (!body?.classList.contains(\"multi-select-on\")) {\n body?.querySelectorAll(\".ml-row.selected\").forEach(r => {\n if (r !== row.el) r.classList.remove(\"selected\");\n });\n }\n if (focusedRow && focusedRow !== row) focusedRow.setSelected(false);\n row.setSelected(true);\n focusedRow = row;\n // Bring the focused row into view ONLY for user-initiated focus changes\n // (click, keyboard nav, post-delete auto-advance). Programmatic restores\n // (restoreSelection after sync rerender) explicitly opt out via\n // {scroll:false}: otherwise the user's scroll-away-from-focused-row gets\n // yanked back every time folderCountsChanged triggers a list reload.\n // Bob 2026-05-14: \"it's not letting me scroll down the list\" \u2014 symptom\n // of repeated scrollIntoView during sync churn.\n if (opts.scroll !== false) {\n row.el.scrollIntoView({ block: \"nearest\" });\n }\n // Drive the viewer with the row's own envelope. Pass currentSpecialUse\n // so the viewer can recognize Drafts/Outbox rows and auto-open them in\n // compose instead of the read-only preview. Without this the draft was\n // showing in the viewer pane as if it were any received message \u2014\n // wrong font (browser default Times), no edit-mode handoff (Bob\n // 2026-05-12: \"when I open the draft it is not in edit mode as it\n // should be\").\n viewerShow(row.accountId, row.msg.uid, row.msg.folderId, currentSpecialUse || undefined, false, row.msg);\n onMessageSelect(row.accountId, row.msg.uid, row.msg.folderId);\n document.dispatchEvent(new CustomEvent(\"mailx-focus-changed\", { detail: row.msg }));\n // Persist position so returning to this view re-focuses this row.\n rememberPosition();\n}\n\n/** Read the currently-focused message envelope. Used by app-level\n * features (flag toggle, mark unread, status bar) that need to know\n * what's open in the viewer. */\nexport function getCurrentFocused(): ListMessage | null {\n return focusedRow ? focusedRow.msg : null;\n}\n\n/** Programmatic focus by identity. Used for thread-popup clicks,\n * keyboard nav, post-delete handoff. Returns true if a row was found\n * and focused. */\nfunction focusByIdentity(accountId: string, uid: number, opts: { scroll?: boolean } = {}): boolean {\n const row = rowByKey.get(rowKey(accountId, uid));\n if (!row) return false;\n focusRow(row, opts);\n return true;\n}\n\n/** Update the flagged class + star on a specific row by identity. Used by\n * the toolbar Flag button so the visible star tracks the data instead of\n * drifting until the next list rebuild. Calls into the row object's own\n * setFlaggedClass \u2014 atomic class+text update, can't desync. */\nexport function setRowFlagged(accountId: string, uid: number, yes: boolean): void {\n const row = rowByKey.get(rowKey(accountId, uid));\n if (row) row.setFlaggedClass(yes);\n}\n\n/** Update the unread class on a specific row by identity. Used by\n * message-viewer's auto-mark-as-read so the visual state flips off\n * ONLY after the dwell timer fires + the IMAP STORE \\Seen succeeds \u2014\n * not on raw click. Previous click-time `setUnreadClass(false)` meant\n * a right-click during the 2-s dwell window read the row as \"read\"\n * and offered \"Mark unread\" on a message that wasn't actually marked\n * yet. */\nexport function setRowSeen(accountId: string, uid: number, yes: boolean): void {\n const row = rowByKey.get(rowKey(accountId, uid));\n if (row) row.setUnreadClass(!yes);\n}\n\n/** Scroll the focused row into view. Wired to a keyboard shortcut so the\n * user can recover after scrolling the list away from the preview. */\nexport function scrollFocusedIntoView(): void {\n if (focusedRow) focusedRow.el.scrollIntoView({ block: \"center\" });\n}\n\n/** Release the focus slot and clear the preview pane in one call. */\nexport function releaseFocus(): void {\n focusedRow = null;\n // Strip EVERY .selected row, not just the focused one. A stray highlight\n // (a multi-select remnant, or a row that kept the class across an\n // incremental rerender) was left behind as a blue row with an empty\n // viewer. The highlight and the preview are one thing: no focus \u27F9 no\n // highlight anywhere.\n const body = document.getElementById(\"ml-body\");\n body?.querySelectorAll(\".ml-row.selected\").forEach(r => r.classList.remove(\"selected\"));\n viewerClear();\n document.dispatchEvent(new CustomEvent(\"mailx-focus-changed\", { detail: null }));\n}\n\n/** Flip the \"not-downloaded\" indicator off for rows whose bodies just cached.\n * Called from the bodyCached service event \u2014 covers both background prefetch\n * and on-demand fetch. No-op for rows not currently rendered. */\nexport function markBodiesCached(items: { accountId: string; uid: number }[]): void {\n const body = document.getElementById(\"ml-body\");\n if (!body || items.length === 0) return;\n for (const { accountId, uid } of items) {\n const row = body.querySelector(`.ml-row[data-uid=\"${uid}\"][data-account-id=\"${CSS.escape(accountId)}\"]`)\n || body.querySelector(`.ml-row[data-uid=\"${uid}\"]`);\n if (row) {\n row.classList.remove(\"not-downloaded\");\n }\n }\n}\n\n/** Get all selected message rows */\nexport function getSelectedMessages(): { accountId: string; uid: number; folderId: number }[] {\n const body = document.getElementById(\"ml-body\");\n if (!body) return [];\n const rows = body.querySelectorAll(\".ml-row.selected\");\n return Array.from(rows).map(r => ({\n accountId: (r as HTMLElement).dataset.accountId || \"\",\n uid: Number((r as HTMLElement).dataset.uid),\n folderId: Number((r as HTMLElement).dataset.folderId),\n }));\n}\n\nfunction clearSelection(): void {\n const body = document.getElementById(\"ml-body\");\n if (body) body.querySelectorAll(\".ml-row.selected\").forEach(r => r.classList.remove(\"selected\"));\n // The focused-row invariant is \"the Row whose .selected is currently\n // mine\". clearSelection wipes all .selected, so the invariant breaks\n // unless we drop the focused-row reference too.\n if (focusedRow) {\n focusedRow = null;\n viewerClear();\n document.dispatchEvent(new CustomEvent(\"mailx-focus-changed\", { detail: null }));\n }\n}\n\n/** Deterministic sender-avatar color from a seed string (typically the\n * email address). Hash \u2192 hue at 12 evenly-spaced positions on the wheel.\n * Saturation + lightness fixed so all colors carry the same visual weight\n * regardless of hue, and so light/dark themes don't have to override. */\nfunction senderColor(seed: string): string {\n let h = 0;\n for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) | 0;\n const hue = ((Math.abs(h) % 12) * 30) + 15; // 15, 45, 75, \u2026, 345\n return `oklch(0.62 0.14 ${hue})`;\n}\n\n/** Render the attachment / replied / forwarded icon strip into a container.\n * Mirrors classic Outlook's date-column status glyphs: paperclip for\n * attachments, leftwards-hook arrow for replied (In-Reply-To linkage in\n * the local DB; \\Answered flag as fallback), rightwards-hook arrow for\n * forwarded ($Forwarded keyword). Empty strip when none apply \u2014 no leading\n * space, so the date sits flush right as before. */\nfunction renderStatusIcons(host: HTMLElement, msg: any): void {\n host.textContent = \"\";\n if (msg.hasAttachments) {\n const a = document.createElement(\"span\");\n a.className = \"ml-icon ml-icon-attach\";\n a.textContent = \"\uD83D\uDCCE\";\n a.title = \"Has attachments\";\n host.appendChild(a);\n }\n const flags: string[] = msg.flags || [];\n // Primary signal: local DB knows the user (or anyone in this account)\n // replied \u2014 derived from In-Reply-To header linkage, instant on send,\n // works cross-account and across servers that drop \\Answered. Plan B:\n // the server-side \\Answered flag (e.g. another client replied from a\n // different machine before mailx synced the reply back).\n if (msg.isReplied || flags.includes(\"\\\\Answered\")) {\n const r = document.createElement(\"span\");\n r.className = \"ml-icon ml-icon-replied\";\n r.textContent = \"\u21A9\";\n r.title = \"Replied\";\n host.appendChild(r);\n }\n if (flags.includes(\"$Forwarded\")) {\n const f = document.createElement(\"span\");\n f.className = \"ml-icon ml-icon-forwarded\";\n f.textContent = \"\u21AA\";\n f.title = \"Forwarded\";\n host.appendChild(f);\n }\n}\n\n/** Exit multi-select mode (entered via touch long-press). Clears selection\n * and the sticky body flag so subsequent taps open messages again. */\nfunction exitMultiSelect(): void {\n const body = document.getElementById(\"ml-body\");\n if (!body?.classList.contains(\"multi-select-on\")) return;\n body.classList.remove(\"multi-select-on\");\n clearSelection();\n updateBulkBar();\n}\n\n/** Bulk-actions bar retired 2026-04-24 \u2014 trash + spam live on the main\n * toolbar now and every other bulk op (mark-read, flag, move) is one\n * right-click away. Kept as a no-op stub so existing call sites\n * (avatar tap, row click, long-press) don't need to be touched. */\nfunction updateBulkBar(): void { /* bar removed; nothing to render */ }\n\n// Escape key + click-outside-list exit multi-select mode. Attached once\n// (idempotent because document only has one listener scope per handler).\nif (!(window as any).__mailxMultiSelectWired) {\n (window as any).__mailxMultiSelectWired = true;\n document.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Escape\") exitMultiSelect();\n });\n document.addEventListener(\"pointerdown\", (e) => {\n const body = document.getElementById(\"ml-body\");\n if (!body?.classList.contains(\"multi-select-on\")) return;\n const target = e.target as HTMLElement;\n // A tap on a row is handled by the row's own click listener.\n // The toolbar must also be exempt: its trash / spam / etc.\n // buttons operate ON the current multi-selection, so a tap on\n // them should NOT clear selection before the button's click\n // handler runs (otherwise getSelectedMessages returns empty\n // and the action no-ops \u2014 Android-reported 2026-04-30: \"press\n // multiple circles, press trashcan, checks vanish, nothing\n // deleted\"). Same logic for the folder-tree (drop targets,\n // future: bulk move). Exit only on a tap to genuine neutral\n // ground.\n // Exempt: rows (handled by their own listener), toolbar buttons\n // (delete/spam/etc. operate ON the selection \u2014 clearing it here\n // empties the selection before the click runs), folder-tree\n // (drop targets / future bulk move), and the context menu\n // (right-click \u2192 \"mark read\" / \"move to\" / etc. all need the\n // selection intact when the menu item runs).\n if (target.closest(\".ml-row, .toolbar, .folder-tree, .ctx-menu, #btn-tb-delete, #btn-tb-spam\")) return;\n exitMultiSelect();\n }, true);\n}\n\nfunction selectRange(from: HTMLElement, to: HTMLElement): void {\n const body = document.getElementById(\"ml-body\");\n if (!body) return;\n const rows = Array.from(body.querySelectorAll(\".ml-row\"));\n const fromIdx = rows.indexOf(from);\n const toIdx = rows.indexOf(to);\n if (fromIdx < 0 || toIdx < 0) return;\n const lo = Math.min(fromIdx, toIdx);\n const hi = Math.max(fromIdx, toIdx);\n // A shift-click range IS a multi-selection \u2014 flag the body so focusRow()\n // (called right after, to drive the viewer off the clicked row) skips\n // its single-select sweep. Without this the sweep wiped every row in the\n // range except the one clicked (Bob 2026-05-19: \"shift click should\n // extend a selection but it does not\" \u2014 regression from the v1.1.88\n // highlight-desync fix).\n if (hi > lo) body.classList.add(\"multi-select-on\");\n for (let i = lo; i <= hi; i++) rows[i].classList.add(\"selected\");\n}\n\n/** The row to anchor a shift-click range against. `lastClickedRow` is the\n * primary anchor, but it can become a detached DOM node after a list\n * re-render (folder switch, sort, search reload, paging) \u2014 `selectRange`\n * would then no-op. Fall back to whichever live row is `.selected` (the\n * one in the viewer) before giving up. */\nfunction resolveShiftAnchor(): HTMLElement | null {\n if (lastClickedRow?.isConnected) return lastClickedRow;\n const body = document.getElementById(\"ml-body\");\n if (!body) return null;\n return body.querySelector(\".ml-row.selected\") as HTMLElement | null;\n}\n\nconst timeFmt: Intl.DateTimeFormatOptions = { hour: \"2-digit\", minute: \"2-digit\", hour12: false };\nconst dateFmt: Intl.DateTimeFormatOptions = { year: \"numeric\", month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\", hour12: false };\nconst dateFmtSameYear: Intl.DateTimeFormatOptions = { month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\", hour12: false };\n\nexport function initMessageList(handler: MessageSelectHandler): void {\n onMessageSelect = handler;\n\n // Infinite scroll\n const body = document.getElementById(\"ml-body\");\n if (body) {\n // Touch scroll vs tap: the WebView occasionally synthesizes a click on\n // touchend even when the user clearly scrolled, which opened a message\n // just from swiping the list. Multi-signal detection so a scroll is\n // reliably classified:\n // 1. touchmove movement \u2265 TAP_SLOP \u2014 the primary signal\n // 2. actual scrollTop change between touchstart and touchend \u2014 always\n // set the flag when the container moved, even if touchmove never\n // fired (some Android builds coalesce events under momentum)\n // 3. longer TAP_SLOP (15px) \u2014 fingers are wide; 10px was too twitchy\n let touchStartY = 0;\n let touchStartX = 0;\n let touchStartScrollTop = 0;\n const TAP_SLOP = 15;\n body.addEventListener(\"touchstart\", (e) => {\n const t = e.touches[0];\n touchStartY = t.clientY;\n touchStartX = t.clientX;\n touchStartScrollTop = body.scrollTop;\n touchWasScroll = false;\n }, { passive: true });\n body.addEventListener(\"touchmove\", (e) => {\n const t = e.touches[0];\n if (Math.abs(t.clientY - touchStartY) > TAP_SLOP || Math.abs(t.clientX - touchStartX) > TAP_SLOP) {\n touchWasScroll = true;\n }\n }, { passive: true });\n body.addEventListener(\"touchend\", () => {\n // If the container actually scrolled during this touch, the user\n // was scrolling regardless of how small their finger movement was.\n if (body.scrollTop !== touchStartScrollTop) {\n touchWasScroll = true;\n }\n }, { passive: true });\n\n body.addEventListener(\"scroll\", () => {\n if (loading) return;\n const nearBottom = body.scrollHeight - body.scrollTop - body.clientHeight < 200;\n if (nearBottom) {\n if (currentPage * 50 < totalMessages) {\n loadMoreMessages();\n } else {\n // Diagnostic: at bottom but not loading more. Log why\n // so the user can see whether totalMessages is wrong,\n // a filter is on, or we've truly reached the end.\n // Only log once per \"stuck at bottom\" episode.\n if (!(body as any)._mlScrollEndLogged) {\n (body as any)._mlScrollEndLogged = true;\n const rows = body.querySelectorAll(\".ml-row\").length;\n console.log(` [ml-scroll] reached bottom \u2014 currentPage=${currentPage} pageSize=50 loadedRows=${rows} totalMessages=${totalMessages} searchMode=${searchMode} unifiedMode=${unifiedMode} flaggedOnly=${body.classList.contains(\"flagged-only\")}`);\n }\n }\n } else {\n // Reset the once-flag so next time we hit bottom we log again.\n (body as any)._mlScrollEndLogged = false;\n }\n });\n }\n\n // Viewer signals \"this row is gone server-side\" via mailx-remove-stale.\n // The list owns row lifecycle, so it runs the removal here \u2014 filtering\n // state, removing the DOM row, and handing focus to a survivor (or\n // clearing the pane) in one transaction.\n document.addEventListener(\"mailx-remove-stale\", (e: any) => {\n const { accountId, uid } = e.detail || {};\n if (typeof uid === \"number\" && typeof accountId === \"string\") {\n removeMessagesAndReconcile([{ accountId, uid }]);\n }\n });\n\n // Sort column headers \u2014 click to cycle. Date defaults desc (newest first);\n // From/Subject default asc on first click so alphabetical order reads\n // naturally. Clicking the currently-active column flips direction.\n const header = document.getElementById(\"ml-header\");\n if (header) {\n header.addEventListener(\"click\", (e) => {\n const col = (e.target as HTMLElement).closest<HTMLElement>(\".ml-col-sortable\");\n if (!col) return;\n const key = col.dataset.sort;\n if (!key) return;\n if (currentSort === key) {\n currentSortDir = currentSortDir === \"asc\" ? \"desc\" : \"asc\";\n } else {\n currentSort = key;\n currentSortDir = key === \"date\" ? \"desc\" : \"asc\";\n }\n // Only per-folder lists support server-side sort today; unified and\n // search paths sort client-side on the fetched page. Reload if we\n // have an active per-folder context.\n if (!searchMode && !unifiedMode && currentAccountId && currentFolderId) {\n loadMessages(currentAccountId, currentFolderId, 1, \"\", false);\n } else {\n applyClientSideSort();\n updateSortIndicators();\n }\n });\n }\n updateSortIndicators();\n}\n\n/** Reorder currently-loaded state messages in-place by currentSort/currentSortDir.\n * Used for unified-inbox and search results where the server can't re-sort\n * a single page on our behalf. */\nfunction applyClientSideSort(): void {\n const items = [...state.getMessages()];\n const sign = currentSortDir === \"asc\" ? 1 : -1;\n items.sort((a: any, b: any) => {\n if (currentSort === \"from\") {\n const av = (a.from?.name || a.from?.address || \"\").toLowerCase();\n const bv = (b.from?.name || b.from?.address || \"\").toLowerCase();\n return av < bv ? -sign : av > bv ? sign : 0;\n }\n if (currentSort === \"subject\") {\n const av = (a.subject || \"\").replace(/^(re:|fwd:|fw:)\\s*/i, \"\").toLowerCase();\n const bv = (b.subject || \"\").replace(/^(re:|fwd:|fw:)\\s*/i, \"\").toLowerCase();\n return av < bv ? -sign : av > bv ? sign : 0;\n }\n // date\n return ((a.date || 0) - (b.date || 0)) * sign;\n });\n state.setMessages(items as any);\n}\n\nfunction updateSortIndicators(): void {\n const header = document.getElementById(\"ml-header\");\n if (!header) return;\n header.querySelectorAll<HTMLElement>(\".ml-col-sortable\").forEach(c => {\n c.classList.remove(\"ml-col-sort-asc\", \"ml-col-sort-desc\");\n if (c.dataset.sort === currentSort) {\n c.classList.add(currentSortDir === \"asc\" ? \"ml-col-sort-asc\" : \"ml-col-sort-desc\");\n }\n });\n}\n\n/**\n * Remove the named messages from the list and reconcile DOM + focus.\n *\n * Single-transaction transition: filters the underlying state, deletes\n * the DOM rows, and either re-focuses a surviving row or releases focus\n * (clearing the viewer). Replaces the old subscribe-and-sync model where\n * state.removeMessages broadcast a \"removed\" event to two independent\n * subscribers \u2014 that path could leave the highlight and preview out of\n * sync if the list and viewer noticed the change in different orders.\n *\n * Call this whenever local rows need to disappear (delete, move,\n * server-side stale removal, undo). Pass identities; the function\n * decides what to focus next.\n */\nexport function removeMessagesAndReconcile(\n uids: { accountId: string; uid: number }[],\n): void {\n const focusedIdent = focusedRow\n ? { accountId: focusedRow.accountId, uid: focusedRow.msg.uid }\n : null;\n const outcome = state.removeMessages(uids, focusedIdent);\n\n // Invalidate the in-memory list cache so a window-switch or folder\n // re-visit doesn't re-paint the deleted rows from a stale snapshot.\n // Bob 2026-05-09: \"I deleted entries, switched windows, came back \u2014\n // the deleted entries reappeared.\" Cache is per-folder; without\n // knowing which folders were affected we drop the lot. Cheap (just\n // a Map clear) and the next IPC repopulates each on demand.\n listCache.clear();\n\n const body = document.getElementById(\"ml-body\");\n if (body) {\n const stateUids = new Set(state.getMessages().map(m => `${m.accountId}:${m.uid}`));\n for (const row of Array.from(body.querySelectorAll(\".ml-row\"))) {\n const el = row as HTMLElement;\n const key = `${el.dataset.accountId}:${el.dataset.uid}`;\n if (!stateUids.has(key)) {\n // Use the row object's detach() not el.remove() so rowByKey\n // stays clean. Stale entries there broke the auto-advance\n // path: focusByIdentity(survivor) could match a deleted row\n // whose .el was already gone from the DOM, then focusRow\n // would call setSelected on a detached node \u2014 visually\n // nothing happened, viewer cleared. Single ownership =\n // rowByKey is in sync with the DOM.\n const dead = rowByKey.get(key);\n if (dead) dead.detach();\n else el.remove();\n }\n }\n if (state.getMessages().length === 0) {\n body.innerHTML = `<div class=\"ml-empty\">No messages</div>`;\n }\n }\n\n if (outcome.focusedWasRemoved) {\n const survivor = outcome.nextSurvivor;\n if (survivor && focusByIdentity(survivor.accountId, survivor.uid)) {\n // focusByIdentity handled the transition (DOM class + viewer).\n return;\n }\n // Survivor identity didn't resolve to a rendered row. Most common\n // case: state computed a survivor that's still loading or got\n // filtered. Fall back to the first row currently in state \u2014 there\n // IS a list, so showing \"Select a message\" while rows are visible\n // is the architectural fuckup (Bob 2026-05-14). Walk state in\n // order; the first row that has a live DOM/rowByKey entry wins.\n const remaining = state.getMessages();\n for (const m of remaining) {\n if (focusByIdentity(m.accountId, m.uid)) return;\n }\n // Truly empty folder (state.length === 0). Now releasing focus is\n // honest \u2014 there's no row to show.\n releaseFocus();\n }\n}\n\n/** Reload the currently displayed folder (preserves current selection).\n * No-op when in search mode \u2014 search is a query snapshot, not a live\n * feed. Sync / IDLE arrivals re-running the search would yank focus\n * away from whatever the user is reading and reshuffle the result\n * list every few seconds. The user can re-trigger search explicitly\n * by pressing Enter or modifying the query. */\nexport function reloadCurrentFolder(): void {\n if (searchMode) return;\n if (unifiedMode) {\n loadUnifiedInbox(false);\n } else if (currentAccountId && currentFolderId) {\n loadMessages(currentAccountId, currentFolderId, 1, \"\", false);\n }\n}\n\n/** Exit search mode without triggering a reload \u2014 caller decides what to load\n * next. Restores the pre-search view mode (unified vs folder) so the\n * subsequent reloadCurrentFolder() call lands on the right branch.\n * Without the unifiedMode restore, clearing a search that was started\n * from \"All Inboxes\" left the list permanently empty. */\nexport function clearSearchMode(): void {\n searchMode = false;\n currentSearchQuery = \"\";\n if (wasUnifiedBeforeSearch) unifiedMode = true;\n wasUnifiedBeforeSearch = false;\n}\n\n/** Load unified inbox (all accounts) */\nexport async function loadUnifiedInbox(autoSelect = true): Promise<void> {\n const myGen = ++loadGen;\n unifiedMode = true;\n searchMode = false;\n currentSpecialUse = \"\";\n showToInsteadOfFrom = false; // Unified inbox always shows From, not To\n currentPage = 1;\n totalMessages = 0;\n\n const body = document.getElementById(\"ml-body\");\n if (!body) return;\n const fromHeader = document.querySelector(\".ml-col-from\");\n if (fromHeader) fromHeader.textContent = \"From\";\n\n // Saved per-view position wins over current DOM selection \u2014 same\n // rationale as loadMessages.\n const remembered = positionMemory.get(CACHE_KEY_UNIFIED);\n const savedScroll = remembered?.scroll ?? (!autoSelect ? body.scrollTop : 0);\n\n // Instant first paint from cache. The IPC fires in background below\n // and only triggers a re-render if the result diverges from the\n // cached snapshot.\n const cached = listCache.get(CACHE_KEY_UNIFIED);\n if (cached) {\n totalMessages = cached.total;\n state.setMessages(cached.items);\n renderMessages(body, \"\", cached.items);\n const targetUuid = remembered ? pickRestoreUid(cached.items, remembered) : null;\n if (targetUuid) {\n body.scrollTop = savedScroll;\n restoreSelection(body, targetUuid);\n } else if (autoSelect) {\n selectFirst(body);\n }\n } else if (autoSelect) {\n body.innerHTML = `<div class=\"ml-empty\">Loading...</div>`;\n }\n\n try {\n const result = await apiGetUnifiedInbox(1);\n if (myGen !== loadGen) return; // user moved on; drop stale response\n totalMessages = result.total;\n listCache.set(CACHE_KEY_UNIFIED, { items: result.items, total: result.total, timestamp: Date.now() });\n\n // Skip the re-render entirely if the IPC result matches what\n // the cache already painted. Avoids DOM churn / scroll-jump\n // on the common no-change refresh.\n if (cached && listResultsEqual(cached.items, result.items)) return;\n\n if (result.items.length === 0) {\n state.setMessages([]);\n body.innerHTML = `<div class=\"ml-empty\">${result.total > 0 ? `${result.total} messages syncing...` : \"Syncing \u2014 messages will appear shortly\"}</div>`;\n return;\n }\n\n state.setMessages(result.items);\n renderMessages(body, \"\", result.items);\n\n const targetUuid = remembered ? pickRestoreUid(result.items, remembered) : null;\n if (targetUuid) {\n body.scrollTop = savedScroll;\n restoreSelection(body, targetUuid);\n } else if (autoSelect) {\n selectFirst(body);\n }\n } catch (e: any) {\n if (e.name === \"AbortError\") return;\n if (myGen !== loadGen) return;\n body.innerHTML = `<div class=\"ml-empty\">Error: ${e.message}</div>`;\n }\n}\n\n/** Load search results.\n * `includeTrashSpam` only matters for global (\"all\") and \"server\" scopes \u2014\n * per-folder search ALWAYS returns matches in that folder, including when\n * the folder itself is trash/junk. */\nexport async function loadSearchResults(query: string, scope = \"all\", accountId = \"\", folderId = 0, includeTrashSpam = false): Promise<void> {\n const myGen = ++loadGen;\n // Capture the pre-search mode on the first transition only \u2014 repeated\n // searches (typing more characters) shouldn't overwrite it with the\n // intermediate `unifiedMode = false` state.\n if (!searchMode) wasUnifiedBeforeSearch = unifiedMode;\n searchMode = true;\n unifiedMode = false;\n currentSearchQuery = query;\n currentPage = 1;\n totalMessages = 0;\n\n const body = document.getElementById(\"ml-body\");\n if (!body) return;\n\n // Search reload tears down the current row set \u2014 focus must release\n // along with the rows. releaseFocus clears the preview pane in the\n // same call frame, so no orphan preview lingers behind a list that\n // no longer contains the previously-shown row.\n releaseFocus();\n\n // Only wipe to \"Searching...\" when there are no rows already showing.\n // Otherwise the list blanks every time sync fires `reloadCurrentFolder`\n // (which re-runs loadSearchResults), making the user see results\n // appear then disappear when an IDLE/sync event lands mid-typing.\n // The gen counter still guarantees only the latest query's rows\n // render \u2014 stale results don't sneak through.\n const hasExistingRows = body.querySelector(\".ml-row\") !== null;\n if (!hasExistingRows) {\n body.innerHTML = `<div class=\"ml-empty\">Searching...</div>`;\n }\n\n const _searchT0 = performance.now();\n const _stamp = (label: string): void => {\n const line = `[search ${(performance.now() - _searchT0).toFixed(0).padStart(5)} ms] ${label} q=${JSON.stringify(query)}`;\n console.log(\" \" + line);\n try { (window as any).mailxapi?.logClientEvent?.(line); } catch { /* */ }\n };\n _stamp(\"start\");\n try {\n // Regex search: filter client-side\n if (query.startsWith(\"/\") && query.endsWith(\"/\") && query.length > 2) {\n const pattern = query.slice(1, -1);\n let regex: RegExp;\n try { regex = new RegExp(pattern, \"i\"); } catch { body.innerHTML = `<div class=\"ml-empty\">Invalid regex</div>`; return; }\n\n const source = scope === \"current\" && accountId\n ? await apiGetMessages(accountId, folderId, 1, 10000)\n : await apiGetUnifiedInbox(1, 10000);\n if (myGen !== loadGen) return;\n const matches = source.items.filter((m: any) =>\n regex.test(m.subject || \"\") || regex.test(m.from?.name || \"\") || regex.test(m.from?.address || \"\") || regex.test(m.preview || \"\")\n );\n totalMessages = matches.length;\n state.setMessages(matches);\n if (matches.length === 0) { body.innerHTML = `<div class=\"ml-empty\">No regex matches</div>`; return; }\n body.innerHTML = \"\";\n appendMessages(body, \"\", matches);\n selectFirst(body);\n return;\n }\n\n _stamp(\"IPC begin\");\n const result = await searchMessages(query, 1, 50, scope, accountId, folderId, includeTrashSpam);\n _stamp(`IPC done (${result.items.length}/${result.total})`);\n if (myGen !== loadGen) return;\n totalMessages = result.total;\n\n // Server search may have failed in some folders. Surfacing it matters\n // most for the zero-results case: \"No results\" + 5 folders that never\n // got searched is NOT the same as \"definitely not on the server\".\n const partial = !!(result as any).partial;\n const foldersFailed = (result as any).foldersFailed || 0;\n const foldersSearched = (result as any).foldersSearched || 0;\n const partialNote = partial\n ? `<div class=\"ml-search-warning\">\u26A0 Searched ${foldersSearched} folder(s); ${foldersFailed} could not be searched (timeout / connection). Results may be incomplete \u2014 retry to search them again.</div>`\n : \"\";\n\n if (result.items.length === 0) {\n state.setMessages([]);\n body.innerHTML = partialNote +\n `<div class=\"ml-empty\">No results for \"${query}\"${partial ? \" in the folders that responded\" : \"\"}</div>`;\n return;\n }\n\n state.setMessages(result.items);\n body.innerHTML = partialNote;\n appendMessages(body, \"\", result.items);\n // Auto-focus the first result so the preview shows immediately \u2014\n // matching the regex-search path and the loadMessages default.\n selectFirst(body);\n } catch (e: any) {\n body.innerHTML = `<div class=\"ml-empty\">Search error: ${e.message}</div>`;\n }\n}\n\nexport async function loadMessages(accountId: string, folderId: number, page = 1, specialUse = \"\", autoSelect = true): Promise<void> {\n const myGen = ++loadGen;\n searchMode = false;\n unifiedMode = false;\n // Folder switch clears any in-progress multi-select \u2014 carrying a \"3\n // selected\" state across folders would lie about what rows the bulk\n // buttons would act on.\n exitMultiSelect();\n // specialUse is either the DB tag (\"sent\"/\"drafts\"/\"outbox\") or the\n // folder path lowercased (folder-tree fallback when tag is missing \u2014 common\n // on Dovecot which doesn't advertise \\Sent). Match both cases.\n // Empty specialUse on reload means \"keep what we had\" \u2014 otherwise a\n // folderCountsChanged event or sort-header click resets Sent/Drafts/Outbox\n // back to showing From (user-reported regression 2026-04-24).\n if (specialUse) currentSpecialUse = specialUse;\n const su = currentSpecialUse.toLowerCase();\n showToInsteadOfFrom = su === \"sent\" || su === \"drafts\" || su === \"outbox\"\n || su.endsWith(\"sent\") || su.endsWith(\"drafts\") || su.endsWith(\"outbox\")\n || su === \"sent items\" || su === \"sent mail\" || su.endsWith(\"/sent items\") || su.endsWith(\".sent items\");\n currentAccountId = accountId;\n currentFolderId = folderId;\n currentPage = 1;\n totalMessages = 0;\n\n const body = document.getElementById(\"ml-body\");\n if (!body) return;\n\n // Update header label\n const fromHeader = document.querySelector(\".ml-col-from\");\n if (fromHeader) fromHeader.textContent = showToInsteadOfFrom ? \"To\" : \"From\";\n\n const flaggedOnly = document.getElementById(\"ml-body\")?.classList.contains(\"flagged-only\") || false;\n const cKey = cacheKey(\"folder\", accountId, folderId, flaggedOnly);\n // Saved per-view position takes priority over the DOM's current\n // selection \u2014 when switching from folder A to B and back, the DOM\n // shows B's selected row but we want to land on whatever was last\n // focused in A.\n const remembered = positionMemory.get(cKey);\n const savedScroll = remembered?.scroll ?? (!autoSelect ? body.scrollTop : 0);\n const cached = listCache.get(cKey);\n if (cached) {\n totalMessages = cached.total;\n state.setMessages(cached.items);\n renderMessages(body, accountId, cached.items);\n const targetUuid = remembered ? pickRestoreUid(cached.items, remembered) : null;\n if (targetUuid) {\n requestAnimationFrame(() => {\n body.scrollTop = savedScroll;\n restoreSelection(body, targetUuid);\n });\n } else if (autoSelect) {\n selectFirst(body);\n }\n } else if (autoSelect) {\n body.innerHTML = `<div class=\"ml-empty\">Loading...</div>`;\n }\n\n try {\n const result = await apiGetMessages(accountId, folderId, 1, 50, flaggedOnly, currentSort, currentSortDir);\n // Stale-response guard: a newer load* fired while we were\n // awaiting; the new view already painted. Drop this result\n // silently rather than overwriting.\n if (myGen !== loadGen) return;\n totalMessages = result.total;\n listCache.set(cKey, { items: result.items, total: result.total, timestamp: Date.now() });\n updateSortIndicators();\n // Skip re-render if data unchanged from the painted cache.\n if (cached && listResultsEqual(cached.items, result.items)) return;\n\n if (result.items.length === 0) {\n state.setMessages([]);\n body.innerHTML = `<div class=\"ml-empty\">${flaggedOnly ? \"No flagged messages\" : \"No messages\"}</div>`;\n return;\n }\n\n state.setMessages(result.items);\n renderMessages(body, accountId, result.items);\n\n // Prefer saved position; otherwise default by autoSelect.\n const targetUuid = remembered ? pickRestoreUid(result.items, remembered) : null;\n if (targetUuid) {\n requestAnimationFrame(() => {\n if (myGen !== loadGen) return;\n body.scrollTop = savedScroll;\n restoreSelection(body, targetUuid);\n });\n } else if (autoSelect) {\n selectFirst(body);\n }\n } catch (e: any) {\n if (e.name === \"AbortError\") return;\n if (myGen !== loadGen) return; // user moved on; suppress the error message\n body.innerHTML = `<div class=\"ml-empty\">Error: ${e.message}</div>`;\n }\n}\n\nasync function loadMoreMessages(): Promise<void> {\n const body = document.getElementById(\"ml-body\");\n if (!body) return;\n\n loading = true;\n currentPage++;\n\n try {\n const flaggedOnly = body.classList.contains(\"flagged-only\");\n const result = searchMode\n ? await searchMessages(currentSearchQuery, currentPage)\n : unifiedMode\n ? await apiGetUnifiedInbox(currentPage)\n : await apiGetMessages(currentAccountId, currentFolderId, currentPage, 50, flaggedOnly);\n // Append to state\n const current = state.getMessages();\n state.setMessages([...current, ...result.items]);\n appendMessages(body, unifiedMode ? \"\" : currentAccountId, result.items);\n } catch (e: any) {\n console.error(`Load more error: ${e.message}`);\n } finally {\n loading = false;\n }\n}\n\n/** Replace body contents with rendered rows */\nfunction renderMessages(body: HTMLElement, accountId: string, items: any[]): void {\n const fragment = document.createDocumentFragment();\n const tempDiv = document.createElement(\"div\");\n appendMessages(tempDiv, accountId, items);\n while (tempDiv.firstChild) fragment.appendChild(tempDiv.firstChild);\n body.replaceChildren(fragment);\n}\n\nfunction selectFirst(body: HTMLElement): void {\n // Narrow viewports (Android, phone-sized): don't auto-select. The\n // click handler in app.ts switches the layout to \"narrow-active\" on\n // any list-row click, which on a phone means the message viewer takes\n // over the screen and hides the list. Auto-selecting at startup\n // therefore lands the user in the LAST letter they read instead of\n // the inbox summary they wanted. Desktop unchanged \u2014 auto-select\n // remains useful when the list and viewer are side-by-side.\n if (window.innerWidth <= 768) return;\n const firstRow = body.querySelector(\".ml-row\") as HTMLElement;\n if (firstRow) firstRow.click();\n}\n\nfunction restoreSelection(body: HTMLElement, savedUuid: string | null | undefined): void {\n if (!savedUuid) return;\n // Locate the row by stable uuid \u2014 unique, so this is the exact message\n // the user had selected, never a uid-number collision in another folder.\n const row = body.querySelector(`.ml-row[data-uuid=\"${CSS.escape(savedUuid)}\"]`) as HTMLElement | null;\n if (!row) return;\n const accountId = row.dataset.accountId;\n const uid = Number(row.dataset.uid);\n if (accountId && Number.isFinite(uid)) {\n // {scroll:false} \u2014 programmatic restore after a sync-driven reload.\n // The user might be reading rows ABOVE the focused one; do NOT yank\n // their scroll back to the focused row every time the list rebuilds.\n focusByIdentity(accountId, uid, { scroll: false });\n }\n}\n\n/** Show a floating list of all messages in a thread when the pill is clicked.\n * Each entry in the popup selects that message in the viewer when clicked.\n * This is simpler than inline expansion and avoids duplicating the row builder. */\nexport async function showThreadPopup(pillEl: HTMLElement, headMsg: any): Promise<void> {\n // Remove any existing popup\n document.querySelectorAll(\".ml-thread-popup\").forEach(el => el.remove());\n let thread: any[] = [];\n try { thread = await getThreadMessages(headMsg.accountId, headMsg.threadId); } catch { /* ignore */ }\n if (!thread || thread.length === 0) return;\n thread.sort((a, b) => (a.date || 0) - (b.date || 0));\n const popup = document.createElement(\"div\");\n popup.className = \"ml-thread-popup\";\n for (const msg of thread) {\n const item = document.createElement(\"div\");\n item.className = \"ml-thread-popup-item\";\n if (!seenOf(msg)) item.classList.add(\"unread\");\n const from = document.createElement(\"span\");\n from.className = \"ml-thread-popup-from\";\n from.textContent = msg.from?.name || msg.from?.address || \"?\";\n const date = document.createElement(\"span\");\n date.className = \"ml-thread-popup-date\";\n date.textContent = formatDate(msg.date);\n const subject = document.createElement(\"span\");\n subject.className = \"ml-thread-popup-subject\";\n subject.textContent = msg.subject || \"(no subject)\";\n item.appendChild(from);\n item.appendChild(date);\n item.appendChild(subject);\n item.addEventListener(\"click\", async () => {\n // Thread popup \u2192 viewer-only update. The list keeps showing the\n // thread head highlighted (it's the row in the actual list);\n // the viewer pivots to the clicked thread member. Single path\n // in: the viewer's show() call. No state, no event, no drift.\n const envelope = {\n accountId: msg.accountId,\n uid: msg.uid,\n folderId: msg.folderId,\n subject: msg.subject,\n from: msg.from,\n to: msg.to,\n cc: msg.cc,\n date: msg.date,\n flags: msg.flags,\n size: msg.size,\n preview: msg.preview,\n hasAttachments: msg.hasAttachments,\n };\n viewerShow(msg.accountId, msg.uid, msg.folderId, currentSpecialUse || undefined, false, envelope as ListMessage);\n onMessageSelect(msg.accountId, msg.uid, msg.folderId);\n popup.remove();\n });\n popup.appendChild(item);\n }\n document.body.appendChild(popup);\n const rect = pillEl.getBoundingClientRect();\n popup.style.left = `${rect.left}px`;\n popup.style.top = `${rect.bottom + 4}px`;\n // Dismiss on outside click\n setTimeout(() => {\n const dismiss = (e: MouseEvent) => {\n if (!popup.contains(e.target as Node)) {\n popup.remove();\n document.removeEventListener(\"mousedown\", dismiss, true);\n }\n };\n document.addEventListener(\"mousedown\", dismiss, true);\n }, 0);\n}\n\n/** A rendered row in the message list.\n *\n * Owns its DOM element, the message envelope it represents, and all of\n * its event handlers (click, dblclick, drag, contextmenu, touch\n * long-press, plus the avatar / flag / thread-pill child handlers).\n * State changes that affect appearance (selected, unread, flagged,\n * body-cached) go through methods so the OO layer is the single point\n * of mutation \u2014 no `el.classList.toggle(\"selected\")` scattered through\n * the codebase.\n *\n * Lifecycle: constructor builds DOM and wires handlers; `attach(body)`\n * inserts into the list; `detach()` removes from DOM and from the\n * module-level rowByKey map. The list controller diff-updates rows on\n * list mutations.\n *\n * Multi-select still uses `.selected` class queries \u2014 that's a single\n * DOM-level concept where drift isn't an issue (no separate render\n * surface to drift against). The Row class wraps the *focus* concept\n * (which couples to the viewer) as a fate-shared unit. */\nclass MessageRow {\n el: HTMLElement;\n private flagEl: HTMLSpanElement;\n\n constructor(\n public msg: any,\n public accountId: string,\n threadHead: boolean,\n threadCount: number,\n showAccountTag: boolean,\n ) {\n const row = document.createElement(\"div\");\n this.el = row;\n row.className = \"ml-row\";\n row.draggable = true;\n if (!seenOf(msg)) row.classList.add(\"unread\");\n if (flaggedOf(msg)) row.classList.add(\"flagged\");\n if (!msg.bodyPath) row.classList.add(\"not-downloaded\");\n if (msg.pending) row.classList.add(\"pending-reconcile\");\n if (msg.inReplyTo) row.classList.add(\"is-reply\");\n if (isPriorityAddr(msg.from?.address || \"\")) row.classList.add(\"priority\");\n row.dataset.uid = String(msg.uid);\n row.dataset.accountId = accountId;\n row.dataset.folderId = String(msg.folderId);\n // Stable local identity. `uid` is only unique within (account, folder)\n // \u2014 in a unified / multi-folder list two messages can share a uid\n // NUMBER, so selection memory keyed on bare uid can rebind the viewer\n // to the wrong letter after a re-render. `uuid` is globally unique\n // and never changes; selection restore keys on it.\n if (msg.uuid) row.dataset.uuid = msg.uuid;\n if (msg.threadId) row.dataset.threadId = msg.threadId;\n if (threadHead) row.classList.add(\"thread-head\");\n\n // \u2500\u2500 Avatar (sender circle, doubles as multi-select affordance) \u2500\u2500\n const fromName = (showToInsteadOfFrom && msg.to?.length)\n ? (msg.to[0].name || msg.to[0].address || \"?\")\n : (msg.from?.name || msg.from?.address || \"?\");\n const seedAddr = (msg.from?.address || msg.from?.name || \"?\").toLowerCase();\n const initial = (fromName.replace(/^[\\W_]+/, \"\") || \"?\").charAt(0).toUpperCase();\n const avatar = document.createElement(\"span\");\n avatar.className = \"ml-avatar\";\n avatar.textContent = initial;\n avatar.style.background = senderColor(seedAddr);\n avatar.title = msg.from?.address || \"\";\n avatar.addEventListener(\"click\", (e) => this.onAvatarClick(e));\n avatar.addEventListener(\"contextmenu\", (e) => this.onAvatarContextMenu(e));\n\n // \u2500\u2500 Flag star (toggle on click) \u2500\u2500\n const flag = document.createElement(\"span\");\n flag.className = \"ml-flag\";\n flag.textContent = flaggedOf(msg) ? \"\u2605\" : \"\u2606\";\n flag.title = \"Toggle flag\";\n flag.addEventListener(\"click\", (e) => this.onFlagClick(e));\n this.flagEl = flag;\n\n // \u2500\u2500 From column \u2500\u2500\n const from = document.createElement(\"span\");\n from.className = \"ml-from\";\n if (showToInsteadOfFrom && msg.to?.length) {\n from.textContent = msg.to.map((a: any) => a.name || a.address).join(\", \");\n } else {\n from.textContent = msg.from.name || msg.from.address;\n }\n if (showAccountTag && accountId) {\n const tag = document.createElement(\"span\");\n tag.className = \"ml-account-tag\";\n tag.textContent = accountId.charAt(0).toUpperCase();\n tag.title = accountId;\n from.prepend(tag);\n }\n if (msg.folderName) {\n const folderTag = document.createElement(\"span\");\n folderTag.className = \"ml-folder-tag\";\n folderTag.textContent = msg.folderName;\n folderTag.title = `In folder: ${msg.folderName}`;\n from.prepend(folderTag);\n }\n if ((msg.dupeCount as number) >= 2) {\n const dupe = document.createElement(\"span\");\n dupe.className = \"ml-dupe-tag\";\n dupe.textContent = \"\u21C6\";\n dupe.title = `Same message on ${msg.dupeCount} accounts`;\n from.prepend(dupe);\n }\n\n // \u2500\u2500 Subject (with optional thread pill + preview snippet) \u2500\u2500\n const subject = document.createElement(\"span\");\n subject.className = \"ml-subject\";\n subject.innerHTML = escapeHtml(msg.subject);\n if (threadHead && threadCount > 1 && msg.threadId) {\n const threadPill = document.createElement(\"span\");\n threadPill.className = \"ml-thread-pill\";\n threadPill.textContent = String(threadCount);\n threadPill.title = `${threadCount} messages in this thread \u2014 click to see list`;\n threadPill.addEventListener(\"click\", async (e) => {\n e.stopPropagation();\n await showThreadPopup(threadPill, msg);\n });\n subject.prepend(threadPill);\n }\n if (msg.preview) {\n const preview = document.createElement(\"span\");\n preview.className = \"ml-preview\";\n preview.textContent = ` \u2014 ${msg.preview}`;\n subject.appendChild(preview);\n }\n\n // \u2500\u2500 Date column (with status icons: attachment / replied / forwarded) \u2500\u2500\n const date = document.createElement(\"span\");\n date.className = \"ml-date\";\n const icons = document.createElement(\"span\");\n icons.className = \"ml-status-icons\";\n renderStatusIcons(icons, msg);\n const dateText = document.createElement(\"span\");\n dateText.className = \"ml-date-text\";\n dateText.textContent = formatDate(msg.date);\n date.appendChild(icons);\n date.appendChild(dateText);\n\n row.appendChild(avatar);\n row.appendChild(flag);\n row.appendChild(from);\n row.appendChild(date);\n row.appendChild(subject);\n\n row.addEventListener(\"click\", (e) => this.onRowClick(e));\n row.addEventListener(\"dblclick\", (e) => this.onRowDoubleClick(e));\n row.addEventListener(\"dragstart\", (e) => this.onDragStart(e));\n row.addEventListener(\"dragend\", () => row.classList.remove(\"dragging\"));\n row.addEventListener(\"contextmenu\", (e) => this.onRowContextMenu(e));\n this.wireLongPress(row);\n }\n\n /** Insert this row into a list body and register it in the lookup map. */\n attach(body: HTMLElement): void {\n body.appendChild(this.el);\n rowByKey.set(rowKey(this.accountId, this.msg.uid), this);\n }\n\n /** Remove this row from the DOM and the lookup map. If it was the\n * focused row, the controller is responsible for releasing focus\n * (this method doesn't auto-clear the viewer because the controller\n * may want to hand focus to a sibling instead). */\n detach(): void {\n this.el.remove();\n rowByKey.delete(rowKey(this.accountId, this.msg.uid));\n }\n\n setSelected(yes: boolean): void { this.el.classList.toggle(\"selected\", yes); }\n get isSelected(): boolean { return this.el.classList.contains(\"selected\"); }\n setUnreadClass(yes: boolean): void { this.el.classList.toggle(\"unread\", yes); }\n setFlaggedClass(yes: boolean): void {\n this.el.classList.toggle(\"flagged\", yes);\n this.flagEl.textContent = yes ? \"\u2605\" : \"\u2606\";\n }\n markBodyCached(): void { this.el.classList.remove(\"not-downloaded\"); }\n /** Update the row's date column. Used by the draft-saved listener so\n * Ctrl+S in compose bumps the time in the Drafts list immediately\n * rather than waiting for the next IMAP APPEND + sync round-trip. */\n setDate(epochMs: number): void {\n this.msg.date = epochMs;\n const el = this.el.querySelector(\".ml-date-text\") as HTMLElement | null;\n if (el) el.textContent = formatDate(epochMs);\n }\n\n /** Re-render the attachment / replied / forwarded glyphs in the date\n * cell. Called when a flag-change event (Answered, $Forwarded) lands\n * for this row so the icon strip reflects current state without a\n * full row rebuild. */\n refreshStatusIcons(): void {\n const icons = this.el.querySelector(\".ml-status-icons\") as HTMLElement | null;\n if (icons) renderStatusIcons(icons, this.msg);\n }\n\n // Visual-state accessors for flag toggles. Read the *visual* state\n // (CSS class), not `this.msg.flags`, because the flag array can lag\n // the rendered class \u2014 auto-mark-as-read removes the `unread` class\n // immediately on click but `\\Seen` doesn't land in `msg.flags` until\n // the auto-mark timer fires (default 2 s). Right-click menus and\n // keyboard shortcuts ask \"what does the user see?\" \u2014 that's what\n // these getters answer. Setters keep both visual + flag-array in\n // sync so the next read by either side agrees.\n get isSeen(): boolean { return !this.el.classList.contains(\"unread\"); }\n set isSeen(yes: boolean) {\n this.setUnreadClass(!yes);\n setSeen(this.msg, yes);\n }\n get isFlagged(): boolean { return this.el.classList.contains(\"flagged\"); }\n set isFlagged(yes: boolean) {\n this.setFlaggedClass(yes);\n setFlagged(this.msg, yes);\n }\n\n private onAvatarClick(e: MouseEvent): void {\n e.stopPropagation();\n const body = document.getElementById(\"ml-body\");\n if (!body) return;\n if (body.classList.contains(\"multi-select-on\")) {\n this.setSelected(!this.isSelected);\n } else {\n clearSelection();\n this.setSelected(true);\n body.classList.add(\"multi-select-on\");\n }\n lastClickedRow = this.el;\n updateBulkBar();\n }\n\n private async onAvatarContextMenu(e: MouseEvent): Promise<void> {\n e.preventDefault();\n e.stopPropagation();\n const { showContextMenu: showMenu } = await import(\"./context-menu.js\");\n const body = document.getElementById(\"ml-body\");\n const visibleRows = body\n ? Array.from(body.querySelectorAll<HTMLElement>(\".ml-row:not(.filter-hidden)\"))\n : [];\n const selectedCount = body\n ? body.querySelectorAll(\".ml-row.selected\").length\n : 0;\n showMenu(e.clientX, e.clientY, [\n {\n label: `Select all (${visibleRows.length})`,\n action: () => {\n if (!body) return;\n body.classList.add(\"multi-select-on\");\n for (const r of visibleRows) r.classList.add(\"selected\");\n lastClickedRow = visibleRows[visibleRows.length - 1] || null;\n updateBulkBar();\n },\n disabled: visibleRows.length === 0,\n },\n {\n label: `Clear selection${selectedCount ? ` (${selectedCount})` : \"\"}`,\n action: () => exitMultiSelect(),\n disabled: selectedCount === 0,\n },\n {\n label: \"Invert selection\",\n action: () => {\n if (!body) return;\n body.classList.add(\"multi-select-on\");\n for (const r of visibleRows) r.classList.toggle(\"selected\");\n lastClickedRow = visibleRows[visibleRows.length - 1] || null;\n updateBulkBar();\n },\n disabled: visibleRows.length === 0,\n },\n ]);\n }\n\n private async onFlagClick(e: MouseEvent): Promise<void> {\n e.stopPropagation();\n // Toggle relative to visual state. The row's `isFlagged` setter\n // would update msg.flags + class together, but we want to send\n // the new array to the server BEFORE persisting locally so a\n // network failure leaves the row in a known state.\n const newFlaggedState = !this.el.classList.contains(\"flagged\");\n const probe = { flags: [...(this.msg.flags || [])] };\n setFlagged(probe, newFlaggedState);\n try {\n await updateFlags(this.accountId, this.msg.uid, probe.flags);\n this.msg.flags = probe.flags;\n this.setFlaggedClass(newFlaggedState);\n } catch { /* ignore \u2014 visual state unchanged */ }\n }\n\n private onRowClick(e: MouseEvent): void {\n if (touchWasScroll) { touchWasScroll = false; return; }\n const body = this.el.parentElement as HTMLElement | null;\n if (body?.classList.contains(\"multi-select-on\")) {\n const willBeSelected = !this.isSelected;\n this.setSelected(willBeSelected);\n lastClickedRow = this.el;\n // 2026-05-13: clicking a row in multi-select mode wasn't showing\n // it in the viewer \u2014 Bob saw a blue-selected row with the empty\n // \"Select a message to read\" pane. Drive the viewer off the\n // last-toggled-on row so the message you just clicked is the\n // one you see. Deselecting doesn't change the focus.\n if (willBeSelected) focusRow(this);\n updateBulkBar();\n return;\n }\n // Note: no `setUnreadClass(false)` on click. The unread\u2192read flip\n // is the auto-mark-as-read path's job (showMessage runs an STORE\n // \\Seen after the dwell delay; on success it calls setRowSeen).\n // Pre-2026-05-13 we flipped the class immediately on click which\n // produced the \"menu says 'Mark unread' on a row whose body never\n // loaded\" complaint.\n if (e.shiftKey) {\n const anchor = resolveShiftAnchor();\n if (anchor) {\n clearSelection();\n selectRange(anchor, this.el);\n lastClickedRow = this.el;\n focusRow(this);\n } else {\n clearSelection();\n focusRow(this);\n lastClickedRow = this.el;\n }\n } else if (e.ctrlKey || e.metaKey) {\n this.setSelected(!this.isSelected);\n lastClickedRow = this.el;\n } else {\n clearSelection();\n focusRow(this);\n lastClickedRow = this.el;\n }\n updateBulkBar();\n }\n\n private onRowDoubleClick(e: MouseEvent): void {\n e.preventDefault();\n e.stopPropagation();\n document.dispatchEvent(new CustomEvent(\"mailx-popout-message\", {\n detail: { accountId: this.accountId, uid: this.msg.uid, folderId: this.msg.folderId, subject: this.msg.subject },\n }));\n }\n\n private onDragStart(e: DragEvent): void {\n if (!this.isSelected) {\n clearSelection();\n this.setSelected(true);\n lastClickedRow = this.el;\n }\n const selected = getSelectedMessages();\n e.dataTransfer!.setData(\"application/x-mailx-messages\", JSON.stringify(selected));\n e.dataTransfer!.setData(\"application/x-mailx-message\", JSON.stringify({\n accountId: this.accountId,\n uid: this.msg.uid,\n folderId: this.msg.folderId,\n subject: this.msg.subject,\n }));\n e.dataTransfer!.effectAllowed = \"copyMove\";\n this.el.classList.add(\"dragging\");\n if (selected.length > 1) {\n const badge = document.createElement(\"div\");\n badge.textContent = `${selected.length} messages`;\n badge.style.cssText = \"position:absolute;top:-1000px;background:#333;color:white;padding:4px 8px;border-radius:4px;font-size:12px\";\n document.body.appendChild(badge);\n e.dataTransfer!.setDragImage(badge, 0, 0);\n setTimeout(() => badge.remove(), 0);\n }\n }\n\n private wireLongPress(row: HTMLElement): void {\n let longPressTimer: ReturnType<typeof setTimeout> | null = null;\n const LONG_PRESS_MS = 550;\n row.addEventListener(\"touchstart\", (_e: TouchEvent) => {\n if (longPressTimer) clearTimeout(longPressTimer);\n longPressTimer = setTimeout(() => {\n longPressTimer = null;\n const body = row.parentElement as HTMLElement | null;\n const alreadyMulti = body?.classList.contains(\"multi-select-on\");\n if (alreadyMulti) {\n this.setSelected(!this.isSelected);\n } else {\n clearSelection();\n this.setSelected(true);\n body?.classList.add(\"multi-select-on\");\n }\n lastClickedRow = this.el;\n updateBulkBar();\n try { (navigator as any).vibrate?.(20); } catch { /* */ }\n }, LONG_PRESS_MS);\n }, { passive: true });\n const cancelLongPress = () => {\n if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }\n };\n row.addEventListener(\"touchmove\", cancelLongPress, { passive: true });\n row.addEventListener(\"touchend\", cancelLongPress, { passive: true });\n row.addEventListener(\"touchcancel\", cancelLongPress, { passive: true });\n }\n\n private onRowContextMenu(e: MouseEvent): void {\n e.preventDefault();\n const body = this.el.parentElement as HTMLElement | null;\n const inMulti = !!body?.classList.contains(\"multi-select-on\");\n if (!this.isSelected) {\n if (inMulti) {\n this.setSelected(true);\n lastClickedRow = this.el;\n } else {\n clearSelection();\n lastClickedRow = this.el;\n focusRow(this);\n }\n }\n\n // Read state for the menu label tracks the VISUAL state, not the\n // flag array. On click we remove the `unread` class immediately\n // (optimistic) but `msg.flags` doesn't pick up `\\Seen` until the\n // auto-mark-as-read timer fires (default 2 s). Bob 2026-05-13:\n // right-clicking a freshly-clicked row offered \"Mark read\" while\n // the row already looked read. Reading the class keeps the label\n // honest with what the user sees.\n const isSeen = !this.el.classList.contains(\"unread\");\n const isFlagged = this.el.classList.contains(\"flagged\");\n const accountId = this.accountId;\n const msg = this.msg;\n const self = this;\n\n const items: MenuItem[] = [\n {\n label: isSeen ? \"Mark unread\" : \"Mark read\",\n action: async () => {\n // Target state = inverse of what the menu showed,\n // matching what the user clicked. Visual-state-based\n // labels stay coherent with the auto-mark-as-read\n // debounce; the new flag array is computed via the\n // typed setter and sent to the server.\n const probe = { flags: [...(msg.flags || [])] };\n setSeen(probe, !isSeen);\n try {\n await updateFlags(accountId, msg.uid, probe.flags);\n msg.flags = probe.flags;\n state.updateMessageFlags(accountId, msg.uid, probe.flags);\n self.setUnreadClass(isSeen);\n } catch { /* ignore */ }\n },\n },\n {\n label: isFlagged ? \"Unflag\" : \"Flag\",\n action: async () => {\n const probe = { flags: [...(msg.flags || [])] };\n setFlagged(probe, !isFlagged);\n try {\n await updateFlags(accountId, msg.uid, probe.flags);\n msg.flags = probe.flags;\n self.setFlaggedClass(!isFlagged);\n } catch { /* ignore */ }\n },\n },\n { label: \"\", action: () => {}, separator: true },\n // Drafts get an explicit \"Edit draft\" entry as the primary action \u2014\n // double-click already opens the row in compose, but the context\n // menu had no equivalent (Bob 2026-05-13). Detect via \\Draft flag\n // or the Drafts folder context. Edit triggers the same path as\n // double-click: dispatching popout-message lets app.ts's draft\n // detection route through to compose.\n ...((() => {\n const isDraftRow = draftOf(msg) || currentSpecialUse.toLowerCase() === \"drafts\";\n if (!isDraftRow) return [] as MenuItem[];\n return [\n {\n label: \"Edit draft\",\n action: () => document.dispatchEvent(new CustomEvent(\"mailx-popout-message\", {\n detail: { accountId, uid: msg.uid, folderId: msg.folderId, subject: msg.subject },\n })),\n },\n { label: \"\", action: () => {}, separator: true },\n ];\n })()),\n { label: \"Reply\", action: () => document.dispatchEvent(new CustomEvent(\"mailx-compose\", { detail: { mode: \"reply\" } })) },\n { label: \"Reply All\", action: () => document.dispatchEvent(new CustomEvent(\"mailx-compose\", { detail: { mode: \"replyAll\" } })) },\n { label: \"Forward\", action: () => document.dispatchEvent(new CustomEvent(\"mailx-compose\", { detail: { mode: \"forward\" } })) },\n { label: \"\", action: () => {}, separator: true },\n {\n label: \"Move to folder\u2026\",\n action: async () => {\n const selectedRows = Array.from(document.querySelectorAll(\".ml-row.selected\"));\n const uids = selectedRows.length > 0\n ? selectedRows.map((r: Element) => Number((r as HTMLElement).dataset.uid)).filter(u => !isNaN(u))\n : [msg.uid];\n const pick = await pickFolder(accountId, { excludeFolderIds: [msg.folderId] });\n if (!pick) return;\n // Local-first: optimistic remove first, fire-and-forget IPC.\n // The IPC response may stall behind a long simpleParser\n // (event-loop block) and time out at 120s, but the server-\n // side move IS already queued at that point. Awaiting +\n // alerting on timeout misreports a success as a failure.\n removeMessagesAndReconcile(uids.map(u => ({ accountId, uid: u })));\n const statusSync = document.getElementById(\"status-sync\");\n if (statusSync) statusSync.textContent = `Moving ${uids.length} message${uids.length !== 1 ? \"s\" : \"\"} to ${pick.folderName}\u2026`;\n apiMoveMessages(accountId, uids, pick.folderId)\n .then(() => { if (statusSync) statusSync.textContent = `Moved ${uids.length} to ${pick.folderName}`; })\n .catch((err: any) => {\n console.error(`Move failed: ${err?.message || err}`);\n if (statusSync) statusSync.textContent = `Move sync issue: ${err?.message || err}`;\n });\n },\n },\n { label: \"Delete\", action: () => document.dispatchEvent(new CustomEvent(\"mailx-delete\")) },\n { label: \"\", action: () => {}, separator: true },\n { label: \"\u26A0 Mark as spam\", action: () => document.getElementById(\"btn-spam\")?.click() },\n { label: \"\", action: () => {}, separator: true },\n {\n label: \"Copy Message-ID\",\n action: async () => {\n if (!msg.messageId) { alert(\"No Message-ID on this row.\"); return; }\n try { await navigator.clipboard.writeText(msg.messageId); } catch { /* */ }\n },\n },\n ];\n\n showContextMenu(e.clientX, e.clientY, items);\n }\n}\n\nfunction appendMessages(body: HTMLElement, accountId: string, items: any[]): void {\n // Thread grouping: when the list has the \"threaded\" class, collapse\n // messages sharing the same threadId to a single row showing the most\n // recent message, with a small pill indicating the thread size.\n const threaded = body.classList.contains(\"threaded\");\n let rowsToRender: any[] = items;\n let threadSize: Map<any, number> | null = null;\n if (threaded) {\n const threadMap = new Map<string, any>();\n threadSize = new Map<any, number>();\n for (const msg of items) {\n const key = msg.threadId || `_msg_${msg.accountId || accountId}_${msg.uid}`;\n const existing = threadMap.get(key);\n if (!existing || (msg.date || 0) > (existing.date || 0)) {\n threadMap.set(key, msg);\n }\n }\n for (const msg of items) {\n const key = msg.threadId || `_msg_${msg.accountId || accountId}_${msg.uid}`;\n const head = threadMap.get(key);\n if (head) threadSize.set(head, (threadSize.get(head) || 0) + 1);\n }\n rowsToRender = Array.from(threadMap.values()).sort((a, b) => (b.date || 0) - (a.date || 0));\n }\n for (const msg of rowsToRender) {\n const msgAccountId = msg.accountId || accountId;\n const threadCount = threadSize ? (threadSize.get(msg) || 1) : 1;\n const isThreadHead = threadCount > 1 && !!msg.threadId;\n // showAccountTag: true when rendering the unified inbox (no\n // single accountId for the page; each row carries its own and\n // gets a one-letter account chip).\n const showAccountTag = !accountId && !!msgAccountId;\n const row = new MessageRow(msg, msgAccountId, isThreadHead, threadCount, showAccountTag);\n row.attach(body);\n }\n}\n\n\nfunction formatDate(epochMs: number): string {\n const d = new Date(epochMs);\n const now = new Date();\n const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());\n const msgDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());\n\n if (msgDay.getTime() === today.getTime())\n return d.toLocaleTimeString(undefined, timeFmt);\n\n if (d.getFullYear() === now.getFullYear())\n return d.toLocaleString(undefined, dateFmtSameYear);\n\n return d.toLocaleString(undefined, dateFmt);\n}\n\nfunction escapeHtml(s: string): string {\n const div = document.createElement(\"div\");\n div.textContent = s;\n return div.innerHTML;\n}\n\n// `draftSaved` from the service tells us a Ctrl+S / autosave wrote new\n// content to disk for the draft identified by `previousDraftUid`. Bump\n// the time in the row immediately so the Drafts list reflects the save\n// instead of waiting for the IMAP APPEND of the new copy + the next sync\n// of the Drafts folder. The new UID is unknown at this point; we update\n// the existing row's date so the user sees something happen on save.\n// Once sync brings the new UID in, the row gets replaced naturally by\n// the list rebuild and the date is whatever the server says.\nonEvent((ev: any) => {\n if (!ev || ev.type !== \"draftSaved\") return;\n if (!ev.accountId || ev.previousDraftUid == null) return;\n const row = rowByKey.get(rowKey(ev.accountId, ev.previousDraftUid));\n if (row) row.setDate(typeof ev.savedAt === \"number\" ? ev.savedAt : Date.now());\n});\n", "/**\n * Outbox view modal \u2014 lists .ltr files currently queued on disk.\n * Pink rows = local-only, not yet reconciled with the server.\n * Clicking the status-bar queue pill opens this.\n */\nimport { listQueuedOutgoing, cancelQueuedOutgoing } from \"../lib/api-client.js\";\n\nlet isOpen = false;\n\nexport async function openOutboxView(): Promise<void> {\n if (isOpen) return;\n isOpen = true;\n\n const backdrop = document.createElement(\"div\");\n backdrop.className = \"mailx-modal-backdrop\";\n const panel = document.createElement(\"div\");\n panel.className = \"mailx-modal mailx-modal-wide\";\n panel.innerHTML = `\n <div class=\"mailx-modal-title\">\n <span class=\"mailx-modal-title-text\">Outbox \u2014 Local Queue</span>\n <button type=\"button\" class=\"mailx-modal-close\" id=\"ob-close\" title=\"Close (Esc)\" aria-label=\"Close\">×</button>\n </div>\n <div class=\"ob-info\">Messages waiting to be sent. Pink rows are local-only until the server accepts them. Click Cancel to drop a queued message.</div>\n <div class=\"ob-list\" id=\"ob-list\">Loading\u2026</div>\n <div class=\"mailx-modal-buttons\">\n <span class=\"mailx-modal-spacer\"></span>\n <button type=\"button\" class=\"mailx-modal-btn\" data-action=\"refresh\">Refresh</button>\n <button type=\"button\" class=\"mailx-modal-btn\" data-action=\"close\">Close</button>\n </div>`;\n backdrop.appendChild(panel);\n document.body.appendChild(backdrop);\n const listEl = panel.querySelector<HTMLElement>(\"#ob-list\")!;\n\n const renderList = (items: any[]) => {\n if (items.length === 0) {\n listEl.innerHTML = `<div class=\"ob-empty\">Outbox is clean \u2014 no messages queued.</div>`;\n return;\n }\n const fmtDate = (ms: number) => new Date(ms).toLocaleString();\n const ageStr = (ms: number): string => {\n const sec = Math.max(0, Math.round((Date.now() - ms) / 1000));\n if (sec < 60) return `${sec}s`;\n if (sec < 3600) return `${Math.round(sec / 60)}m`;\n return `${Math.round(sec / 3600)}h`;\n };\n listEl.innerHTML = items.map((m, i) => {\n // A claim that's \"young\" (under 60 s) is presumed actively\n // sending. Older claims are stuck \u2014 server hung mid-SMTP,\n // worker crashed, PID recycled. Cancel must always be\n // available either way: the user knows their intent. A\n // brief in-flight cancel may cross the SMTP-send window\n // (rare; <60s of risk), in which case the X-Mailx-Retry +\n // Message-ID dedup on the receiving end catches the dup.\n const claimAge = m.claimed ? ageStr(m.createdAt) : \"\";\n const stuckClaim = m.claimed && (Date.now() - m.createdAt) > 60_000;\n const claimBadge = !m.claimed\n ? \"\"\n : stuckClaim\n ? `<span class=\"ob-badge ob-stuck\" title=\"Claim is older than 60 s \u2014 likely stuck. Cancel will drop it.\">stuck (${claimAge})</span>`\n : `<span class=\"ob-badge ob-claimed\" title=\"Sending now on this host\">sending\u2026 (${claimAge})</span>`;\n return `\n <div class=\"ob-row ob-pink\" data-idx=\"${i}\">\n <div class=\"ob-row-hdr\">\n <span class=\"ob-acct\">${escapeHtml(m.accountId)}</span>\n <span class=\"ob-subject\">${escapeHtml(m.subject || \"(no subject)\")}</span>\n <span class=\"ob-created\">${fmtDate(m.createdAt)}</span>\n ${claimBadge}\n ${m.attempts > 0 ? `<span class=\"ob-badge ob-retry\" title=\"Retry attempts made so far\">retry \u00D7${m.attempts}</span>` : \"\"}\n </div>\n <div class=\"ob-row-meta\">\n <span class=\"ob-from\">${escapeHtml(m.from || \"\")}</span>\n \u2192 <span class=\"ob-to\">${escapeHtml(m.to || \"\")}</span>\n ${m.cc ? ` \u00B7 Cc: ${escapeHtml(m.cc)}` : \"\"}\n <span class=\"ob-size\">\u00B7 ${(m.sizeBytes / 1024).toFixed(1)}kB</span>\n </div>\n <div class=\"ob-row-path\">${escapeHtml(m.path)}</div>\n <div class=\"ob-row-actions\">\n <button type=\"button\" class=\"ob-cancel\">Cancel</button>\n </div>\n </div>`;\n }).join(\"\");\n listEl.querySelectorAll<HTMLElement>(\".ob-row\").forEach((row, idx) => {\n row.querySelector<HTMLButtonElement>(\".ob-cancel\")!.addEventListener(\"click\", async () => {\n const m = items[idx];\n const warning = m.claimed\n ? `This message is currently in flight to the SMTP server.\\nIf SMTP already finished, the recipient may have received it.\\nMessage-ID dedup catches the duplicate side; cancel is still safe.\\n\\nDrop anyway?\\n\\nTo: ${m.to}\\nSubject: ${m.subject}`\n : `Drop this queued message?\\n\\nTo: ${m.to}\\nSubject: ${m.subject}`;\n if (!confirm(warning)) return;\n try {\n await cancelQueuedOutgoing(m.path);\n await reload();\n } catch (e: any) {\n alert(`Cancel failed: ${e?.message || e}`);\n }\n });\n });\n };\n\n const reload = async () => {\n try {\n const items = await listQueuedOutgoing();\n renderList(items || []);\n } catch (e: any) {\n listEl.innerHTML = `<div class=\"ob-empty\">Load failed: ${escapeHtml(e?.message || String(e))}</div>`;\n }\n };\n\n const close = () => {\n backdrop.remove();\n document.removeEventListener(\"keydown\", onKey, true);\n isOpen = false;\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") { e.stopPropagation(); e.preventDefault(); close(); }\n };\n document.addEventListener(\"keydown\", onKey, true);\n panel.querySelector<HTMLButtonElement>(\"#ob-close\")!.addEventListener(\"click\", close);\n panel.querySelector<HTMLButtonElement>('[data-action=\"close\"]')!.addEventListener(\"click\", close);\n panel.querySelector<HTMLButtonElement>('[data-action=\"refresh\"]')!.addEventListener(\"click\", reload);\n backdrop.addEventListener(\"mousedown\", (e) => { if (e.target === backdrop) close(); });\n\n await reload();\n}\n\nfunction escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, c => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", \"\\\"\": \""\", \"'\": \"'\" }[c]!));\n}\n", "/**\n * Calendar sidebar \u2014 Thunderbird Lightning \"Events and Tasks\" pane.\n *\n * Right-docked vertical panel showing:\n * - Day-grouped upcoming events (Today / Tomorrow / Friday Apr 24 / \u2026)\n * - Tasks list (no due date \u2014 just a checkable to-do list)\n *\n * Toggled by the View menu's \"Calendar sidebar\" checkbox. Reads from the\n * primary account's Google Calendar / Tasks via the existing OAuth token.\n * Sidebar and the full-screen calendar modal (calendar.ts) read the SAME\n * underlying data \u2014 two views onto one source.\n *\n * All storage goes through the service-side two-way cache (calendar_events\n * and tasks tables); this file does not use localStorage for data.\n */\n\nimport {\n getPrimaryAccount,\n getCalendarEvents, getCalendars, createCalendarEvent,\n getTasks, createTask, updateTask, deleteTask,\n reauthGoogleScopes,\n getSettings, saveSettings,\n} from \"../lib/api-client.js\";\nimport { showContextMenu } from \"./context-menu.js\";\n\nconst SIDEBAR_PREF = \"mailx-calendar-sidebar-on\";\nconst SHOW_RECURRING_PREF = \"mailx-cal-show-recurring\";\nconst SHOW_DONE_PREF = \"mailx-task-show-done\";\nconst HORIZON_DAYS_PREF = \"mailx-cal-horizon-days\";\nconst HORIZON_DEFAULT_DAYS = 30;\n\ninterface CalEvent {\n id: string;\n title: string;\n start: number; // ms since epoch\n end: number;\n allDay?: boolean;\n location?: string;\n notes?: string;\n source?: \"local\" | \"google\";\n recurringEventId?: string | null;\n htmlLink?: string | null;\n isHoliday?: boolean;\n /** Google calendar id this row was fetched from. Distinguishes\n * holiday sources (en.usa#holiday vs en.jewish#holiday) so the\n * sidebar can color them differently. */\n calendarId?: string;\n}\n\n/** Cap holiday rows at this lookahead window regardless of the user's\n * overall horizon. Holidays mostly clutter a list 2 weeks out; the\n * user can always extend by opening Google Calendar directly.\n *\n * Recurring events deliberately DON'T get this cap any more \u2014 a weekly\n * meeting whose next occurrence is on day 16 would otherwise vanish\n * from a 30-day-horizon view, which surprised users. Recurring follows\n * the overall horizon. */\nconst HOLIDAY_HORIZON_MS = 14 * 86400_000;\n\nlet viewYear = new Date().getFullYear();\nlet viewMonth = new Date().getMonth();\nlet viewDay = new Date().getDate();\nlet lastEvents: CalEvent[] = [];\n/** Overdue tasks (due before today, not completed) \u2014 pinned to the top of\n * the calendar list. Cached so renderEvents() can be re-run on a calendar\n * visibility toggle without re-fetching tasks. */\nlet lastOverdueTasks: Array<{ uuid: string; title: string; dueMs?: number }> = [];\n\n// \u2500\u2500 Per-calendar metadata \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// The user curates which calendars exist by selecting them in Google\n// Calendar; mailx enumerates them via getCalendars() and renders one\n// checkbox + icon per calendar. Holiday calendars are no longer hard-coded.\n\ntype CalKind = \"personal\" | \"usHoliday\" | \"jewishHoliday\" | \"birthday\" | \"otherHoliday\" | \"other\";\n\ninterface CalInfo {\n id: string;\n name: string;\n color: string;\n primary: boolean;\n}\n\n/** Selected Google calendars, by id. The primary calendar is also keyed\n * under the literal \"primary\" so legacy/stale rows (whose `calendar_id`\n * predates per-calendar tagging) still resolve. */\nconst calById = new Map<string, CalInfo>();\nlet calendarList: CalInfo[] = [];\n/** Calendar IDs the user has hidden in mailx (still selected in Google \u2014\n * hidden is a display filter, not a fetch filter). Persisted in\n * settings.calendar.hiddenCalendars. */\nlet hiddenCalendars = new Set<string>();\n\n/** Classify a calendar by its Google id. Birthdays live on Google's\n * auto `addressbook#contacts` calendar; holiday calendars carry\n * `#holiday@group.v.calendar.google.com`. The personal calendar is the\n * `primary` one. Everything else is a generic named calendar. */\nfunction calendarKind(id: string, primary: boolean): CalKind {\n if (primary) return \"personal\";\n const c = (id || \"\").toLowerCase();\n if (c.includes(\"addressbook#contacts\")) return \"birthday\";\n if (c.includes(\"#holiday@\") || c.includes(\"holiday@group\")) {\n if (c.includes(\"usa\")) return \"usHoliday\";\n if (c.includes(\"judaism\") || c.includes(\"jewish\")) return \"jewishHoliday\";\n return \"otherHoliday\";\n }\n return \"other\";\n}\n\n/** Icon markup for a calendar: blue dot = personal, themed emoji for\n * holiday/birthday calendars, monogram badge (first letter in a circle\n * tinted with the Google calendar color) for any other named calendar. */\nfunction calIconHtml(info: CalInfo): string {\n const kind = calendarKind(info.id, info.primary);\n const t = escapeHtml(info.name);\n if (kind === \"personal\") return `<span class=\"cal-ico cal-ico-dot\" title=\"${t}\"></span>`;\n if (kind === \"usHoliday\") return `<span class=\"cal-ico cal-ico-emoji\" title=\"${t}\">\uD83C\uDDFA\uD83C\uDDF8</span>`;\n if (kind === \"jewishHoliday\") return `<span class=\"cal-ico cal-ico-emoji\" title=\"${t}\">\u2721\uFE0F</span>`;\n if (kind === \"birthday\") return `<span class=\"cal-ico cal-ico-emoji\" title=\"${t}\">\uD83C\uDF82</span>`;\n if (kind === \"otherHoliday\") return `<span class=\"cal-ico cal-ico-emoji\" title=\"${t}\">\u2726</span>`;\n const letter = escapeHtml((info.name.trim()[0] || \"?\").toUpperCase());\n const color = info.color || \"#7a7a7a\";\n return `<span class=\"cal-ico cal-ico-mono\" style=\"background:${escapeHtml(color)}\" title=\"${t}\">${letter}</span>`;\n}\n\n/** Resolve an event's `calendarId` to a CalInfo. Falls back to a\n * synthesised entry (pattern-based kind, generic monogram) when the id\n * isn't in the enumerated list \u2014 e.g. a stale row, or before\n * getCalendars() has resolved. */\nfunction calInfoFor(calendarId: string | undefined): CalInfo {\n const id = calendarId || \"primary\";\n const found = calById.get(id);\n if (found) return found;\n return { id, name: (id.split(\"@\")[0] || id), color: \"\", primary: id === \"primary\" };\n}\n\nfunction isCalHidden(calendarId: string | undefined): boolean {\n return hiddenCalendars.has(calInfoFor(calendarId).id);\n}\n\nasync function loadHiddenCalendars(): Promise<void> {\n try {\n const s: any = await getSettings();\n const arr = s?.calendar?.hiddenCalendars;\n hiddenCalendars = new Set(Array.isArray(arr) ? arr : []);\n } catch { hiddenCalendars = new Set(); }\n}\n\nasync function saveHiddenCalendars(): Promise<void> {\n try {\n const s: any = await getSettings();\n s.calendar = { ...(s.calendar || {}), hiddenCalendars: [...hiddenCalendars] };\n await saveSettings(s);\n } catch (e: any) { console.error(\"[cal] save hiddenCalendars failed:\", e); }\n}\n\n/** Pull the user's selected Google calendars and render the per-calendar\n * checkbox list. Each row toggles `hiddenCalendars` (a display filter \u2014\n * instant, no Google round-trip, no re-fetch). Called on init and on\n * manual refresh; cheap enough but not run on every nav click. */\nasync function renderCalendarList(): Promise<void> {\n const host = document.getElementById(\"cal-side-calendars\");\n let list: CalInfo[] = [];\n try {\n list = await getCalendars() as CalInfo[];\n } catch { /* offline / no calendar scope \u2014 leave the list empty */ }\n calendarList = list;\n calById.clear();\n for (const c of list) {\n calById.set(c.id, c);\n if (c.primary) calById.set(\"primary\", c);\n }\n if (host) {\n if (list.length === 0) {\n host.innerHTML = \"\";\n } else {\n // Personal first, then alphabetical.\n const sorted = [...list].sort((a, b) =>\n (a.primary ? 0 : 1) - (b.primary ? 0 : 1) || a.name.localeCompare(b.name));\n host.innerHTML = sorted.map(c => `<label class=\"cal-side-cal-row\" title=\"${escapeHtml(c.name)}\">\n <input type=\"checkbox\" class=\"cal-side-cal-check\" data-cal-id=\"${escapeHtml(c.id)}\" ${hiddenCalendars.has(c.id) ? \"\" : \"checked\"}>\n ${calIconHtml(c)}\n <span class=\"cal-side-cal-name\">${escapeHtml(c.name)}</span>\n </label>`).join(\"\");\n host.querySelectorAll<HTMLInputElement>(\".cal-side-cal-check\").forEach(cb => {\n cb.addEventListener(\"change\", async () => {\n const id = cb.dataset.calId || \"\";\n if (cb.checked) hiddenCalendars.delete(id);\n else hiddenCalendars.add(id);\n await saveHiddenCalendars();\n renderEvents(lastEvents); // re-filter instantly\n });\n });\n }\n }\n // Repaint events so rows pick up proper icons now the list is known.\n if (lastEvents.length > 0) renderEvents(lastEvents);\n}\n\n// Set of selected task UUIDs. Persists across renderTasks() calls so the\n// user keeps their selection when a re-render happens (e.g., after a\n// `tasksUpdated` event from the service). Clicking a row with no modifier\n// replaces the set; ctrl/cmd-click toggles a single row; shift-click is\n// not implemented yet (would need an \"anchor\" to compute the range).\nconst selectedTaskUuids = new Set<string>();\n\n/** Read-only snapshot of selected task UUIDs \u2014 used by app.ts to route the\n * global Delete key / trash-button click to tasks when any are selected. */\nexport function getSelectedTaskUuids(): string[] {\n return [...selectedTaskUuids];\n}\n\n/** Delete every selected task and clear the selection. Called from app.ts\n * when the user hits Delete / Ctrl+D / the trash button while tasks are\n * selected \u2014 same semantics as the per-row \u00D7 button, just batch. */\nexport async function deleteSelectedTasks(): Promise<void> {\n const uuids = [...selectedTaskUuids];\n if (uuids.length === 0) return;\n selectedTaskUuids.clear();\n for (const uuid of uuids) {\n try { await deleteTask(uuid); } catch { /* ignore individual failures */ }\n }\n await renderTasks();\n}\n\nfunction getHorizonDays(): number {\n try {\n const v = localStorage.getItem(HORIZON_DAYS_PREF);\n const n = v ? parseInt(v, 10) : NaN;\n if (Number.isFinite(n) && n > 0 && n <= 365) return n;\n } catch { /* */ }\n return HORIZON_DEFAULT_DAYS;\n}\n\nfunction getShowRecurring(): boolean {\n try { return localStorage.getItem(SHOW_RECURRING_PREF) !== \"false\"; } catch { return true; }\n}\n\n/** Fetch events from the local two-way cache; service returns local rows\n * immediately and kicks a background refresh from Google. Next render\n * (view-nav or user action) picks up the refreshed rows. No localStorage\n * \u2014 everything lives in the service-side DB so phone / desktop share\n * the same events. */\nasync function fetchUpcoming(from: Date): Promise<CalEvent[]> {\n const horizon = from.getTime() + getHorizonDays() * 86400_000;\n const rows = await getCalendarEvents(from.getTime(), horizon);\n const showRecurring = getShowRecurring();\n const filtered = showRecurring ? rows : rows.filter((r: any) => !r.recurringEventId);\n return filtered.map((r: any) => ({\n id: r.uuid,\n title: r.title,\n start: r.startMs,\n end: r.endMs,\n allDay: !!r.allDay,\n location: r.location,\n notes: r.notes,\n source: r.providerId ? \"google\" : \"local\",\n recurringEventId: r.recurringEventId,\n htmlLink: r.htmlLink,\n isHoliday: !!r.isHoliday,\n calendarId: r.calendarId,\n }));\n}\n\nfunction formatDayHeader(d: Date, today: Date, tomorrow: Date): string {\n const sameDay = (a: Date, b: Date) =>\n a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();\n if (sameDay(d, today)) return \"Today\";\n if (sameDay(d, tomorrow)) return \"Tomorrow\";\n return d.toLocaleDateString(undefined, { weekday: \"long\", month: \"long\", day: \"numeric\" });\n}\n\nfunction formatTime(e: CalEvent): string {\n if (e.allDay) return \"all day\";\n return new Date(e.start).toLocaleTimeString(undefined, { hour: \"2-digit\", minute: \"2-digit\", hour12: false });\n}\n\nfunction escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, c => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", \"\\\"\": \""\", \"'\": \"'\" }[c]!));\n}\n\nfunction renderHead(): void {\n const dateEl = document.getElementById(\"cal-side-date\");\n if (!dateEl) return;\n const d = new Date(viewYear, viewMonth, viewDay);\n dateEl.innerHTML = `<strong>${d.getDate()}</strong> ${d.toLocaleDateString(undefined, { weekday: \"short\" })} <span class=\"cal-side-date-month\">${d.toLocaleDateString(undefined, { month: \"short\", year: \"numeric\" })}</span>`;\n}\n\n/** Markup for one overdue-task row (pinned at the top of the calendar). */\nfunction overdueTaskRowHtml(t: { uuid: string; title: string; dueMs?: number }): string {\n let dueLabel = \"\";\n if (t.dueMs) {\n const d = new Date(t.dueMs);\n const sameYear = d.getFullYear() === new Date().getFullYear();\n dueLabel = sameYear ? `${d.getMonth() + 1}/${d.getDate()}` : d.toISOString().slice(0, 10);\n }\n return `<div class=\"cal-side-task cal-side-task-overdue\" data-uuid=\"${escapeHtml(t.uuid)}\">\n <input type=\"checkbox\" class=\"cal-side-overdue-check\" title=\"Mark done\">\n <span class=\"cal-side-task-title\">${escapeHtml(t.title)}</span>\n <span class=\"cal-side-task-due overdue\">${escapeHtml(dueLabel)}</span>\n </div>`;\n}\n\nfunction renderEvents(events: CalEvent[]): void {\n const body = document.getElementById(\"cal-side-body\");\n if (!body) return;\n // Drop events from calendars the user has hidden in mailx.\n events = events.filter(e => !isCalHidden(e.calendarId));\n const overdue = lastOverdueTasks;\n if (events.length === 0 && overdue.length === 0) {\n body.innerHTML = `<div class=\"cal-side-empty\">No upcoming events. Click + New event to add one.</div>`;\n return;\n }\n const today = new Date(); today.setHours(0, 0, 0, 0);\n const tomorrow = new Date(today.getTime() + 86400_000);\n\n // Daily-recurring detection: group expanded instances by recurringEventId,\n // pull out series that fire (roughly) every day at the same time of day,\n // and render them once at the top instead of cluttering each per-day\n // section. \"Daily\" = same recurringEventId + same H:MM + appears on at\n // least 80% of the days in the rendered horizon (allows for the\n // occasional skipped day without losing the grouping).\n const horizonDays = getHorizonDays();\n const dailyThreshold = Math.max(2, Math.floor(horizonDays * 0.8));\n const byRecurId = new Map<string, CalEvent[]>();\n for (const e of events) {\n if (!e.recurringEventId) continue;\n let arr = byRecurId.get(e.recurringEventId);\n if (!arr) { arr = []; byRecurId.set(e.recurringEventId, arr); }\n arr.push(e);\n }\n const dailyKeys = new Set<string>();\n const dailyHeads: CalEvent[] = [];\n for (const [recId, instances] of byRecurId) {\n if (instances.length < dailyThreshold) continue;\n // Same time-of-day across all instances?\n const hm = (e: CalEvent) => {\n if (e.allDay) return \"all\";\n const d = new Date(e.start);\n return `${d.getHours()}:${d.getMinutes()}`;\n };\n const firstHm = hm(instances[0]);\n const allSameTime = instances.every(e => hm(e) === firstHm);\n if (!allSameTime) continue;\n dailyKeys.add(recId);\n // Use the earliest instance as the \"head\" entry (its htmlLink is\n // identical across instances of the same series).\n dailyHeads.push(instances.reduce((a, b) => a.start < b.start ? a : b));\n }\n\n let html = \"\";\n\n // Overdue tasks pinned to the very top \u2014 a persistent \"behind on these\"\n // block. Marking one done (the checkbox) completes it in Google and it\n // drops off on the next render.\n if (overdue.length > 0) {\n html += `<div class=\"cal-side-day cal-side-day-overdue\">Overdue tasks</div>`;\n for (const t of overdue) html += overdueTaskRowHtml(t);\n }\n\n if (dailyHeads.length > 0) {\n html += `<div class=\"cal-side-day cal-side-day-daily\">Daily</div>`;\n for (const e of dailyHeads) {\n const link = e.htmlLink || \"\";\n html += `<div class=\"cal-side-event\" data-id=\"${e.id}\" data-link=\"${escapeHtml(link)}\" ${link ? 'title=\"Click to open in Google Calendar\"' : \"\"}>\n ${calIconHtml(calInfoFor(e.calendarId))}\n <span class=\"cal-side-event-time\">${escapeHtml(formatTime(e))}</span>\n <span class=\"cal-side-event-title\">${escapeHtml(e.title)}<span class=\"cal-side-event-recur\" title=\"Daily\">\u21BB</span></span>\n </div>`;\n }\n }\n\n let lastDayKey = \"\";\n const nowMs = Date.now();\n const holidayCutoff = nowMs + HOLIDAY_HORIZON_MS;\n for (const e of events) {\n // Filter out daily-grouped instances \u2014 they're shown once at top.\n if (e.recurringEventId && dailyKeys.has(e.recurringEventId)) continue;\n const info = calInfoFor(e.calendarId);\n const kind = calendarKind(info.id, info.primary);\n const isHolidayKind = kind === \"usHoliday\" || kind === \"jewishHoliday\" || kind === \"otherHoliday\";\n // Holiday calendars get the 2-week cap \u2014 they otherwise clutter a\n // long horizon. Birthdays and recurring events follow the user's\n // overall horizon.\n if (isHolidayKind && e.start > holidayCutoff) continue;\n const d = new Date(e.start);\n const dayKey = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;\n if (dayKey !== lastDayKey) {\n html += `<div class=\"cal-side-day\">${escapeHtml(formatDayHeader(d, today, tomorrow))}</div>`;\n lastDayKey = dayKey;\n }\n const recurMark = e.recurringEventId ? `<span class=\"cal-side-event-recur\" title=\"Recurring event\">\u21BB</span>` : \"\";\n const link = e.htmlLink || \"\";\n const recurAttr = e.recurringEventId ? ' data-recurring=\"1\"' : \"\";\n // Holiday + birthday calendars render as a single full-width row\n // (all-day, no useful clock time) led by the calendar's icon.\n // Regular events keep the icon/time/title columns and the\n // click-to-open-in-Google deep link.\n if (isHolidayKind || kind === \"birthday\") {\n html += `<div class=\"cal-side-event\" data-holiday=\"1\" data-holiday-kind=\"${kind}\" data-id=\"${e.id}\">\n <span class=\"cal-side-event-title cal-side-event-holiday-title\">${calIconHtml(info)} ${escapeHtml(e.title)}</span>\n </div>`;\n } else {\n const titleAttr = link ? 'title=\"Click to open in Google Calendar\"' : \"\";\n html += `<div class=\"cal-side-event\" data-id=\"${e.id}\"${recurAttr} data-link=\"${escapeHtml(link)}\" ${titleAttr}>\n ${calIconHtml(info)}\n <span class=\"cal-side-event-time\">${escapeHtml(formatTime(e))}</span>\n <span class=\"cal-side-event-title\">${escapeHtml(e.title)}${recurMark}</span>\n </div>`;\n }\n }\n body.innerHTML = html;\n\n // Overdue-task checkboxes \u2014 mark the task completed in Google (non-\n // destructive; it survives in the Completed section) and re-render.\n body.querySelectorAll<HTMLElement>(\".cal-side-task-overdue\").forEach(row => {\n const uuid = row.dataset.uuid!;\n row.querySelector<HTMLInputElement>(\".cal-side-overdue-check\")?.addEventListener(\"change\", async () => {\n await updateTask(uuid, { completedMs: Date.now() });\n lastOverdueTasks = lastOverdueTasks.filter(t => t.uuid !== uuid);\n renderEvents(lastEvents);\n renderTasks();\n });\n });\n\n // Click-to-open \u2014 interim per user 2026-04-23: route to Google Calendar's\n // web UI via openExternal until we build an in-app event editor.\n // Right-click gives a context menu with \"View in browser\" explicit.\n // Item 17: on Android, openCalendarEvent redirects to the native Calendar\n // app via ACTION_VIEW; desktop still falls through to the browser.\n const openInCalendar = (url: string): void => {\n const api = (window as any).mailxapi;\n if (api?.openCalendarEvent) { api.openCalendarEvent(url); return; }\n if (api?.openExternal) api.openExternal(url);\n else window.open(url, \"_blank\");\n };\n const openInBrowser = (url: string): void => {\n const api = (window as any).mailxapi;\n if (api?.openExternal) api.openExternal(url);\n else window.open(url, \"_blank\");\n };\n body.querySelectorAll<HTMLElement>(\".cal-side-event\").forEach(el => {\n el.addEventListener(\"click\", () => {\n const link = el.dataset.link;\n if (link) openInCalendar(link);\n });\n el.addEventListener(\"contextmenu\", (e) => {\n e.preventDefault();\n const link = el.dataset.link;\n const items = [];\n if (link) items.push({\n label: \"View in browser\",\n action: () => openInBrowser(link),\n });\n items.push({\n label: \"Open Google Calendar\",\n action: () => openInBrowser(\"https://calendar.google.com/\"),\n });\n if (items.length > 0) showContextMenu(e.clientX, e.clientY, items);\n });\n });\n}\n\nasync function renderTasks(prefetched?: any[]): Promise<void> {\n const cb = document.getElementById(\"cal-side-show-done\") as HTMLInputElement | null;\n const showDone = cb?.checked ?? false;\n // Reuse the list refresh() already fetched (avoids a duplicate getTasks\n // IPC + Google pull); fall back to fetching when called standalone.\n const tasks = prefetched ?? await getTasks(showDone);\n const host = document.getElementById(\"cal-side-tasks\");\n if (!host) return;\n if (tasks.length === 0) {\n host.innerHTML = `<div class=\"cal-side-empty\">No tasks.</div>`;\n return;\n }\n let html = \"<div class='cal-side-task-head'>Title</div>\";\n const startOfToday = new Date(); startOfToday.setHours(0, 0, 0, 0);\n for (const t of tasks) {\n const done = !!t.completedMs;\n // Due date column: short M/D if same year, else YYYY-MM-DD; overdue\n // (date < today, not completed) gets a distinct color via the\n // .overdue class. No due date \u2192 empty span keeps the grid aligned.\n let dueHtml = \"\";\n if (t.dueMs) {\n const d = new Date(t.dueMs);\n const sameYear = d.getFullYear() === startOfToday.getFullYear();\n const label = sameYear\n ? `${d.getMonth() + 1}/${d.getDate()}`\n : d.toISOString().slice(0, 10);\n const overdue = !done && d < startOfToday;\n dueHtml = `<span class=\"cal-side-task-due${overdue ? \" overdue\" : \"\"}\" title=\"${d.toLocaleDateString()}\">${label}</span>`;\n } else {\n dueHtml = `<span class=\"cal-side-task-due\"></span>`;\n }\n const sel = selectedTaskUuids.has(t.uuid) ? \" selected\" : \"\";\n html += `<div class=\"cal-side-task${sel}\" data-uuid=\"${t.uuid}\">\n <input type=\"checkbox\" ${done ? \"checked\" : \"\"} class=\"cal-side-task-check\">\n <span class=\"cal-side-task-title${done ? \" done\" : \"\"}\">${escapeHtml(t.title)}</span>\n ${dueHtml}\n <button class=\"cal-side-task-delete\" title=\"Delete task\" aria-label=\"Delete task\">\u00D7</button>\n </div>`;\n }\n // Drop selection entries for tasks that no longer exist (deleted /\n // synced-out from another device) so the set doesn't grow unbounded.\n const liveUuids = new Set(tasks.map(t => t.uuid));\n for (const uuid of selectedTaskUuids) {\n if (!liveUuids.has(uuid)) selectedTaskUuids.delete(uuid);\n }\n host.innerHTML = html;\n const openInBrowser = (url: string): void => {\n const api = (window as any).mailxapi;\n if (api?.openExternal) api.openExternal(url);\n else window.open(url, \"_blank\");\n };\n host.querySelectorAll<HTMLElement>(\".cal-side-task\").forEach(row => {\n const uuid = row.dataset.uuid!;\n row.querySelector<HTMLInputElement>(\".cal-side-task-check\")?.addEventListener(\"change\", async (e) => {\n const checked = (e.target as HTMLInputElement).checked;\n await updateTask(uuid, { completedMs: checked ? Date.now() : null });\n renderTasks();\n });\n row.querySelector<HTMLButtonElement>(\".cal-side-task-delete\")?.addEventListener(\"click\", async (e) => {\n e.stopPropagation(); // don't also trigger the row's selection click\n await deleteTask(uuid);\n selectedTaskUuids.delete(uuid);\n renderTasks();\n });\n // Click on the row body selects it. Plain click replaces the set;\n // ctrl/cmd toggles. Clicks that originated on the checkbox or the\n // \u00D7 button were stopped above, so they don't bubble to here.\n row.addEventListener(\"click\", (e) => {\n const t = e.target as HTMLElement;\n if (t.closest(\".cal-side-task-check\") || t.closest(\".cal-side-task-delete\")) return;\n if (e.ctrlKey || e.metaKey) {\n if (selectedTaskUuids.has(uuid)) selectedTaskUuids.delete(uuid);\n else selectedTaskUuids.add(uuid);\n } else {\n selectedTaskUuids.clear();\n selectedTaskUuids.add(uuid);\n }\n // Re-render to update selected styling. Cheap (DOM is small).\n renderTasks();\n });\n row.addEventListener(\"contextmenu\", (e) => {\n e.preventDefault();\n // Google Tasks doesn't have a per-task web URL; open the list view.\n showContextMenu(e.clientX, e.clientY, [\n { label: \"View in Google Tasks\", action: () => openInBrowser(\"https://tasks.google.com/\") },\n { label: \"\", action: () => {}, separator: true },\n { label: \"Delete task\", action: async () => { await deleteTask(uuid); renderTasks(); } },\n ]);\n });\n });\n}\n\nasync function refresh(): Promise<void> {\n renderHead();\n const from = new Date(viewYear, viewMonth, viewDay);\n // A throw here (IPC reject, daemon not ready) must NOT leave the\n // sidebar stuck on its initial \"Loading\u2026\" placeholder \u2014 that was the\n // \"calendar shows nothing\" symptom. Render an explicit state either\n // way: events on success, an error line on failure.\n // Tasks are fetched ONCE here and handed to renderTasks() so the events\n // pane and the task pane don't each fire their own getTasks() \u2014 that\n // doubled the Google Tasks API call rate and helped trip the 429 storm.\n let prefetchedTasks: any[] | undefined;\n try {\n const startOfToday = new Date(); startOfToday.setHours(0, 0, 0, 0);\n const showDone = (document.getElementById(\"cal-side-show-done\") as HTMLInputElement | null)?.checked ?? false;\n const [events, tasks] = await Promise.all([\n fetchUpcoming(from),\n getTasks(showDone).catch(() => [] as any[]),\n ]);\n lastEvents = events;\n prefetchedTasks = tasks as any[];\n // Overdue tasks (due before today, not completed) pin to the top of\n // the calendar list.\n lastOverdueTasks = prefetchedTasks\n .filter(t => t.dueMs && !t.completedMs && t.dueMs < startOfToday.getTime())\n .map(t => ({ uuid: t.uuid, title: t.title, dueMs: t.dueMs }));\n renderEvents(lastEvents);\n } catch (e: any) {\n const body = document.getElementById(\"cal-side-body\");\n if (body) body.innerHTML = `<div class=\"cal-side-empty cal-side-quota-error\">Couldn't load calendar: ${escapeHtml(e?.message || String(e))}</div>`;\n }\n renderTasks(prefetchedTasks);\n}\n\n/** Open the natural-language new-event modal. The user types a free-form\n * description (\"Lunch with John tomorrow at noon at Joe's\"); we send it\n * to aiTransform with action=extractEvent, get back structured fields\n * (title/start/end/location/notes), and open Google Calendar's\n * create-event URL with those fields pre-filled. The user reviews / edits\n * / saves in Google's UI \u2014 that path keeps mailx out of the\n * event-editor business and gets all the niceties (recurrence, guests,\n * conferencing) for free. If AI is disabled or extraction fails, the raw\n * text becomes the title and Google's own quick-add parser takes over. */\nfunction openNewEventDialog(): void {\n const backdrop = document.createElement(\"div\");\n backdrop.className = \"mailx-modal-backdrop\";\n backdrop.style.cssText = \"position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;\";\n\n const panel = document.createElement(\"div\");\n panel.style.cssText = \"background:var(--color-bg, #fff);color:var(--color-text, #000);border-radius:8px;box-shadow:0 8px 32px rgba(0,0,0,0.3);width:min(560px,90vw);max-height:80vh;display:flex;flex-direction:column;padding:18px;gap:10px;\";\n\n const heading = document.createElement(\"div\");\n heading.style.cssText = \"font-weight:600;font-size:1.05em;\";\n heading.textContent = \"New event\";\n\n const hint = document.createElement(\"div\");\n hint.style.cssText = \"font-size:0.9em;color:var(--color-text-muted);\";\n hint.textContent = \"Describe the event in plain English \u2014 AI extracts the details and opens Google Calendar to confirm.\";\n\n const ta = document.createElement(\"textarea\");\n ta.style.cssText = \"width:100%;min-height:120px;font:inherit;padding:8px;border-radius:4px;border:1px solid var(--color-border, #ccc);box-sizing:border-box;resize:vertical;\";\n ta.placeholder = \"Lunch with John tomorrow at noon at Joe's Pizza\";\n\n const status = document.createElement(\"div\");\n status.style.cssText = \"min-height:1.2em;font-size:0.85em;color:var(--color-text-muted);\";\n\n const btnRow = document.createElement(\"div\");\n btnRow.style.cssText = \"display:flex;gap:8px;justify-content:flex-end;\";\n\n const cancelBtn = document.createElement(\"button\");\n cancelBtn.textContent = \"Cancel\";\n cancelBtn.style.cssText = \"padding:6px 14px;\";\n\n const createBtn = document.createElement(\"button\");\n createBtn.textContent = \"Create\";\n createBtn.style.cssText = \"padding:6px 14px;font-weight:500;\";\n\n btnRow.append(cancelBtn, createBtn);\n panel.append(heading, hint, ta, status, btnRow);\n backdrop.append(panel);\n document.body.append(backdrop);\n setTimeout(() => ta.focus(), 0);\n\n const close = () => backdrop.remove();\n cancelBtn.addEventListener(\"click\", close);\n backdrop.addEventListener(\"click\", (e) => { if (e.target === backdrop) close(); });\n document.addEventListener(\"keydown\", function escClose(e) {\n if (e.key === \"Escape\") { close(); document.removeEventListener(\"keydown\", escClose); }\n });\n\n // Submit on Ctrl/Cmd+Enter as well as button click.\n ta.addEventListener(\"keydown\", (e) => {\n if ((e.ctrlKey || e.metaKey) && e.key === \"Enter\") { e.preventDefault(); createBtn.click(); }\n });\n\n createBtn.addEventListener(\"click\", async () => {\n const text = ta.value.trim();\n if (!text) { ta.focus(); return; }\n createBtn.disabled = true;\n cancelBtn.disabled = true;\n status.textContent = \"Parsing\u2026\";\n\n let event: any = null;\n try {\n const { aiTransform } = await import(\"../lib/api-client.js\");\n const r = await aiTransform({\n action: \"extractEvent\",\n text,\n nowISO: new Date().toISOString(),\n });\n event = r?.event || null;\n if (!event && r?.reason) status.textContent = `AI: ${r.reason} \u2014 using raw text as title.`;\n } catch (e: any) {\n status.textContent = `AI call failed: ${e.message} \u2014 using raw text as title.`;\n }\n\n // Build Google Calendar's create-event URL. Always opens in browser\n // for the user to confirm/edit/save. We never write to the local\n // store from this path \u2014 the round-trip back via Google Calendar\n // sync keeps state consistent and avoids duplicate events.\n const url = buildGoogleEventUrl(event, text);\n const api = (window as any).mailxapi;\n if (api?.openExternal) api.openExternal(url);\n else window.open(url, \"_blank\");\n close();\n });\n}\n\n/** Compose Google Calendar's \"render?action=TEMPLATE\" URL with extracted\n * fields. When `event` is null we still produce a useful URL \u2014 the raw\n * description goes into `text=` (becomes the title) and Google's UI lets\n * the user fill in dates manually. Times are emitted in compact form\n * without `Z` so Google interprets them in the user's calendar timezone. */\nfunction buildGoogleEventUrl(event: any, fallbackText: string): string {\n const base = \"https://calendar.google.com/calendar/render?action=TEMPLATE\";\n const params: string[] = [];\n const fmt = (iso: string): string => {\n // ISO \"2026-05-06T12:00:00\" \u2192 \"20260506T120000\". Google accepts\n // local-naive times in this form.\n const m = iso.match(/(\\d{4})-(\\d{2})-(\\d{2})(?:T(\\d{2}):(\\d{2})(?::(\\d{2}))?)?/);\n if (!m) return \"\";\n const [, y, mo, d, hh, mm, ss] = m;\n const date = `${y}${mo}${d}`;\n if (!hh) return date; // all-day form: dates=YYYYMMDD/YYYYMMDD\n return `${date}T${hh}${mm}${ss || \"00\"}`;\n };\n\n if (event?.title) params.push(`text=${encodeURIComponent(event.title)}`);\n else params.push(`text=${encodeURIComponent(fallbackText.slice(0, 200))}`);\n\n if (event?.startISO) {\n const startFmt = fmt(event.startISO);\n // For all-day events Google wants `dates=YYYYMMDD/YYYYMMDD` where\n // the end date is exclusive (next day). For timed events it's\n // `YYYYMMDDTHHMMSS/YYYYMMDDTHHMMSS`. Default end = +1h timed,\n // +1 day all-day.\n if (event.allDay) {\n const startDate = new Date(event.startISO + (event.startISO.includes(\"T\") ? \"\" : \"T00:00:00\"));\n const endDate = new Date(startDate.getTime() + 86400_000);\n const endFmt = `${endDate.getFullYear()}${String(endDate.getMonth() + 1).padStart(2, \"0\")}${String(endDate.getDate()).padStart(2, \"0\")}`;\n params.push(`dates=${startFmt}/${endFmt}`);\n } else {\n const endFmt = event.endISO\n ? fmt(event.endISO)\n : fmt(new Date(new Date(event.startISO).getTime() + 3600_000).toISOString().slice(0, 19));\n params.push(`dates=${startFmt}/${endFmt}`);\n }\n }\n if (event?.location) params.push(`location=${encodeURIComponent(event.location)}`);\n if (event?.notes) params.push(`details=${encodeURIComponent(event.notes)}`);\n return `${base}&${params.join(\"&\")}`;\n}\n\n/** Show the sidebar (called from the View menu toggle). Idempotent. */\nexport async function showCalendarSidebar(): Promise<void> {\n const el = document.getElementById(\"calendar-sidebar\");\n if (!el) return;\n el.hidden = false;\n document.body.classList.add(\"calendar-sidebar-on\");\n try { localStorage.setItem(SIDEBAR_PREF, \"true\"); } catch { /* */ }\n await loadHiddenCalendars();\n void renderCalendarList(); // async \u2014 repaints events when it resolves\n await refresh();\n}\n\nexport function hideCalendarSidebar(): void {\n const el = document.getElementById(\"calendar-sidebar\");\n if (!el) return;\n el.hidden = true;\n document.body.classList.remove(\"calendar-sidebar-on\");\n try { localStorage.setItem(SIDEBAR_PREF, \"false\"); } catch { /* */ }\n}\n\nexport function isCalendarSidebarOn(): boolean {\n // Default is ON \u2014 user-reported 2026-04-23 that the sidebar should be\n // visible by default, not hidden. An explicit \"false\" in localStorage\n // still hides it (the user's stored preference wins).\n try {\n const v = localStorage.getItem(SIDEBAR_PREF);\n return v === null ? true : v !== \"false\";\n } catch { return true; }\n}\n\n/** Wire one-time event handlers + restore from localStorage. Safe to call\n * multiple times \u2014 handlers are idempotent because the elements are stable. */\nexport function initCalendarSidebar(): void {\n const wireOnce = (id: string, fn: () => void) => {\n const el = document.getElementById(id);\n if (!el || (el as any).__wired) return;\n (el as any).__wired = true;\n el.addEventListener(\"click\", fn);\n };\n wireOnce(\"cal-side-prev\", () => { const d = new Date(viewYear, viewMonth, viewDay - 1); viewYear = d.getFullYear(); viewMonth = d.getMonth(); viewDay = d.getDate(); refresh(); });\n wireOnce(\"cal-side-next\", () => { const d = new Date(viewYear, viewMonth, viewDay + 1); viewYear = d.getFullYear(); viewMonth = d.getMonth(); viewDay = d.getDate(); refresh(); });\n wireOnce(\"cal-side-today\", () => { const t = new Date(); viewYear = t.getFullYear(); viewMonth = t.getMonth(); viewDay = t.getDate(); refresh(); });\n wireOnce(\"cal-side-new\", () => { openNewEventDialog(); });\n wireOnce(\"cal-side-new-task\", async () => {\n const title = prompt(\"Task title:\");\n if (!title) return;\n await createTask({ title });\n renderTasks();\n });\n // Manual refresh \u2014 getTasks on the service side already fires a Google\n // pull under the hood on every call, but the visible feedback (spinning\n // glyph for ~600 ms, disabled while in flight) makes it explicit that\n // the user asked for a fresh pull, not a cached redraw.\n wireOnce(\"cal-side-refresh-tasks\", async () => {\n const btn = document.getElementById(\"cal-side-refresh-tasks\") as HTMLButtonElement | null;\n if (btn?.classList.contains(\"cal-side-refreshing\")) return;\n btn?.classList.add(\"cal-side-refreshing\");\n (btn as HTMLButtonElement).disabled = true;\n try {\n // renderTasks() calls getTasks() on the service which triggers\n // the async Google pull; the subsequent tasksUpdated event\n // re-renders with the merged result.\n await renderTasks();\n } finally {\n setTimeout(() => {\n btn?.classList.remove(\"cal-side-refreshing\");\n if (btn) btn.disabled = false;\n }, 600);\n }\n });\n // Manual events refresh \u2014 same pattern as tasks. fetchUpcoming hits the\n // service which kicks a Google pull under the hood.\n wireOnce(\"cal-side-refresh-events\", async () => {\n const btn = document.getElementById(\"cal-side-refresh-events\") as HTMLButtonElement | null;\n if (btn?.classList.contains(\"cal-side-refreshing\")) return;\n btn?.classList.add(\"cal-side-refreshing\");\n if (btn) btn.disabled = true;\n try {\n // Also re-enumerate calendars \u2014 the user may have selected /\n // deselected one in Google since the sidebar opened.\n await Promise.all([renderCalendarList(), refresh()]);\n } finally {\n setTimeout(() => {\n btn?.classList.remove(\"cal-side-refreshing\");\n if (btn) btn.disabled = false;\n }, 600);\n }\n });\n const showDoneCb = document.getElementById(\"cal-side-show-done\") as HTMLInputElement | null;\n if (showDoneCb && !(showDoneCb as any).__wired) {\n (showDoneCb as any).__wired = true;\n // Sticky: restore prior state and persist on change. Default off.\n try { showDoneCb.checked = localStorage.getItem(SHOW_DONE_PREF) === \"true\"; } catch { /* */ }\n showDoneCb.addEventListener(\"change\", () => {\n try { localStorage.setItem(SHOW_DONE_PREF, String(showDoneCb.checked)); } catch { /* */ }\n renderTasks();\n });\n }\n // Recurring-events filter toggle \u2014 hides expanded recurring-series\n // instances when unchecked. Default on so new users see everything.\n const recurCb = document.getElementById(\"cal-side-show-recurring\") as HTMLInputElement | null;\n if (recurCb && !(recurCb as any).__wired) {\n (recurCb as any).__wired = true;\n recurCb.checked = getShowRecurring();\n recurCb.addEventListener(\"change\", () => {\n try { localStorage.setItem(SHOW_RECURRING_PREF, String(recurCb.checked)); } catch { /* */ }\n refresh();\n });\n }\n // Per-calendar checkboxes are generated dynamically from the user's\n // selected Google calendars \u2014 see renderCalendarList(), driven from\n // showCalendarSidebar() + the manual refresh button. No hard-coded\n // holiday toggles any more.\n // Horizon input \u2014 how many days ahead to list events. Bounded 1..365.\n const horizonInput = document.getElementById(\"cal-side-horizon\") as HTMLInputElement | null;\n if (horizonInput && !(horizonInput as any).__wired) {\n (horizonInput as any).__wired = true;\n horizonInput.value = String(getHorizonDays());\n const commit = () => {\n const n = parseInt(horizonInput.value, 10);\n const clamped = Number.isFinite(n) && n > 0 ? Math.min(365, Math.max(1, n)) : HORIZON_DEFAULT_DAYS;\n horizonInput.value = String(clamped);\n try { localStorage.setItem(HORIZON_DAYS_PREF, String(clamped)); } catch { /* */ }\n refresh();\n };\n horizonInput.addEventListener(\"change\", commit);\n horizonInput.addEventListener(\"blur\", commit);\n }\n // Subscribe to service events \u2014 when a background Google pull finishes\n // and upserted/reconciled rows, re-render without waiting for a nav\n // click. Uses the mailxapi.onEvent bus. Idempotent flag keeps multiple\n // initCalendarSidebar calls from dup-subscribing.\n if (!(window as any).__mailxCalEventsWired) {\n const api = (window as any).mailxapi;\n if (api?.onEvent) {\n (window as any).__mailxCalEventsWired = true;\n api.onEvent((event: any) => {\n if (event?.type === \"calendarUpdated\") refresh();\n else if (event?.type === \"tasksUpdated\") renderTasks();\n else if (event?.type === \"quotaError\") {\n // Surface a non-clickable banner \u2014 daily quota will reset\n // on its own, no user action helps. Idempotent so the\n // banner doesn't flash on repeat poll attempts.\n const host = event.feature === \"tasks\"\n ? document.getElementById(\"cal-side-tasks\")\n : document.getElementById(\"cal-side-body\");\n if (host && !host.querySelector(\".cal-side-quota-error\")) {\n const msg = event.message || `Google ${event.feature} quota exceeded \u2014 try again later.`;\n host.innerHTML = `<div class=\"cal-side-empty cal-side-quota-error\">${escapeHtml(msg)}</div>`;\n }\n }\n else if (event?.type === \"authScopeError\") {\n // Surface a visible hint right in the affected pane so the\n // user doesn't stare at an empty list wondering why. Only\n // writes to the matching pane; other panes keep rendering.\n //\n // Idempotent: if the banner is already shown for this\n // feature (class `.cal-side-auth-error` present), don't\n // re-write the DOM \u2014 stops the \"flashing on and off\n // continually\" effect when the service re-emits on every\n // 5-min poll or sidebar-nav click.\n const host = event.feature === \"tasks\"\n ? document.getElementById(\"cal-side-tasks\")\n : document.getElementById(\"cal-side-body\");\n if (host && !host.querySelector(\".cal-side-auth-error\")) {\n const msg = event.message || \"Google access needs re-consent.\";\n host.innerHTML = `<div class=\"cal-side-empty cal-side-auth-error\">\n <div style=\"margin-bottom:0.6em\">${escapeHtml(msg)}</div>\n <button type=\"button\" class=\"cal-side-reauth-btn\" style=\"padding:0.3em 0.8em;border-radius:4px;border:1px solid currentColor;background:transparent;color:inherit;cursor:pointer;font-size:0.9em\">Re-authenticate Now</button>\n </div>`;\n const btn = host.querySelector<HTMLButtonElement>(\".cal-side-reauth-btn\");\n btn?.addEventListener(\"click\", async () => {\n btn.disabled = true;\n btn.textContent = \"Opening browser\u2026\";\n try {\n await reauthGoogleScopes();\n btn.textContent = \"Consent opened \u2014 complete it in the browser\";\n } catch (err: any) {\n btn.disabled = false;\n btn.textContent = `Failed: ${err?.message || err}. Click to retry.`;\n }\n });\n }\n }\n });\n }\n }\n}\n", "/**\n * Address book modal \u2014 list / search / edit / delete contacts.\n * Sources: 'sent' (added on outgoing), 'received' (seeded from message senders),\n * 'manual' (added directly here), 'google' (Google Contacts sync).\n */\nimport { listContacts, upsertContact, deleteContact } from \"../lib/api-client.js\";\n\ninterface Contact {\n name: string;\n email: string;\n source: string;\n useCount: number;\n lastUsed: number;\n}\n\nlet isOpen = false;\n\nexport async function openAddressBook(): Promise<void> {\n if (isOpen) return;\n isOpen = true;\n\n const backdrop = document.createElement(\"div\");\n backdrop.className = \"mailx-modal-backdrop\";\n const panel = document.createElement(\"div\");\n panel.className = \"mailx-modal mailx-modal-wide\";\n panel.innerHTML = `\n <div class=\"mailx-modal-title\">\n <span class=\"mailx-modal-title-text\">Address Book</span>\n <button type=\"button\" class=\"mailx-modal-close\" id=\"ab-close\" title=\"Close (Esc)\" aria-label=\"Close\">×</button>\n </div>\n <div class=\"ab-toolbar\">\n <input type=\"search\" id=\"ab-search\" class=\"mailx-modal-input\" placeholder=\"Search name or email\u2026\" autocomplete=\"off\">\n <button type=\"button\" class=\"mailx-modal-btn mailx-modal-btn-primary\" id=\"ab-new\">+ New contact</button>\n </div>\n <div class=\"ab-count\" id=\"ab-count\"></div>\n <div class=\"ab-list\" id=\"ab-list\">Loading\u2026</div>\n <div class=\"mailx-modal-buttons\">\n <span class=\"mailx-modal-spacer\"></span>\n <button type=\"button\" class=\"mailx-modal-btn\" data-action=\"close\">Close</button>\n </div>`;\n backdrop.appendChild(panel);\n document.body.appendChild(backdrop);\n\n const searchInput = panel.querySelector<HTMLInputElement>(\"#ab-search\")!;\n const listEl = panel.querySelector<HTMLElement>(\"#ab-list\")!;\n const countEl = panel.querySelector<HTMLElement>(\"#ab-count\")!;\n\n let editingEmail: string | null = null;\n\n const render = (items: Contact[], total: number) => {\n countEl.textContent = total === items.length\n ? `${total} contact${total === 1 ? \"\" : \"s\"}`\n : `Showing ${items.length} of ${total}`;\n if (items.length === 0) {\n listEl.innerHTML = `<div class=\"ab-empty\">No contacts match.</div>`;\n return;\n }\n const fmtDate = (ms: number) => {\n if (!ms) return \"\";\n const d = new Date(ms);\n const now = new Date();\n return d.getFullYear() === now.getFullYear()\n ? d.toLocaleDateString(undefined, { month: \"short\", day: \"numeric\" })\n : d.toLocaleDateString();\n };\n listEl.innerHTML = `\n <div class=\"ab-row ab-header\">\n <span class=\"ab-name\">Name</span>\n <span class=\"ab-email\">Email</span>\n <span class=\"ab-source\">Source</span>\n <span class=\"ab-count-cell\" title=\"Times sent to\">\u00D7</span>\n <span class=\"ab-last\">Last</span>\n <span class=\"ab-actions\"></span>\n </div>` + items.map(c => `\n <div class=\"ab-row\" data-email=\"${escapeAttr(c.email)}\">\n <span class=\"ab-name\">${escapeHtml(c.name || \"\")}</span>\n <span class=\"ab-email\">${escapeHtml(c.email)}</span>\n <span class=\"ab-source\">${escapeHtml(c.source)}</span>\n <span class=\"ab-count-cell\">${c.useCount || 0}</span>\n <span class=\"ab-last\">${fmtDate(c.lastUsed)}</span>\n <span class=\"ab-actions\">\n <button type=\"button\" class=\"ab-edit\" title=\"Edit name\">\u270E</button>\n <button type=\"button\" class=\"ab-del\" title=\"Delete\">\uD83D\uDDD1</button>\n <button type=\"button\" class=\"ab-mail\" title=\"Compose to\">\u2709</button>\n </span>\n </div>`).join(\"\");\n listEl.querySelectorAll<HTMLElement>(\".ab-row[data-email]\").forEach(row => {\n const email = row.dataset.email!;\n const c = items.find(x => x.email === email)!;\n row.querySelector(\".ab-edit\")?.addEventListener(\"click\", () => beginEdit(row, c));\n row.querySelector(\".ab-del\")?.addEventListener(\"click\", () => doDelete(c));\n row.querySelector(\".ab-mail\")?.addEventListener(\"click\", () => composeTo(c));\n });\n };\n\n const beginEdit = (row: HTMLElement, c: Contact) => {\n if (editingEmail === c.email) return;\n editingEmail = c.email;\n const nameSpan = row.querySelector<HTMLElement>(\".ab-name\")!;\n const original = c.name || \"\";\n nameSpan.innerHTML = `<input type=\"text\" value=\"${escapeAttr(original)}\" class=\"ab-name-input\">`;\n const inp = nameSpan.querySelector<HTMLInputElement>(\"input\")!;\n inp.focus();\n inp.select();\n const commit = async () => {\n const newName = inp.value.trim();\n if (newName !== original) {\n try {\n await upsertContact(newName, c.email);\n c.name = newName;\n } catch (e: any) {\n alert(`Update failed: ${e?.message || e}`);\n }\n }\n nameSpan.textContent = c.name || \"\";\n editingEmail = null;\n };\n inp.addEventListener(\"blur\", commit);\n inp.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") { e.preventDefault(); inp.blur(); }\n else if (e.key === \"Escape\") { e.preventDefault(); inp.value = original; inp.blur(); }\n });\n };\n\n const doDelete = async (c: Contact) => {\n if (!confirm(`Delete contact \"${c.name || c.email}\"?`)) return;\n try {\n await deleteContact(c.email);\n await reload();\n } catch (e: any) {\n alert(`Delete failed: ${e?.message || e}`);\n }\n };\n\n const composeTo = (c: Contact) => {\n const init: {\n mode: string;\n to: { name: string; address: string }[];\n cc: { name: string; address: string }[];\n bcc: { name: string; address: string }[];\n subject: string;\n bodyHtml: string;\n inReplyTo: string;\n references: string[];\n accounts: { id: string; name: string; email: string }[];\n } = {\n mode: \"new\",\n to: [{ name: c.name || \"\", address: c.email }],\n cc: [], bcc: [], subject: \"\", bodyHtml: \"\",\n inReplyTo: \"\", references: [], accounts: [],\n };\n try { sessionStorage.setItem(\"composeInit\", JSON.stringify(init)); } catch { /* */ }\n document.dispatchEvent(new CustomEvent(\"mailx-compose\", { detail: { mode: \"new\" } }));\n close();\n };\n\n let reloadDebounce: number | undefined;\n const reload = async () => {\n try {\n const r = await listContacts(searchInput.value, 1, 200);\n render(r.items, r.total);\n } catch (e: any) {\n listEl.innerHTML = `<div class=\"ab-empty\">Load failed: ${escapeHtml(e?.message || String(e))}</div>`;\n }\n };\n const scheduleReload = () => {\n if (reloadDebounce) window.clearTimeout(reloadDebounce);\n reloadDebounce = window.setTimeout(reload, 200);\n };\n\n panel.querySelector<HTMLButtonElement>(\"#ab-new\")?.addEventListener(\"click\", async () => {\n const email = prompt(\"Email address:\");\n if (!email) return;\n const name = prompt(\"Display name (optional):\") || \"\";\n try {\n await upsertContact(name, email.trim());\n await reload();\n } catch (e: any) {\n alert(`Add failed: ${e?.message || e}`);\n }\n });\n searchInput.addEventListener(\"input\", scheduleReload);\n\n const close = () => {\n if (reloadDebounce) window.clearTimeout(reloadDebounce);\n backdrop.remove();\n document.removeEventListener(\"keydown\", onKey, true);\n isOpen = false;\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") { e.stopPropagation(); e.preventDefault(); close(); }\n };\n document.addEventListener(\"keydown\", onKey, true);\n panel.querySelector<HTMLButtonElement>(\"#ab-close\")!.addEventListener(\"click\", close);\n panel.querySelector<HTMLButtonElement>('[data-action=\"close\"]')!.addEventListener(\"click\", close);\n backdrop.addEventListener(\"mousedown\", (e) => { if (e.target === backdrop) close(); });\n\n await reload();\n}\n\nfunction escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, c => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", \"\\\"\": \""\", \"'\": \"'\" }[c]!));\n}\nfunction escapeAttr(s: string): string { return escapeHtml(s); }\n", "/**\n * Alarm subsystem \u2014 Thunderbird/Outlook-style reminder popups.\n *\n * Design decisions (2026-04-24):\n * - One shared subsystem for calendar events + tasks (+ future mail reminders).\n * - Popup shows item title, scheduled time, Snooze (5 / 15 / 30 min / 1 hr /\n * custom), Dismiss. Snooze delays the alarm by the chosen interval; Dismiss\n * suppresses it permanently.\n * - Dismissed / snoozed state lives in localStorage (per-device). Google\n * Calendar's own reminders aren't mutated by this \u2014 mailx's popup is a\n * local convenience, not a reminder-authority replacement.\n * - Default lead time: 10 min before calendar event start; at task due time\n * for tasks. Per-event overrides from Google Calendar's `reminders.overrides`\n * are a follow-up (we don't currently fetch that field).\n *\n * Check cadence: every 30 s while the tab/window is focused. No check when\n * hidden \u2014 alarm won't fire until user returns, which matches Thunderbird's\n * behavior (focus-gated). OS-level notifications are a separate follow-up.\n */\n\nimport { getCalendarEvents, getTasks, showReminderPopup, deleteCalendarEvent, logClientEvent } from \"../lib/api-client.js\";\n\n/** Alarm diagnostics. A plain console.log from the WebView never reaches\n * the daemon log file, so snooze/dismiss/fire decisions were invisible\n * after the fact \u2014 \"is snooze working?\" couldn't be answered from the\n * log. alog() mirrors the line to the daemon log via logClientEvent. */\nfunction alog(tag: string, data?: any): void {\n console.log(`[alarm] ${tag}`, data ?? \"\");\n try { logClientEvent(`[alarm] ${tag}`, data); } catch { /* never break the alarm path */ }\n}\n\nconst DISMISSED_KEY = \"mailx-alarm-dismissed\"; // { uuid: true }\nconst SNOOZED_KEY = \"mailx-alarm-snoozed\"; // { uuid: epoch-ms-end }\nconst LOOKBACK_MS = 60 * 60 * 1000; // fire past-due alarms from the last 60 min\nconst LOOKAHEAD_MS = 2 * 60 * 60 * 1000; // poll window: upcoming 2 hr\nconst POLL_INTERVAL_MS = 30_000;\n\ninterface AlarmItem {\n uuid: string;\n kind: \"calendar\" | \"task\";\n title: string;\n /** When the alarm should fire (epoch ms). */\n alarmMs: number;\n /** When the source event actually starts / is due (epoch ms) \u2014 shown in popup. */\n whenMs: number;\n /** Optional web link for \"view in browser\". */\n htmlLink?: string;\n}\n\nfunction loadDismissed(): Record<string, boolean> {\n try { return JSON.parse(localStorage.getItem(DISMISSED_KEY) || \"{}\"); } catch { return {}; }\n}\nfunction saveDismissed(map: Record<string, boolean>): void {\n try { localStorage.setItem(DISMISSED_KEY, JSON.stringify(map)); } catch { /* private mode */ }\n}\nfunction loadSnoozed(): Record<string, number> {\n try { return JSON.parse(localStorage.getItem(SNOOZED_KEY) || \"{}\"); } catch { return {}; }\n}\nfunction saveSnoozed(map: Record<string, number>): void {\n try { localStorage.setItem(SNOOZED_KEY, JSON.stringify(map)); } catch { /* */ }\n}\n\n/** Prune expired entries so the maps don't grow forever. */\nfunction pruneState(now: number): void {\n const snoozed = loadSnoozed();\n let changed = false;\n for (const [uuid, until] of Object.entries(snoozed)) {\n // Snoozed entries older than 7 days have been fired already (or the\n // event is long past); drop them.\n if (until < now - 7 * 86400_000) { delete snoozed[uuid]; changed = true; }\n }\n if (changed) saveSnoozed(snoozed);\n // Dismissed is sparse \u2014 don't bother pruning aggressively.\n}\n\n/** Per-occurrence key for dismissed/snoozed/fired tracking. Recurring\n * events all share `ev.uuid`, so dismissing one occurrence used to\n * dismiss every future one. Including startMs makes today's 9am\n * meeting distinct from yesterday's. Tasks also use this \u2014 duplicate\n * taskId+dueMs is fine since completed tasks drop out of getTasks. */\nfunction occKey(uuid: string, ms: number): string {\n return `${uuid}:${ms}`;\n}\n\nasync function collectDueAlarms(now: number): Promise<AlarmItem[]> {\n const dismissed = loadDismissed();\n const snoozed = loadSnoozed();\n const items: AlarmItem[] = [];\n\n try {\n const events = await getCalendarEvents(now - LOOKBACK_MS, now + LOOKAHEAD_MS);\n for (const ev of events) {\n // Prefer providerId (Google Calendar event id) as the dedup\n // root. It's stable across local resyncs \u2014 if calendar sync\n // purges + reinserts a row, mailx-uuid changes but providerId\n // doesn't. Using uuid here was the cause of \"popup keeps\n // reappearing even when I don't react\" (Bob 2026-05-14): a\n // resync mid-popup-display assigned a new uuid \u2192 openPopups /\n // firedThisSession / dismissed all keyed on the old one \u2192\n // every poll spawned a NEW popup for what was logically the\n // same event-occurrence. uuid is the fallback for local-only\n // rows that never got a providerId.\n const stableId = ev.providerId || ev.uuid;\n if (!stableId) continue;\n const startMs = ev.startMs || 0;\n if (!startMs) continue;\n // Per-event popup reminder offsets. Bob 2026-05-13: \"alarm\n // should popup for events that have reminders set\" \u2014 i.e.\n // ONLY fire when the event (or its calendar default) actually\n // configured a popup reminder. The upstream filter in\n // google-sync.ts keeps `method === \"popup\"` only (Google also\n // supports email/sms; those aren't ours to fire). An empty\n // reminderMinutes here means \"no popup reminders\" \u2014 skip the\n // event entirely instead of fabricating a 10-min default that\n // the user never asked for.\n if (!Array.isArray(ev.reminderMinutes) || ev.reminderMinutes.length === 0) continue;\n const offsets: number[] = ev.reminderMinutes.map((m: number) => m * 60_000);\n // Per event-occurrence, emit at most ONE alarm \u2014 the one whose\n // alarm time is the latest among those still eligible (i.e.,\n // closest to \"now\" but already in the past). If Bob set\n // reminders at -60/-30/-15/-5/0 min and mailx wakes from\n // hidden-tab / reboot with several inside the 60-min lookback,\n // we'd otherwise stack one popup per offset. Earlier offsets\n // get auto-dismissed so they can't re-fire later as stragglers.\n const occBaseKey = occKey(stableId, startMs);\n let pick: { offsetMs: number; effective: number; key: string } | null = null;\n const eligibleOffsets: number[] = [];\n for (const offsetMs of offsets) {\n const key = `${occBaseKey}@${offsetMs}`;\n if (dismissed[key]) continue;\n const alarm = startMs - offsetMs;\n const effective = snoozed[key] || alarm;\n if (effective <= now && effective > now - LOOKBACK_MS) {\n eligibleOffsets.push(offsetMs);\n if (!pick || effective > pick.effective) {\n pick = { offsetMs, effective, key };\n }\n }\n }\n if (pick) {\n // Diagnostic for \"dismissed reminder reappears after restart\"\n // (Bob 2026-05-16): log the exact fire key so a re-fire can\n // be compared against the `[alarm] dismiss saved` line from\n // before the restart \u2014 a mismatch pinpoints a key shift\n // (stableId / startMs instability across resync).\n alog(\"fire decision\", { key: pick.key, title: ev.title || \"\", startMs, effective: pick.effective });\n items.push({\n uuid: pick.key, kind: \"calendar\",\n title: ev.title || \"(no title)\",\n alarmMs: pick.effective, whenMs: startMs,\n htmlLink: ev.htmlLink,\n });\n // Suppress the earlier offsets so they don't fire as\n // stragglers next poll. Persist immediately so a refresh\n // doesn't lose the suppression.\n let touched = false;\n for (const offsetMs of eligibleOffsets) {\n if (offsetMs === pick.offsetMs) continue;\n const k = `${occBaseKey}@${offsetMs}`;\n if (!dismissed[k]) { dismissed[k] = true; touched = true; }\n }\n if (touched) saveDismissed(dismissed);\n }\n }\n } catch { /* sidebar will surface API errors; alarm path stays quiet */ }\n\n try {\n const tasks = await getTasks(false);\n for (const t of tasks) {\n if (!t.uuid || !t.dueMs) continue;\n const key = occKey(t.uuid, t.dueMs);\n if (dismissed[key]) continue;\n const effective = snoozed[key] || t.dueMs;\n if (effective <= now && effective > now - LOOKBACK_MS) {\n items.push({\n uuid: key, kind: \"task\",\n title: t.title || \"(no title)\",\n alarmMs: effective, whenMs: t.dueMs,\n });\n }\n }\n } catch { /* */ }\n\n return items;\n}\n\nfunction formatWhen(ms: number): string {\n if (!ms) return \"\";\n const d = new Date(ms);\n return d.toLocaleString(undefined, { weekday: \"short\", month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\" });\n}\n\nlet firedThisSession = new Set<string>();\n/** uuids whose popup is currently visible on screen \u2014 set when the\n * popup opens, cleared when the user resolves it (button or close).\n * pollAlarms skips uuids in here so a tick that lands while a popup is\n * still up doesn't open a second window for the same event. Distinct\n * from `firedThisSession` (which is \"shown at any point this session\"\n * and gets cleared by snooze) \u2014 `openPopups` is \"right now on screen\". */\nlet openPopups = new Set<string>();\n\nfunction escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, c => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", \"\\\"\": \""\", \"'\": \"'\" }[c]!));\n}\n\n/** Render the popup using an inline DOM overlay \u2014 used when no msger\n * host is available (browser mode, Android shells without\n * showReminderPopup wired). Returns the same shape as the msger path\n * so the caller can switch on `r.button` uniformly. */\nfunction showInWebViewPopup(opts: { title: string; html: string; buttons: string[] }): Promise<{ button: string }> {\n return new Promise(resolve => {\n const overlay = document.createElement(\"div\");\n overlay.className = \"alarm-overlay\";\n const panel = document.createElement(\"div\");\n panel.className = \"alarm-panel\";\n const buttonsHtml = opts.buttons.map(b =>\n `<button type=\"button\" class=\"alarm-btn${b === \"Open\" || b === \"Dismiss\" ? \" alarm-btn-primary\" : \"\"}\" data-button=\"${escapeHtml(b)}\">${escapeHtml(b)}</button>`\n ).join(\"\");\n panel.innerHTML = `\n <div class=\"alarm-head\">\n <span class=\"alarm-icon\">\u23F0</span>\n <span class=\"alarm-title\">${escapeHtml(opts.title)}</span>\n <button type=\"button\" class=\"alarm-close\" data-button=\"\" aria-label=\"Close\">×</button>\n </div>\n <div class=\"alarm-body\">${opts.html}</div>\n <div class=\"alarm-foot\">${buttonsHtml}</div>\n `;\n overlay.appendChild(panel);\n document.body.appendChild(overlay);\n\n const msgapi: any = (window as any).msgapi;\n try { msgapi?.setAlwaysOnTop?.(true); } catch { /* */ }\n\n const cleanup = (button: string): void => {\n overlay.remove();\n try { msgapi?.setAlwaysOnTop?.(false); } catch { /* */ }\n document.removeEventListener(\"keydown\", onKey, true);\n resolve({ button });\n };\n const onKey = (e: KeyboardEvent): void => {\n if (e.key === \"Escape\") { e.stopPropagation(); cleanup(\"\"); }\n };\n document.addEventListener(\"keydown\", onKey, true);\n panel.querySelectorAll<HTMLButtonElement>(\"[data-button]\").forEach(btn => {\n btn.addEventListener(\"click\", () => cleanup(btn.dataset.button || \"\"));\n });\n });\n}\n\nasync function firePopupForItem(item: AlarmItem): Promise<void> {\n // Already-showing guard. The popup chain is fire-and-forget so\n // without an explicit registry there's nothing stopping a second\n // window for the same uuid (e.g., a 2-min Open snooze expires while\n // the user is still looking at the first popup). `openPopups` tracks\n // \"currently on screen\" \u2014 pollAlarms also checks it.\n if (openPopups.has(item.uuid)) return;\n openPopups.add(item.uuid);\n\n // Buttons map to msger's `buttons` array for the keyboard-default\n // detection in template mode; in rawHtml we render our own controls,\n // but the array still flows back as the default-button fallback if\n // the user closes the window without picking. Order = priority.\n const buttons: string[] = [\"snooze\", \"Dismiss\"];\n if (item.htmlLink) buttons.push(\"Open\");\n if (item.kind === \"calendar\") buttons.push(\"Delete\");\n\n // rawHtml mode. msger's design intent is that we deliver any HTML\n // form and the parent process receives whatever `window.ipc.postMessage`\n // serializes \u2014 `MessageBoxResult` includes a `form: serde_json::Value`\n // field for exactly this. We mirror Thunderbird's snooze popup: a\n // row of preset chips PLUS a custom \"N units\" form. Snooze submits\n // `{button:\"snooze\", form:{minutes:N}}` so the daemon can handle any\n // duration; Dismiss/Open/Delete still discriminate by button label.\n const icon = item.kind === \"calendar\" ? \"\uD83D\uDCC5\" : \"\u2611\";\n const kindLabel = item.kind === \"calendar\" ? \"Calendar event\" : \"Task due\";\n // Presets in minutes. Tuned to TBird's defaults \u2014 covers the common\n // \"give me 15 more minutes\" through \"remind me tomorrow morning.\"\n const presets: Array<{ label: string; minutes: number }> = [\n { label: \"5m\", minutes: 5 },\n { label: \"15m\", minutes: 15 },\n { label: \"30m\", minutes: 30 },\n { label: \"1h\", minutes: 60 },\n { label: \"2h\", minutes: 120 },\n { label: \"1d\", minutes: 60 * 24 },\n ];\n const presetChips = presets.map(p =>\n `<button type=\"button\" class=\"chip\" data-snooze-min=\"${p.minutes}\">${p.label}</button>`\n ).join(\"\");\n const actionBtns: string[] = [\"Dismiss\"];\n if (item.htmlLink) actionBtns.push(\"Open\");\n if (item.kind === \"calendar\") actionBtns.push(\"Delete\");\n const actionHtml = actionBtns.map(b =>\n `<button type=\"button\" class=\"action\" data-btn=\"${escapeHtml(b)}\">${escapeHtml(b)}</button>`\n ).join(\"\");\n\n const html = `<!DOCTYPE html>\n<html><head><meta charset=\"utf-8\"><style>\n html, body { height: 100%; }\n body { font: 13px system-ui, sans-serif; margin: 0; padding: 14px 16px; background: #fff; color: #222; display:flex; flex-direction:column; box-sizing:border-box; gap: 8px; }\n .icon { display: inline-block; width: 20px; text-align: center; margin-right: 4px; }\n .title { font-size: 1.1em; font-weight: 600; }\n .when { color: #555; font-size: 0.95em; }\n .kind { color: #888; font-size: 0.85em; }\n .spacer { flex: 1; }\n .row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }\n .row-label { color: #555; font-size: 0.85em; margin-right: 2px; }\n button { font: 13px system-ui, sans-serif; cursor: pointer; }\n .chip { padding: 3px 9px; border: 1px solid #ccc; border-radius: 12px; background: #f7f7f7; color: #222; }\n .chip:hover { background: #e8f0fe; border-color: #0066cc; color: #0066cc; }\n .custom { display: flex; align-items: center; gap: 4px; }\n .custom input[type=number] { width: 48px; padding: 2px 4px; border: 1px solid #ccc; border-radius: 3px; font: inherit; }\n .custom select { padding: 2px 4px; border: 1px solid #ccc; border-radius: 3px; font: inherit; background: #fff; }\n .custom button.ok { padding: 2px 8px; border: 1px solid #0066cc; border-radius: 3px; background: #0066cc; color: #fff; }\n .custom button.ok:hover { background: #0052a3; }\n .actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 4px; }\n .actions button.action { padding: 5px 14px; border: none; border-radius: 4px; background: #0066cc; color: #fff; min-width: 70px; }\n .actions button.action:hover { background: #0052a3; }\n .actions button.action[data-btn=\"Delete\"] { background: #b00; }\n .actions button.action[data-btn=\"Delete\"]:hover { background: #800; }\n</style></head><body>\n <div class=\"title\"><span class=\"icon\">${icon}</span>${escapeHtml(item.title)}</div>\n <div class=\"when\">${escapeHtml(formatWhen(item.whenMs))}</div>\n <div class=\"kind\">${kindLabel}</div>\n <div class=\"row\">\n <span class=\"row-label\">Snooze:</span>\n ${presetChips}\n </div>\n <div class=\"row custom\">\n <span class=\"row-label\">Custom:</span>\n <input type=\"number\" id=\"customN\" value=\"5\" min=\"1\" max=\"999\">\n <select id=\"customUnit\">\n <option value=\"1\">minutes</option>\n <option value=\"60\">hours</option>\n <option value=\"1440\">days</option>\n </select>\n <button type=\"button\" class=\"ok\" id=\"customGo\">Snooze</button>\n </div>\n <div class=\"spacer\"></div>\n <div class=\"actions\">${actionHtml}</div>\n<script>\n function send(payload) {\n try { window.ipc.postMessage(JSON.stringify(payload)); }\n catch (e) { try { window.close(); } catch (_) {} }\n }\n // Preset chips \u2192 snooze N minutes\n document.querySelectorAll(\"button[data-snooze-min]\").forEach(function(b) {\n b.addEventListener(\"click\", function() {\n var m = parseInt(b.getAttribute(\"data-snooze-min\"), 10) || 15;\n send({ button: \"snooze\", form: { minutes: m } });\n });\n });\n // Custom snooze form \u2192 snooze N * unit-multiplier minutes\n var customGo = document.getElementById(\"customGo\");\n var customN = document.getElementById(\"customN\");\n var customUnit = document.getElementById(\"customUnit\");\n function submitCustom() {\n var n = parseInt(customN.value, 10);\n if (!isFinite(n) || n < 1) n = 5;\n var mult = parseInt(customUnit.value, 10) || 1;\n send({ button: \"snooze\", form: { minutes: n * mult } });\n }\n customGo.addEventListener(\"click\", submitCustom);\n customN.addEventListener(\"keydown\", function(e) { if (e.key === \"Enter\") submitCustom(); });\n // Action buttons (Dismiss/Open/Delete) \u2192 discriminate by label.\n document.querySelectorAll(\"button[data-btn]\").forEach(function(b) {\n b.addEventListener(\"click\", function() {\n send({ button: b.getAttribute(\"data-btn\") });\n });\n });\n</script>\n</body></html>`;\n\n const title = item.kind === \"calendar\" ? \"Calendar reminder\" : \"Task reminder\";\n const hasHost = !!(window as any).mailxapi?.showReminderPopup;\n // Result now carries an optional `form` object \u2014 see msger's\n // MessageBoxResult.form (serde_json::Value passthrough). Snooze\n // chips/inputs send `{ button: \"snooze\", form: { minutes: N } }`.\n let r: { button: string; form?: { minutes?: number } };\n try {\n if (hasHost) {\n r = await showReminderPopup({\n title, html, buttons,\n // Sized for the new snooze form (chips row + custom input\n // row + action buttons) plus title/when/kind. Width fits\n // \"5m 15m 30m 1h 2h 1d\" on one line in Segoe UI.\n size: { width: 560, height: 300 },\n });\n } else {\n r = await showInWebViewPopup({ title, html, buttons });\n }\n } finally {\n openPopups.delete(item.uuid);\n }\n\n const api = (window as any).mailxapi;\n const openLink = (): void => {\n if (!item.htmlLink) return;\n if (api?.openExternal) api.openExternal(item.htmlLink);\n else window.open(item.htmlLink, \"_blank\");\n };\n switch (r.button) {\n case \"Open\":\n openLink();\n // After opening for edit, treat the event as a brand-new\n // entry: clear all local alarm state so the next poll tick\n // re-fetches the (possibly edited) event and decides anew\n // whether to alarm. Edits can come from any client (web,\n // phone, another mailx instance, a co-organizer) \u2014 we just\n // re-evaluate against the synced data. Pushed start out \u2192\n // alarm waits for the new lead time; pulled in \u2192 fires again.\n // No carryover.\n //\n // Brief 2-min snooze gives the calendar sync window to pick\n // up edits before the next alarm pass \u2014 without it, the\n // next 30-s poll reads the still-stale local cache and\n // re-fires immediately. After 2 min, re-evaluation is\n // against (hopefully) fresh data.\n { const dm = loadDismissed(); delete dm[item.uuid]; saveDismissed(dm); }\n firedThisSession.delete(item.uuid);\n snoozeItem(item, 2);\n break;\n case \"snooze\": {\n // Generic snooze, duration in `form.minutes`. Falls back to\n // 15 min if the form is malformed or missing the value (the\n // chip / custom-form JS always populates it, so this is just\n // defense-in-depth).\n const m = Math.max(1, Math.floor(r.form?.minutes ?? 15));\n snoozeItem(item, m);\n break;\n }\n // Legacy literal labels \u2014 retained so a pre-upgrade popup whose\n // bundle still says \"Snooze 15m\" / \"Snooze 1h\" continues to work\n // through a daemon-side rebuild without a UI restart.\n case \"Snooze 15m\": snoozeItem(item, 15); break;\n case \"Snooze 1h\": snoozeItem(item, 60); break;\n case \"Dismiss\":\n case \"dismissed\": // msger reports window-close as r.dismissed=true \u2192 daemon\n // wrapper lowercases to \"dismissed\". Treat the same as\n // explicit Dismiss-button: the popup HAD a clear button\n // for that action, so any close that lands here is the\n // user's \"I'm done with this reminder\" \u2014 not a 15-min\n // snooze. Bob 2026-05-14: \"I keep dismissing the\n // reminder but it keeps coming back\" \u2014 the lowercase\n // path was hitting the snooze-15-min default, so the\n // popup re-fired every 15 min indefinitely.\n case \"closed\": // user closed via X / Esc with no labeled button \u2014\n // popup HAS Dismiss right there; the user clearly\n // didn't want this. Same treatment.\n { const m = loadDismissed(); m[item.uuid] = true; saveDismissed(m);\n alog(\"dismiss saved\", { key: item.uuid, via: r.button }); }\n break;\n case \"Delete\":\n // Remove the event server-side. Local row gets the pending-delete\n // mark inside the service; reconcile picks it up. Also dismiss\n // the alarm so it can't re-fire on a stale local row before the\n // delete propagates.\n { const m = loadDismissed(); m[item.uuid] = true; saveDismissed(m); }\n firedThisSession.delete(item.uuid);\n deleteCalendarEvent(item.uuid).catch((e: any) =>\n console.error(` [alarm] delete failed for ${item.uuid}: ${e?.message || e}`));\n break;\n default:\n // Unrecognized button label \u2014 shouldn't happen with the\n // current popup HTML. Log it so we can diagnose, then\n // dismiss permanently (the popup HAD explicit options; if\n // the user picked something we can't decode, don't keep\n // pestering them every 30 s).\n alog(\"unknown popup result\", { button: r.button, form: (r as any).form });\n { const m = loadDismissed(); m[item.uuid] = true; saveDismissed(m); }\n break;\n }\n}\n\nfunction snoozeItem(item: AlarmItem, minutes: number): void {\n const now = Date.now();\n const map = loadSnoozed();\n map[item.uuid] = now + minutes * 60_000;\n saveSnoozed(map);\n firedThisSession.delete(item.uuid);\n // Diagnostic \u2014 `key` here is the SAME per-occurrence key collectDueAlarms\n // reads back as `snoozed[key]`, so this line + the next \"fire decision\"\n // line confirm a snooze actually took (and for how long).\n alog(\"snooze saved\", { key: item.uuid, minutes, untilMs: map[item.uuid] });\n}\n\nasync function pollAlarms(): Promise<void> {\n if (document.hidden) return; // don't tick while tab hidden\n const now = Date.now();\n pruneState(now);\n const due = await collectDueAlarms(now);\n // Two filters: skip uuids already fired this session (Snooze + an\n // expired snooze re-allows them; Dismiss permanently suppresses via\n // localStorage), and skip uuids whose popup is currently visible\n // (race guard for fire-and-forget OS popups).\n const fresh = due.filter(d => !firedThisSession.has(d.uuid) && !openPopups.has(d.uuid));\n if (fresh.length === 0) return;\n for (const d of fresh) firedThisSession.add(d.uuid);\n\n // One unified path. firePopupForItem picks render target (msger OS\n // window vs in-WebView DOM overlay) internally; same buttons,\n // same post-click logic, same dedup. Fire-and-forget per item \u2014\n // errors logged but never block the poll loop.\n for (const item of fresh) {\n firePopupForItem(item).catch(e => console.error(` [alarm] popup failed: ${e?.message || e}`));\n }\n}\n\n/** Start the alarm poller. Called from app.ts on startup. */\nexport function startAlarmPoller(): void {\n if ((window as any).__mailxAlarmPollerRunning) return;\n (window as any).__mailxAlarmPollerRunning = true;\n // First check after 5 s so startup isn't jittered by a modal.\n setTimeout(() => { pollAlarms().catch(() => { /* */ }); }, 5000);\n setInterval(() => { pollAlarms().catch(() => { /* */ }); }, POLL_INTERVAL_MS);\n // Re-check on visibility change \u2014 if the user comes back to the tab\n // after an hour away, they should see anything they missed immediately.\n document.addEventListener(\"visibilitychange\", () => {\n if (!document.hidden) pollAlarms().catch(() => { /* */ });\n });\n}\n", "/**\n * Search help \u2014 part of the app, rendered as HTML.\n *\n * Help for a real app feature (as opposed to the JSONC config-file editors)\n * ships *inside the client*, not as a `.md` in the docs/ deploy set. The\n * `docs/*.md` files exist only as a stand-in for proper settings UI; feature\n * help like this is application content and must render as HTML.\n *\n * This module is dynamically imported the first time the search `?` button is\n * pressed, so the markup stays out of the cold-start bundle path.\n */\n\nexport const SEARCH_HELP_HTML = `\n<p>Search runs in one of three modes depending on the scope you pick. The mode\ndecides which operators work.</p>\n<ul>\n <li><strong>All folders</strong> (default) \u2014 searches the <em>local cache</em> with SQLite FTS5.</li>\n <li><strong>This folder</strong> \u2014 instant client-side filter on the visible rows; full FTS5 search on Enter.</li>\n <li><strong>Server</strong> (checkbox) \u2014 sends the query to the mail server (IMAP SEARCH; not yet wired for Gmail accounts).</li>\n</ul>\n\n<h3>Qualifiers \u2014 work in every mode</h3>\n<table>\n <tr><th>Form</th><th>Effect</th></tr>\n <tr><td><code>from:bob</code></td><td>Sender contains</td></tr>\n <tr><td><code>to:eleanor</code></td><td>Recipient contains</td></tr>\n <tr><td><code>subject:lunch</code></td><td>Subject contains</td></tr>\n <tr><td><code>date:2026-05-01</code></td><td>On that date \u2014 also <code>date:>1w</code>, <code>date:<=2026-01-15</code></td></tr>\n <tr><td><code>after:1w</code> / <code>before:1m</code></td><td>Newer / older than \u2014 <code>d</code>, <code>w</code>, <code>m</code>, <code>y</code>, a date, <code>today</code>, <code>yesterday</code></td></tr>\n <tr><td><code>has:attachment</code></td><td>Has an attachment</td></tr>\n <tr><td><code>is:unread</code></td><td>Also <code>is:flagged</code>, <code>is:read</code>, <code>is:answered</code>, <code>is:draft</code></td></tr>\n <tr><td><code>folder:sent</code></td><td>Restrict to folders whose name contains the term</td></tr>\n <tr><td><code>/regex/</code></td><td>Client-side regex over the currently-visible rows. Local only \u2014 never sent to the server.</td></tr>\n</table>\n<p>Any remaining unqualified text is the free-text term.</p>\n\n<h3>Local search (FTS5) \u2014 default</h3>\n<p>Full-text index over envelopes plus locally-cached bodies. Fast and indexed.</p>\n<table>\n <tr><th>Operator</th><th>Example</th><th>Meaning</th></tr>\n <tr><td>implicit AND</td><td><code>bob lunch</code></td><td>Both terms must appear</td></tr>\n <tr><td><code>OR</code></td><td><code>bob OR eleanor</code></td><td>Either term</td></tr>\n <tr><td><code>NOT</code></td><td><code>bob NOT spam</code></td><td>First without the second</td></tr>\n <tr><td><code>\"phrase\"</code></td><td><code>\"happy birthday\"</code></td><td>Exact phrase</td></tr>\n <tr><td><code>term*</code></td><td><code>lunch*</code></td><td>Prefix</td></tr>\n <tr><td><code>NEAR(a b, 5)</code></td><td><code>NEAR(bob lunch, 5)</code></td><td>Both within 5 tokens</td></tr>\n</table>\n<p>Indexed: subject, from, to, cc, a body snippet, and the full body <em>if</em> it\nhas been downloaded locally (the blue dot in the list). Bodies not yet\nprefetched aren't searchable until they are.</p>\n\n<h3>This-folder filter</h3>\n<p>With scope set to <strong>This folder</strong>, typing filters the rendered rows\ninstantly \u2014 no server hit, no FTS5, just substring match. Press <kbd>Enter</kbd>\nto escalate to a full FTS5 search of the folder.</p>\n\n<h3>Server search</h3>\n<p>Tick <strong>Server</strong> and the query goes to the mail server. On Dovecot\n(IMAP SEARCH) your <code>from:</code> / <code>to:</code> / <code>subject:</code>\nqualifiers map to IMAP keys; remaining text becomes a body search. IMAP has no\nliteral <code>AND</code> keyword \u2014 keys are implicitly ANDed. Server search on\nGmail accounts is not yet wired and currently returns local results only.</p>\n\n<h3>Case sensitivity</h3>\n<p>All three modes \u2014 local FTS5, IMAP server SEARCH, and <code>/regex/</code> \u2014\nare <strong>case-insensitive</strong>. <code>Lunch</code>, <code>lunch</code>, and\n<code>LUNCH</code> match the same messages. There is no case-sensitive mode.</p>\n\n<h3>Limitations</h3>\n<ul>\n <li>No regex on the server side, any provider \u2014 <code>/pattern/</code> only filters visible local rows.</li>\n <li>Server search results are stored locally (envelope-only) on first hit, so a server search also backfills those envelopes.</li>\n <li>Body search on bodies you haven't prefetched only works server-side; the local index can't search what isn't downloaded.</li>\n</ul>\n`;\n", "/**\n * Folder tree component -- renders account folders with hierarchy,\n * expand/collapse, and optional unified inbox.\n */\n\nimport { getAccounts, getFolders, moveMessage, moveMessages, markFolderRead, createFolder, renameFolder, deleteFolder, moveFolderToTrash, emptyFolder, setupAccount, getDeviceAccounts, getVersion, syncAccount } from \"../lib/api-client.js\";\nimport { showContextMenu, type MenuItem } from \"./context-menu.js\";\nimport { openTab } from \"./tabs.js\";\n\ntype FolderSelectHandler = (accountId: string, folderId: number, folderName: string, specialUse: string) => void;\n// Unified inbox uses folderId = -1 as a sentinel\ntype UnifiedHandler = () => void;\n\nlet onFolderSelect: FolderSelectHandler;\nlet onUnifiedInbox: UnifiedHandler | null = null;\nlet selectedElement: HTMLElement;\nlet selectedAccountId: string | null = null;\nlet selectedFolderId: number | null = null;\nlet isFirstLoad = true; // only auto-select on first load\nlet hasAutoSelected = false; // track whether we've ever managed to auto-select\n\n// Debounce timer for refreshFolderTree\nlet refreshDebounceTimer: ReturnType<typeof setTimeout> | null = null;\n\n// Spring-loaded folder hover-expand delay (Q117). Matches Outlook/Finder\n// feel \u2014 short enough to feel responsive, long enough that grazing past a\n// collapsed folder doesn't expand it accidentally.\nconst DRAG_HOVER_EXPAND_MS = 600;\n\n// Track folders that were auto-expanded during the current drag so they can\n// be collapsed back on dragend if the user didn't drop on (or inside) them.\n// Keyed by expandKey; value is true if it was previously collapsed.\nconst dragAutoExpanded = new Set<string>();\nlet dragInFlight = false;\n\n// Persist expand/collapse state in localStorage\nconst expandState: Record<string, boolean> = JSON.parse(localStorage.getItem(\"mailx-folders-expanded\") || \"{}\");\n\nfunction saveExpandState(): void {\n localStorage.setItem(\"mailx-folders-expanded\", JSON.stringify(expandState));\n}\n\n// Last-sync tracking: populated by folderSynced events. Memory-only; fresh\n// restarts start with an empty map and fill in as syncs happen.\nconst folderLastSync = new Map<string, number>(); // key = `${accountId}:${folderId}`\n\nfunction syncKey(accountId: string, folderId: number): string {\n return `${accountId}:${folderId}`;\n}\n\nexport function setFolderSynced(accountId: string, folderId: number, syncedAt: number): void {\n folderLastSync.set(syncKey(accountId, folderId), syncedAt);\n // Update the row in place if rendered \u2014 avoids a full re-render.\n const el = document.querySelector<HTMLElement>(`.ft-folder[data-account-id=\"${CSS.escape(accountId)}\"][data-folder-id=\"${folderId}\"]`);\n if (el) applyFreshness(el, syncedAt);\n}\n\nexport function getFolderSynced(accountId: string, folderId: number): number | undefined {\n return folderLastSync.get(syncKey(accountId, folderId));\n}\n\nfunction formatAge(ms: number): string {\n const secs = Math.round(ms / 1000);\n if (secs < 60) return `${secs}s ago`;\n const mins = Math.round(secs / 60);\n if (mins < 60) return `${mins}m ago`;\n const hours = Math.round(mins / 60);\n if (hours < 24) return `${hours}h ago`;\n return `${Math.round(hours / 24)}d ago`;\n}\n\nfunction freshnessClass(ageMs: number): string {\n if (ageMs < 5 * 60_000) return \"fresh\"; // green\n if (ageMs < 30 * 60_000) return \"stale-soft\"; // yellow\n return \"stale\"; // red\n}\n\nfunction applyFreshness(el: HTMLElement, syncedAt: number): void {\n const age = Date.now() - syncedAt;\n el.classList.remove(\"fresh\", \"stale-soft\", \"stale\");\n el.classList.add(freshnessClass(age));\n el.title = `Last synced: ${formatAge(age)} (${new Date(syncedAt).toLocaleTimeString()})`;\n}\n\ninterface FolderNode {\n id: number;\n accountId: string;\n path: string;\n name: string;\n specialUse: string;\n delimiter: string;\n unreadCount: number;\n totalCount: number;\n children: FolderNode[];\n}\n\n/** Build a tree from flat folder list using delimiter */\nfunction buildTree(folders: any[], delimiter: string, accountId: string): FolderNode[] {\n const root: FolderNode[] = [];\n const byPath: Record<string, FolderNode> = {};\n\n // Sort by path so parents come before children\n const sorted = [...folders].sort((a, b) => a.path.localeCompare(b.path));\n\n for (const f of sorted) {\n const node: FolderNode = {\n id: f.id,\n accountId: f.accountId,\n path: f.path,\n name: f.name,\n specialUse: f.specialUse,\n delimiter: f.delimiter || delimiter,\n unreadCount: f.unreadCount || 0,\n totalCount: f.totalCount || 0,\n children: [],\n };\n byPath[f.path] = node;\n\n // Find parent by stripping the last segment\n const lastDelim = f.path.lastIndexOf(delimiter);\n if (lastDelim > 0) {\n const parentPath = f.path.substring(0, lastDelim);\n let parent = byPath[parentPath];\n if (!parent) {\n // Create virtual parent for non-selectable folders (e.g., \"Added2\")\n const parentName = parentPath.split(delimiter).pop() || parentPath;\n parent = {\n id: -1, // virtual, not selectable\n accountId,\n path: parentPath,\n name: parentName,\n specialUse: \"\",\n delimiter,\n unreadCount: 0,\n totalCount: 0,\n children: [],\n };\n byPath[parentPath] = parent;\n // Insert the virtual parent into the tree\n const grandParentDelim = parentPath.lastIndexOf(delimiter);\n if (grandParentDelim > 0) {\n const grandParent = byPath[parentPath.substring(0, grandParentDelim)];\n if (grandParent) {\n grandParent.children.push(parent);\n } else {\n root.push(parent);\n }\n } else {\n root.push(parent);\n }\n }\n parent.children.push(node);\n } else {\n root.push(node);\n }\n }\n\n // Aggregate counts from children to parents (so collapsed parents show totals)\n function aggregateCounts(nodes: FolderNode[]): { unread: number; total: number } {\n let unread = 0, total = 0;\n for (const n of nodes) {\n const child = aggregateCounts(n.children);\n n.unreadCount += child.unread;\n n.totalCount += child.total;\n unread += n.unreadCount;\n total += n.totalCount;\n }\n return { unread, total };\n }\n aggregateCounts(root);\n\n return root;\n}\n\n/** Sort: INBOX first, then special folders, then alphabetical */\nfunction sortFolders(nodes: FolderNode[]): void {\n const specialOrder: Record<string, number> = { inbox: 0, sent: 1, outbox: 2, drafts: 3, trash: 4, junk: 5, archive: 6 };\n const nameOrder: Record<string, number> = { inbox: 0, sent: 1, \"sent items\": 1, outbox: 2, drafts: 3, trash: 4, \"deleted items\": 4, junk: 5, \"junk email\": 5, \"junk e-mail\": 5, spam: 5, archive: 6 };\n nodes.sort((a, b) => {\n const aOrder = specialOrder[a.specialUse] ?? nameOrder[a.name.toLowerCase()] ?? 99;\n const bOrder = specialOrder[b.specialUse] ?? nameOrder[b.name.toLowerCase()] ?? 99;\n if (aOrder !== bOrder) return aOrder - bOrder;\n return a.name.localeCompare(b.name);\n });\n for (const n of nodes) {\n if (n.children.length > 0) sortFolders(n.children);\n }\n}\n\n/** Render a folder node and its children recursively */\nfunction renderNode(node: FolderNode, container: HTMLElement, depth: number): void {\n const hasChildren = node.children.length > 0;\n const expandKey = `${node.accountId}:${node.path}`;\n const isExpanded = expandState[expandKey] === true; // default collapsed\n\n const folderEl = document.createElement(\"div\");\n folderEl.className = \"ft-folder\";\n folderEl.dataset.accountId = node.accountId;\n folderEl.dataset.folderId = String(node.id);\n folderEl.dataset.folderPath = node.path;\n folderEl.dataset.specialUse = node.specialUse || \"\";\n folderEl.style.paddingLeft = `${depth * 16 + 8}px`;\n\n // Expand/collapse toggle\n const toggle = document.createElement(\"span\");\n toggle.className = \"ft-toggle\";\n if (hasChildren) {\n toggle.textContent = isExpanded ? \"\u25BE\" : \"\u25B8\";\n toggle.addEventListener(\"click\", (e) => {\n e.stopPropagation();\n expandState[expandKey] = !isExpanded;\n saveExpandState();\n // Re-render the tree\n const treeContainer = document.getElementById(\"folder-tree\");\n if (treeContainer) loadFolderTree(treeContainer);\n });\n } else {\n toggle.textContent = \" \";\n }\n folderEl.appendChild(toggle);\n\n const freshnessDot = document.createElement(\"span\");\n freshnessDot.className = \"ft-freshness\";\n freshnessDot.setAttribute(\"aria-hidden\", \"true\");\n folderEl.appendChild(freshnessDot);\n\n const nameSpan = document.createElement(\"span\");\n nameSpan.className = \"ft-folder-name\";\n nameSpan.textContent = node.name;\n folderEl.appendChild(nameSpan);\n\n const syncedAt = getFolderSynced(node.accountId, node.id);\n if (syncedAt) applyFreshness(folderEl, syncedAt);\n\n const isOutbox = node.specialUse === \"outbox\" || node.path.toLowerCase() === \"outbox\";\n if (isOutbox && node.totalCount > 0) {\n // Outbox: show total (pending) count with warning style\n const badge = document.createElement(\"span\");\n badge.className = \"ft-badge ft-badge-outbox\";\n badge.textContent = String(node.totalCount);\n badge.title = `${node.totalCount} pending`;\n folderEl.appendChild(badge);\n } else if (node.unreadCount > 0) {\n const badge = document.createElement(\"span\");\n badge.className = \"ft-badge\";\n badge.textContent = String(node.unreadCount);\n badge.title = `${node.unreadCount} unread`;\n folderEl.appendChild(badge);\n }\n // Total count (shown when View > Folder counts is checked)\n if (node.totalCount > 0) {\n const total = document.createElement(\"span\");\n total.className = \"ft-total-count\";\n total.textContent = String(node.totalCount);\n total.title = `${node.totalCount} total messages`;\n folderEl.appendChild(total);\n }\n\n folderEl.addEventListener(\"click\", () => {\n if (node.id === -1) {\n // Virtual parent \u2014 toggle expand instead of selecting\n expandState[expandKey] = !isExpanded;\n saveExpandState();\n const treeContainer = document.getElementById(\"folder-tree\");\n if (treeContainer) loadFolderTree(treeContainer);\n return;\n }\n if (selectedElement) selectedElement.classList.remove(\"selected\");\n folderEl.classList.add(\"selected\");\n selectedElement = folderEl;\n selectedAccountId = node.accountId;\n selectedFolderId = node.id;\n onFolderSelect(node.accountId, node.id, node.name, node.specialUse || node.path.toLowerCase());\n });\n\n // \u2500\u2500 Right-click context menu \u2500\u2500\n folderEl.addEventListener(\"contextmenu\", (e) => {\n e.preventDefault();\n e.stopPropagation();\n const isTrash = node.specialUse === \"trash\" || node.path.toLowerCase().includes(\"trash\");\n const isJunk = node.specialUse === \"junk\" || node.path.toLowerCase().includes(\"spam\") || node.path.toLowerCase().includes(\"junk\");\n\n const items: MenuItem[] = [\n { label: \"Open in new tab\", action: () => {\n openTab(\n { kind: \"folder\", accountId: node.accountId, folderId: node.id, specialUse: node.specialUse || \"\" },\n node.name,\n true,\n );\n }},\n { label: \"\", action: () => {}, separator: true },\n { label: \"Mark all read\", action: async () => {\n try {\n await markFolderRead(node.accountId, node.id);\n const treeContainer = document.getElementById(\"folder-tree\");\n if (treeContainer) loadFolderTree(treeContainer);\n } catch { /* ignore */ }\n }},\n { label: \"\", action: () => {}, separator: true },\n { label: \"New subfolder...\", action: async () => {\n const name = prompt(\"New folder name:\");\n if (!name) return;\n try {\n await createFolder(node.accountId, node.path, name);\n // Auto-expand the parent so the just-created child is\n // visible immediately. Without this, a collapsed parent\n // hides the new folder until the user clicks the\n // disclosure triangle \u2014 looked like CREATE silently\n // failed, which it hadn't.\n expandState[`${node.accountId}:${node.path}`] = true;\n saveExpandState();\n const treeContainer = document.getElementById(\"folder-tree\");\n if (treeContainer) loadFolderTree(treeContainer);\n } catch (err: any) { alert(`Failed: ${err.message}`); }\n }},\n { label: \"Rename...\", action: async () => {\n const newName = prompt(\"Rename folder:\", node.name);\n if (!newName || newName === node.name) return;\n try {\n await renameFolder(node.accountId, node.id, newName);\n const treeContainer = document.getElementById(\"folder-tree\");\n if (treeContainer) loadFolderTree(treeContainer);\n } catch (err: any) { alert(`Failed: ${err.message}`); }\n }, disabled: !!node.specialUse },\n // Two delete entries. Move-to-Trash is the default; permanent\n // delete is a second, separated item with a tooltip so it's\n // visible from the menu rather than requiring a discovery\n // shortcut. The IMAP RENAME under Trash brings messages +\n // subfolders along; the server-side fallback (when Trash is\n // \\Noinferiors) moves messages to Trash root and then deletes\n // the empty folder. Permanent skips Trash entirely.\n { label: \"Move folder to Trash\", action: async () => {\n if (!confirm(`Move folder \"${node.name}\" to Trash? It can be restored by dragging back out of Trash.`)) return;\n try {\n await moveFolderToTrash(node.accountId, node.id);\n const treeContainer = document.getElementById(\"folder-tree\");\n if (treeContainer) loadFolderTree(treeContainer);\n } catch (err: any) { alert(`Failed: ${err.message}`); }\n }, disabled: !!node.specialUse,\n tooltip: \"Renames the folder into Trash (date-suffixed if needed); use Delete permanently below to skip Trash.\" },\n { label: \"Delete folder permanently\", action: async () => {\n if (!confirm(`Permanently delete folder \"${node.name}\" and ALL its messages? This cannot be undone.`)) return;\n try {\n await deleteFolder(node.accountId, node.id);\n const treeContainer = document.getElementById(\"folder-tree\");\n if (treeContainer) loadFolderTree(treeContainer);\n } catch (err: any) { alert(`Failed: ${err.message}`); }\n }, disabled: !!node.specialUse,\n tooltip: \"Skips Trash. Same as Shift+Delete on a regular file. No undo.\" },\n { label: \"\", action: () => {}, separator: true },\n // Q57: copy IMAP path so user can paste into accounts.jsonc as\n // a spam/sent/drafts/trash hint without retyping case-sensitively.\n { label: \"Copy folder path\", action: async () => {\n try {\n await navigator.clipboard.writeText(node.path);\n const status = document.getElementById(\"status-sync\");\n if (status) status.textContent = `Copied: ${node.path}`;\n } catch {\n prompt(\"Folder path:\", node.path);\n }\n }},\n ];\n\n if (isTrash || isJunk) {\n items.push({ label: \"\", action: () => {}, separator: true });\n items.push({ label: `Empty ${node.name}`, action: async () => {\n if (!confirm(`Permanently delete all messages in \"${node.name}\"?`)) return;\n try {\n await emptyFolder(node.accountId, node.id);\n const { setMessages } = await import(\"../lib/message-state.js\");\n setMessages([]); // Folder emptied \u2014 clear list and viewer\n const treeContainer = document.getElementById(\"folder-tree\");\n if (treeContainer) loadFolderTree(treeContainer);\n } catch (err: any) { alert(`Failed: ${err.message}`); }\n }});\n }\n\n showContextMenu(e.clientX, e.clientY, items);\n });\n\n // \u2500\u2500 Drop target for message drag-and-drop \u2500\u2500\n if (node.id !== -1) {\n let dragExpandTimer: ReturnType<typeof setTimeout> | null = null;\n folderEl.addEventListener(\"dragover\", (e) => {\n e.preventDefault();\n e.dataTransfer!.dropEffect = e.ctrlKey ? \"copy\" : \"move\";\n folderEl.classList.add(\"drop-target\");\n });\n folderEl.addEventListener(\"dragenter\", () => {\n if (hasChildren && !isExpanded && !dragExpandTimer) {\n dragExpandTimer = setTimeout(() => {\n dragExpandTimer = null;\n expandState[expandKey] = true;\n // In-drag expand is transient: don't persist it to\n // localStorage so closing the tree returns to user's\n // explicit collapsed state. saveExpandState() only runs\n // for explicit toggles below.\n dragAutoExpanded.add(expandKey);\n const treeContainer = document.getElementById(\"folder-tree\");\n if (treeContainer) loadFolderTree(treeContainer);\n }, DRAG_HOVER_EXPAND_MS);\n }\n });\n folderEl.addEventListener(\"dragleave\", () => {\n folderEl.classList.remove(\"drop-target\");\n if (dragExpandTimer) { clearTimeout(dragExpandTimer); dragExpandTimer = null; }\n });\n folderEl.addEventListener(\"drop\", async (e) => {\n e.preventDefault();\n folderEl.classList.remove(\"drop-target\");\n if (dragExpandTimer) { clearTimeout(dragExpandTimer); dragExpandTimer = null; }\n\n // Multi-message or single-message drop\n const multiData = e.dataTransfer!.getData(\"application/x-mailx-messages\");\n const singleData = e.dataTransfer!.getData(\"application/x-mailx-message\");\n const messages: { accountId: string; uid: number; folderId: number }[] =\n multiData ? JSON.parse(multiData) : singleData ? [JSON.parse(singleData)] : [];\n\n // Filter: not already in target folder\n const toMove = messages.filter(m => m.folderId !== node.id || m.accountId !== node.accountId);\n if (toMove.length === 0) return;\n\n const statusEl = document.getElementById(\"status-sync\");\n const crossAccount = toMove.some(m => m.accountId !== node.accountId);\n // Local-first: optimistic remove + Ctrl+Z entry FIRST, then\n // fire IPC without awaiting. Old order awaited the daemon\n // round-trip before removing rows; if simpleParser had the\n // event loop saturated the move \"stuck\" on the drop target\n // for the full mailxapi 120s timeout before the rows finally\n // disappeared. Service-side moveMessages is itself local-first\n // (sync DB commit + queued IMAP), so the IPC ack adds nothing.\n const moved = toMove.length;\n if (statusEl) statusEl.textContent = `Moved ${moved} message${moved > 1 ? \"s\" : \"\"} to ${node.name} \u2014 Ctrl+Z to undo`;\n const { removeMessagesAndReconcile } = await import(\"./message-list.js\");\n removeMessagesAndReconcile(toMove);\n const treeContainer = document.getElementById(\"folder-tree\");\n if (treeContainer) loadFolderTree(treeContainer);\n document.dispatchEvent(new CustomEvent(\"mailx-moved\", {\n detail: {\n messages: toMove.map(m => ({ accountId: m.accountId, uid: m.uid, sourceFolderId: m.folderId })),\n targetAccountId: node.accountId,\n targetFolderId: node.id,\n },\n }));\n // Fire the IPC(s); surface only real service-side failures.\n const onErr = (err: any) => {\n console.error(`Move failed: ${err?.message || err}`);\n if (statusEl) statusEl.textContent = `Move sync issue: ${err?.message || err}`;\n };\n if (crossAccount) {\n for (const msg of toMove) {\n const targetAccountId = msg.accountId !== node.accountId ? node.accountId : undefined;\n moveMessage(msg.accountId, msg.uid, node.id, targetAccountId).catch(onErr);\n }\n } else {\n const accountId = toMove[0].accountId;\n const uids = toMove.map(m => m.uid);\n moveMessages(accountId, uids, node.id).catch(onErr);\n }\n });\n }\n\n container.appendChild(folderEl);\n\n // Render children if expanded\n if (hasChildren && isExpanded) {\n for (const child of node.children) {\n renderNode(child, container, depth + 1);\n }\n }\n}\n\nexport function initFolderTree(container: HTMLElement, handler: FolderSelectHandler, unifiedHandler?: UnifiedHandler): void {\n onFolderSelect = handler;\n onUnifiedInbox = unifiedHandler || null;\n loadFolderTree(container);\n}\n\n// Spring-loaded folders (Q117): collapse any auto-expanded folders when the\n// drag ends, regardless of where it dropped. The user got the visual feedback\n// they needed; if the drop landed in a child folder the move already happened.\n// Restoring the prior state matches Outlook/Finder behavior.\ndocument.addEventListener(\"dragstart\", (e) => {\n const dt = e.dataTransfer;\n if (!dt) return;\n // Only track drags that carry a mailx message payload \u2014 ignore unrelated\n // drag interactions (image drag, text selection, etc.).\n const types = dt.types || [];\n if (!Array.from(types).some(t => t.startsWith(\"application/x-mailx-\"))) return;\n dragInFlight = true;\n});\ndocument.addEventListener(\"dragend\", () => {\n if (!dragInFlight) return;\n dragInFlight = false;\n if (dragAutoExpanded.size === 0) return;\n for (const key of dragAutoExpanded) delete expandState[key];\n dragAutoExpanded.clear();\n const treeContainer = document.getElementById(\"folder-tree\");\n if (treeContainer) loadFolderTree(treeContainer);\n});\n\n// Item 12: outbox total drives a synthesized \"Send-pending\" row at the top\n// of the folder tree. The server pushes outboxStatus on every mutation; when\n// the total flips between zero and non-zero we re-render so the row appears\n// / disappears without waiting for the next full refresh.\nlet lastOutboxTotal = 0;\nexport function setOutboxTotal(total: number): void {\n const prev = lastOutboxTotal;\n lastOutboxTotal = total | 0;\n // Zero \u2192 zero: nothing to render, nothing to clear.\n if (prev === 0 && lastOutboxTotal === 0) return;\n const existing = document.getElementById(\"ft-send-pending\") as HTMLElement;\n // Non-zero in both \u2192 just update the badge text; avoid a full re-render.\n if (prev > 0 && lastOutboxTotal > 0 && existing) {\n const badge = existing.querySelector<HTMLElement>(\".ft-badge\");\n if (badge) badge.textContent = String(lastOutboxTotal);\n existing.title = `${lastOutboxTotal} message${lastOutboxTotal === 1 ? \"\" : \"s\"} queued for send`;\n return;\n }\n // Presence flipped (0\u2192N or N\u21920) \u2014 re-render to insert / remove the row.\n const container = document.getElementById(\"folder-tree\");\n if (container) loadFolderTree(container);\n}\n\nasync function loadFolderTree(container: HTMLElement): Promise<void> {\n // Show loading state while preserving existing tree (if any) on refresh\n const hadContent = container.children.length > 0 && !container.querySelector(\".folder-loading\");\n if (!hadContent) {\n container.innerHTML = `<div class=\"folder-loading\">Loading accounts...</div>`;\n }\n\n try {\n const accounts = await getAccounts();\n // The instant getAccounts answers, the IPC bridge is alive. Dismiss\n // the startup overlay NOW \u2014 don't make the user\n // stare at a spinner while we go on to fetch every account's folder\n // list (which is the actual paint of the tree, but it doesn't\n // belong on top of a global modal). The tree's own\n // \".folder-loading\" inline state covers the residual fetch.\n const earlyOverlay = document.getElementById(\"startup-overlay\");\n if (earlyOverlay) { earlyOverlay.classList.add(\"hidden\"); setTimeout(() => earlyOverlay.remove(), 400); }\n // No polling. The daemon now serves IPC immediately with the local\n // accounts.jsonc cache (cloud refresh is fire-and-forget post-launch),\n // so the first getAccounts call returns the real list. If it returns\n // empty, that means truly no accounts are configured \u2014 show the\n // setup form (handled below). When the user adds an account or\n // accounts.jsonc changes from another device, the daemon emits\n // `configChanged` and the UI picks it up via refreshFolderTree.\n\n if (accounts.length === 0) {\n container.innerHTML = `<div class=\"folder-loading\">No accounts</div>`;\n // Hide the message list and show setup in the viewer pane (full width)\n const mlSection = document.querySelector(\".message-list\") as HTMLElement;\n if (mlSection) mlSection.style.display = \"none\";\n const splitter = document.getElementById(\"splitter-h\");\n if (splitter) splitter.style.display = \"none\";\n const mvHeader = document.getElementById(\"mv-header\");\n if (mvHeader) mvHeader.style.display = \"none\";\n const mainBody = document.getElementById(\"mv-body\");\n if (mainBody) {\n const isAndroid = (window as any).mailxapi?.platform === \"android\";\n const formDisplay = isAndroid ? \"display:none;\" : \"\";\n const introText = isAndroid ? \"\" : \"Add your email account to get started.\";\n const checkingHtml = isAndroid ? '<div style=\"padding:0.5rem;color:var(--color-text-muted)\">Checking for accounts...</div>' : \"\";\n const gmailNote = isAndroid ? \"\" : `<p style=\"margin-top:0.5rem;padding:0.5rem 0.75rem;background:color-mix(in oklch, var(--color-accent) 12%, var(--color-bg-surface));border:1px solid var(--color-border);border-radius:4px;font-size:0.9rem\">For now, a <strong>Gmail</strong> or Google Workspace account is required. Support for other providers is coming.</p>`;\n mainBody.innerHTML = `<div style=\"padding:2rem;line-height:1.8;max-width:500px\">\n <h2 style=\"margin-bottom:1rem\">Welcome to mailx</h2>\n <div id=\"setup-device-accounts\">${checkingHtml}</div>\n <div id=\"setup-cloud-status\"></div>\n <p id=\"setup-form-intro\">${introText}</p>\n ${gmailNote}\n <form id=\"setup-form\" style=\"margin-top:1rem;${formDisplay}\">\n <label style=\"display:block;margin-bottom:0.5rem\">\n Email address\n <input id=\"setup-email\" type=\"email\" placeholder=\"you@gmail.com\" required style=\"display:block;width:100%;padding:0.5rem;margin-top:0.25rem;background:var(--color-bg-surface);color:var(--color-text);border:1px solid var(--color-border);border-radius:4px\">\n </label>\n <div id=\"setup-provider-preview\" style=\"display:none;margin-bottom:0.5rem;padding:0.4rem 0.6rem;background:var(--color-bg-surface);border:1px solid var(--color-border);border-radius:4px;font-size:0.9rem\">\n <span id=\"setup-provider-icon\" style=\"display:inline-block;width:1.2em;text-align:center;margin-right:0.4em\"></span><span id=\"setup-provider-label\"></span>\n </div>\n <label id=\"setup-name-row\" style=\"display:none;margin-bottom:0.5rem\">\n Your name <span style=\"color:var(--color-text-muted);font-size:0.85rem\">(optional \u2014 auto-detected from Google)</span>\n <input id=\"setup-name\" type=\"text\" placeholder=\"Your Name (leave blank to use Google profile)\" style=\"display:block;width:100%;padding:0.5rem;margin-top:0.25rem;background:var(--color-bg-surface);color:var(--color-text);border:1px solid var(--color-border);border-radius:4px\">\n </label>\n <label id=\"setup-password-row\" style=\"display:none;margin-bottom:0.5rem\">\n Password\n <input id=\"setup-password\" type=\"password\" placeholder=\"password\" style=\"display:block;width:100%;padding:0.5rem;margin-top:0.25rem;background:var(--color-bg-surface);color:var(--color-text);border:1px solid var(--color-border);border-radius:4px\">\n <div id=\"setup-app-password-help\" style=\"display:none;margin-top:0.25rem;font-size:0.8rem;color:var(--color-text-muted)\"></div>\n </label>\n <button id=\"setup-submit\" type=\"submit\" style=\"display:none;margin-top:1rem;padding:0.5rem 2rem;background:var(--color-accent);color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:1rem\">Add Account</button>\n <div id=\"setup-status\" style=\"margin-top:1rem;color:var(--color-text-muted)\"></div>\n </form>\n <details style=\"margin-top:2rem;color:var(--color-text-muted)\">\n <summary>Manual setup (advanced)</summary>\n <p style=\"margin-top:0.5rem\">Create <code>~/.rmfmail/config.jsonc</code> with a cloud provider:</p>\n <code style=\"display:block;padding:0.75rem;background:var(--color-bg-surface);border:1px solid var(--color-border);border-radius:4px;margin:0.5rem 0;white-space:pre;font-size:0.85rem\">{ \"sharedDir\": { \"provider\": \"gdrive\", \"path\": \"rmfmail\" } }</code>\n <p style=\"margin-top:0.5rem;font-size:0.85rem\">Settings sync via Google Drive API (auto-configured for Gmail accounts).</p>\n </details>\n </div>`;\n // Wire up the setup form\n const form = document.getElementById(\"setup-form\") as HTMLFormElement;\n const emailInput = document.getElementById(\"setup-email\") as HTMLInputElement;\n const statusEl = document.getElementById(\"setup-status\")!;\n // Hide password for OAuth providers, show app-password help for others\n const APP_PASSWORD_HELP: Record<string, string> = {\n \"yahoo.com\": \"Use an app password: Yahoo Settings \u2192 Account Security \u2192 Generate app password\",\n \"aol.com\": \"Use an app password: AOL Settings \u2192 Account Security \u2192 Generate app password\",\n \"icloud.com\": \"Use an app-specific password: appleid.apple.com \u2192 Sign-In and Security \u2192 App-Specific Passwords\",\n };\n // Q67: describe the detected provider so the user knows which\n // auto-config path we're about to take BEFORE they hit Next.\n // Gmail / Google Workspace domains auto-detect via MX in the\n // service; here we can only name the known ones up front and\n // say \"will auto-detect\" for everything else.\n const PROVIDER_PREVIEW: Record<string, { icon: string; label: string }> = {\n \"gmail.com\": { icon: \"\u2709\", label: \"Gmail \u2014 OAuth (no password needed)\" },\n \"googlemail.com\": { icon: \"\u2709\", label: \"Gmail \u2014 OAuth (no password needed)\" },\n \"outlook.com\": { icon: \"\u2709\", label: \"Outlook.com \u2014 OAuth (no password needed)\" },\n \"hotmail.com\": { icon: \"\u2709\", label: \"Outlook.com \u2014 OAuth (no password needed)\" },\n \"live.com\": { icon: \"\u2709\", label: \"Outlook.com \u2014 OAuth (no password needed)\" },\n \"yahoo.com\": { icon: \"\u2709\", label: \"Yahoo Mail \u2014 IMAP (needs app password)\" },\n \"aol.com\": { icon: \"\u2709\", label: \"AOL Mail \u2014 IMAP (needs app password)\" },\n \"icloud.com\": { icon: \"\u2709\", label: \"iCloud Mail \u2014 IMAP (needs app-specific password)\" },\n \"me.com\": { icon: \"\u2709\", label: \"iCloud Mail \u2014 IMAP (needs app-specific password)\" },\n \"mac.com\": { icon: \"\u2709\", label: \"iCloud Mail \u2014 IMAP (needs app-specific password)\" },\n };\n let oauthAutoFired = false;\n emailInput?.addEventListener(\"input\", () => {\n const email = emailInput.value.trim();\n const domain = email.split(\"@\")[1]?.toLowerCase() || \"\";\n const hasAt = email.includes(\"@\") && domain.length > 0;\n const isOAuth = [\"gmail.com\", \"googlemail.com\", \"outlook.com\", \"hotmail.com\", \"live.com\"].includes(domain);\n const isGmailLike = [\"gmail.com\", \"googlemail.com\"].includes(domain);\n // Provider preview row\n const preview = document.getElementById(\"setup-provider-preview\");\n const icon = document.getElementById(\"setup-provider-icon\");\n const label = document.getElementById(\"setup-provider-label\");\n if (preview && icon && label) {\n if (hasAt) {\n const hit = PROVIDER_PREVIEW[domain];\n icon.textContent = hit ? hit.icon : \"\u2753\";\n label.textContent = hit ? hit.label : `${domain} \u2014 will auto-detect via MX records`;\n preview.style.display = \"block\";\n } else {\n preview.style.display = \"none\";\n }\n }\n // OAuth providers: auto-fire setup immediately once domain\n // is recognized \u2014 don't show name/password (name is auto-\n // detected from Google profile, no password needed). This\n // eliminates the \"form flash\" where fields briefly appear\n // before the page reloads.\n if (hasAt && isOAuth && !oauthAutoFired && !setupTriggered) {\n oauthAutoFired = true;\n statusEl.textContent = `Connecting to ${isGmailLike ? \"Gmail\" : \"Outlook\"}...`;\n trySetup();\n return;\n }\n // Non-OAuth: progressive reveal of name + password + submit.\n const nameRow = document.getElementById(\"setup-name-row\");\n const pwRow = document.getElementById(\"setup-password-row\");\n const submitBtn = document.getElementById(\"setup-submit\");\n if (nameRow) nameRow.style.display = hasAt && !isOAuth ? \"block\" : \"none\";\n if (pwRow) pwRow.style.display = hasAt && !isOAuth ? \"block\" : \"none\";\n if (submitBtn) submitBtn.style.display = hasAt && !isOAuth ? \"block\" : \"none\";\n const helpEl = document.getElementById(\"setup-app-password-help\");\n if (helpEl) {\n const help = APP_PASSWORD_HELP[domain];\n helpEl.style.display = help ? \"block\" : \"none\";\n helpEl.textContent = help || \"\";\n }\n });\n // When a valid email is entered, try setup immediately \u2014 if cloud has\n // existing accounts, loads them without needing name/password\n let setupTriggered = false;\n async function trySetup(): Promise<void> {\n const email = emailInput.value.trim();\n if (!email || !email.includes(\"@\") || setupTriggered) return;\n const name = (document.getElementById(\"setup-name\") as HTMLInputElement).value.trim();\n const password = (document.getElementById(\"setup-password\") as HTMLInputElement).value;\n setupTriggered = true;\n statusEl.textContent = \"Checking for existing accounts...\";\n try {\n const data = await setupAccount(name, email, password);\n if (data.ok) {\n // Reload immediately. The status text was a\n // dwell-time band-aid around the user's\n // perception that setup \"didn't complete\";\n // the real fix is for setup itself to be\n // fast enough that the result is visible\n // through the new render. setupAccount's own\n // 20 s+ of OAuth/GDrive work has already\n // happened by the time we get here \u2014 adding\n // 7 s of \"look at this status\" on top is\n // pure latency.\n location.reload();\n } else {\n setupTriggered = false;\n statusEl.style.color = \"#f55\";\n statusEl.textContent = `Error: ${data.error || \"Setup failed\"}`;\n }\n } catch (err: any) {\n setupTriggered = false;\n statusEl.style.color = \"#f55\";\n statusEl.textContent = `Error: ${err.message}`;\n }\n }\n form?.addEventListener(\"submit\", async (e) => {\n e.preventDefault();\n await trySetup();\n });\n // Auto-trigger for OAuth providers when email looks complete\n emailInput?.addEventListener(\"change\", async () => {\n const domain = emailInput.value.split(\"@\")[1]?.toLowerCase() || \"\";\n const isOAuth = [\"gmail.com\", \"googlemail.com\", \"outlook.com\", \"hotmail.com\", \"live.com\"].includes(domain);\n if (isOAuth) await trySetup();\n });\n // Show cloud storage status in setup form\n getVersion().then((d: any) => {\n const cloudEl = document.getElementById(\"setup-cloud-status\");\n if (!cloudEl) return;\n const s = d.storage || {};\n if (s.cloudError) {\n cloudEl.innerHTML = `<div style=\"padding:0.75rem;margin-bottom:1rem;background:#5c1a1a;color:#fca;border:1px solid #a33;border-radius:4px\">\n <strong>Cloud storage unavailable:</strong> ${s.cloudError}<br>\n <span style=\"font-size:0.85rem\">Settings on ${s.provider || \"cloud\"} cannot be read. Add an account below to initialize cloud storage.</span>\n </div>`;\n } else if (s.mode === \"api\") {\n cloudEl.innerHTML = `<div style=\"padding:0.5rem;margin-bottom:1rem;background:var(--color-bg-surface);border:1px solid var(--color-border);border-radius:4px;color:var(--color-text-muted)\">\n Using ${s.provider} API (no local mount)\n </div>`;\n }\n }).catch(() => {});\n // On Android, check for device Google accounts\n getDeviceAccounts().then(async (deviceAccounts) => {\n const pickerEl = document.getElementById(\"setup-device-accounts\");\n if (!pickerEl) return;\n if (deviceAccounts.length === 0) {\n // No device accounts \u2014 show the form\n pickerEl.innerHTML = \"\";\n const f = document.getElementById(\"setup-form\") as HTMLElement;\n const i = document.getElementById(\"setup-form-intro\") as HTMLElement;\n if (f) f.style.display = \"block\";\n if (i) i.textContent = \"Add your email account to get started.\";\n return;\n }\n const formEl = document.getElementById(\"setup-form\") as HTMLElement;\n const introEl = document.getElementById(\"setup-form-intro\") as HTMLElement;\n\n // Auto-setup helper\n async function autoSetup(email: string, name: string): Promise<void> {\n if (introEl) introEl.textContent = \"\";\n if (formEl) formEl.style.display = \"none\";\n pickerEl.innerHTML = `<div style=\"padding:0.5rem;color:var(--color-text-muted)\">Setting up ${email}...</div>`;\n const result = await setupAccount(name, email, \"\");\n if (result?.ok) {\n // Reload immediately \u2014 the dwell-time was a\n // perception band-aid, same removal as the\n // explicit setup form's reload above.\n location.reload();\n } else {\n pickerEl.innerHTML = `<div style=\"padding:0.5rem;color:#f55\">${result?.error || \"Setup failed\"}</div>`;\n if (formEl) formEl.style.display = \"block\";\n }\n }\n\n // One account \u2014 auto-select it\n if (deviceAccounts.length === 1) {\n await autoSetup(deviceAccounts[0].email, deviceAccounts[0].name);\n return;\n }\n\n // Multiple accounts \u2014 show picker\n if (introEl) introEl.textContent = \"Select an account:\";\n if (formEl) formEl.style.display = \"none\";\n pickerEl.innerHTML = deviceAccounts.map((a: { email: string; name: string }) =>\n `<button class=\"device-account-btn\" data-email=\"${a.email}\" data-name=\"${a.name}\" style=\"display:block;width:100%;padding:0.75rem 1rem;margin-bottom:0.5rem;background:var(--color-accent);color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:1rem;text-align:left\">Use ${a.email}</button>`\n ).join(\"\") + `<button id=\"setup-show-form\" style=\"margin-top:0.5rem;padding:0.5rem 1rem;background:none;color:var(--color-text-muted);border:none;cursor:pointer;font-size:0.9rem\">Use a different account...</button>`;\n pickerEl.querySelectorAll(\".device-account-btn\").forEach((btn: Element) => {\n btn.addEventListener(\"click\", async () => {\n await autoSetup((btn as HTMLElement).dataset.email || \"\", (btn as HTMLElement).dataset.name || \"\");\n });\n });\n document.getElementById(\"setup-show-form\")?.addEventListener(\"click\", () => {\n pickerEl.style.display = \"none\";\n if (formEl) formEl.style.display = \"block\";\n if (introEl) introEl.textContent = \"Add your email account to get started.\";\n });\n }).catch(() => {\n // Bridge failed \u2014 show the form\n const p = document.getElementById(\"setup-device-accounts\");\n if (p) p.innerHTML = \"\";\n const f = document.getElementById(\"setup-form\") as HTMLElement;\n const i = document.getElementById(\"setup-form-intro\") as HTMLElement;\n if (f) f.style.display = \"block\";\n if (i) i.textContent = \"Add your email account to get started.\";\n });\n }\n // Dismiss startup overlay\n const overlay = document.getElementById(\"startup-overlay\");\n if (overlay) { overlay.classList.add(\"hidden\"); setTimeout(() => overlay.remove(), 400); }\n return;\n }\n\n // Fetch ALL account folder data in parallel BEFORE touching the DOM\n const accountFolderData: { account: any; folders: any[] }[] = await Promise.all(\n accounts.map(async (account: any) => {\n const accountKey = `account:${account.id}`;\n const accountExpanded = expandState[accountKey] !== false;\n const folders = accountExpanded ? await getFolders(account.id) : [];\n return { account, folders };\n })\n );\n\n // Save scroll position before rebuild\n const savedScroll = container.scrollTop;\n\n // Build entire new tree into a DocumentFragment (off-screen, no reflows)\n const fragment = document.createDocumentFragment();\n\n // Unified Inbox \u2014 always shown so startup auto-selects it consistently\n // (with one account it's effectively that account's INBOX, but the UI\n // stays uniform so the auto-select path doesn't fork on account count)\n if (accounts.length >= 1) {\n const unifiedEl = document.createElement(\"div\");\n unifiedEl.className = \"ft-folder ft-unified\";\n unifiedEl.title = accounts.length > 1\n ? \"Merged inbox view of all accounts \u2014 click to see messages from every account's INBOX sorted by date\"\n : \"Inbox view across all your accounts\";\n unifiedEl.innerHTML = `<span class=\"ft-toggle\"> </span><span class=\"ft-folder-name\">All Inboxes</span>`;\n unifiedEl.addEventListener(\"click\", () => {\n if (selectedElement) selectedElement.classList.remove(\"selected\");\n unifiedEl.classList.add(\"selected\");\n selectedElement = unifiedEl;\n selectedAccountId = null;\n selectedFolderId = -1;\n if (onUnifiedInbox) onUnifiedInbox();\n });\n fragment.appendChild(unifiedEl);\n }\n\n // Item 12: Send-pending virtual row \u2014 synthesized from the outbox\n // queue, only shown when something is actually queued. Clicking\n // opens the outbox-view modal (pink rows, cancellable). Lives at\n // the top of the tree so a stuck send is impossible to miss.\n if (lastOutboxTotal > 0) {\n const pendingEl = document.createElement(\"div\");\n pendingEl.className = \"ft-folder ft-unified ft-send-pending\";\n pendingEl.id = \"ft-send-pending\";\n pendingEl.title = `${lastOutboxTotal} message${lastOutboxTotal === 1 ? \"\" : \"s\"} queued for send`;\n pendingEl.innerHTML = `<span class=\"ft-toggle\"> </span><span class=\"ft-folder-name\">Send-pending</span><span class=\"ft-badge ft-badge-outbox\">${lastOutboxTotal}</span>`;\n pendingEl.addEventListener(\"click\", async () => {\n try {\n const { openOutboxView } = await import(\"./outbox-view.js\");\n openOutboxView();\n } catch { /* outbox-view load failed \u2014 silent is OK, status pill still works */ }\n });\n fragment.appendChild(pendingEl);\n }\n\n // When two accounts share the same display name (e.g. both `bobma`\n // and `gmail` set name=\"Bob Frankston\"), the folder tree previously\n // showed two identical \"Bob Frankston\" headers and the user had no\n // way to tell which was which. Detect collisions on the displayed\n // label and append the email (or account id) to the duplicates so\n // each header is unique.\n const labelOf = (a: any): string => a.label || a.name || a.id;\n const labelCounts = new Map<string, number>();\n for (const { account } of accountFolderData) {\n const l = labelOf(account);\n labelCounts.set(l, (labelCounts.get(l) || 0) + 1);\n }\n for (const { account, folders } of accountFolderData) {\n const accountEl = document.createElement(\"div\");\n accountEl.className = \"ft-account\";\n\n const accountKey = `account:${account.id}`;\n const accountExpanded = expandState[accountKey] !== false; // accounts default expanded\n\n const header = document.createElement(\"div\");\n header.className = \"ft-account-header\";\n const baseLabel = labelOf(account);\n const isDup = (labelCounts.get(baseLabel) || 0) > 1;\n const disambiguator = isDup ? ` (${(account as any).email || account.id})` : \"\";\n header.textContent = `${accountExpanded ? \"\u25BE\" : \"\u25B8\"} ${baseLabel}${disambiguator}`;\n header.addEventListener(\"click\", () => {\n expandState[accountKey] = !accountExpanded;\n saveExpandState();\n const treeContainer = document.getElementById(\"folder-tree\");\n if (treeContainer) loadFolderTree(treeContainer);\n });\n\n // Right-click: full menu instead of plain toggle (Q54).\n header.addEventListener(\"contextmenu\", (e) => {\n e.preventDefault();\n e.stopPropagation();\n const items: MenuItem[] = [\n { label: \"Mark all read (account)\", action: async () => {\n const folderRows = folders.slice();\n for (const f of folderRows) {\n try { await markFolderRead(account.id, f.id); } catch { /* keep going */ }\n }\n const tc = document.getElementById(\"folder-tree\");\n if (tc) loadFolderTree(tc);\n }},\n { label: \"\", action: () => {}, separator: true },\n { label: \"Expand all folders\", action: () => {\n const keys = Object.keys(expandState).filter(k => k.startsWith(`${account.id}:`));\n for (const k of keys) expandState[k] = true;\n expandState[accountKey] = true;\n saveExpandState();\n const tc = document.getElementById(\"folder-tree\");\n if (tc) loadFolderTree(tc);\n }},\n { label: \"Collapse all folders\", action: () => {\n const keys = Object.keys(expandState).filter(k => k.startsWith(`${account.id}:`));\n for (const k of keys) expandState[k] = false;\n expandState[accountKey] = false;\n saveExpandState();\n const tc = document.getElementById(\"folder-tree\");\n if (tc) loadFolderTree(tc);\n }},\n { label: \"\", action: () => {}, separator: true },\n { label: \"Sync this account now\", action: async () => {\n try { await syncAccount(account.id); } catch (err: any) { alert(`Sync failed: ${err?.message || err}`); }\n }},\n ];\n showContextMenu(e.clientX, e.clientY, items);\n });\n\n accountEl.appendChild(header);\n\n if (accountExpanded && folders.length > 0) {\n const delimiter = folders[0]?.delimiter || \".\";\n const tree = buildTree(folders, delimiter, account.id);\n sortFolders(tree);\n\n // Case-duplicate detection: fold folder paths to lowercase and\n // flag any whose form matches another. Common with servers that\n // let users create `Archive` and `archive` as distinct folders,\n // or `Sent Items` alongside a `Sent items` rename gone sideways.\n // A \u26A0 glyph on the affected rows lets the user notice before\n // losing mail to the wrong one.\n const lowerCounts = new Map<string, number>();\n for (const f of folders) {\n const key = (f.path || \"\").toLowerCase();\n lowerCounts.set(key, (lowerCounts.get(key) || 0) + 1);\n }\n const duplicatePaths = new Set<string>();\n for (const [k, c] of lowerCounts) if (c > 1) duplicatePaths.add(k);\n\n for (const node of tree) {\n renderNode(node, accountEl, 1);\n }\n\n if (duplicatePaths.size > 0) {\n accountEl.querySelectorAll<HTMLElement>(\".ft-folder\").forEach(el => {\n const p = (el.dataset.folderPath || \"\").toLowerCase();\n if (duplicatePaths.has(p)) {\n el.classList.add(\"ft-folder-duplicate\");\n el.title = (el.title ? el.title + \" \u2014 \" : \"\") +\n \"Case-duplicate folder name on the server (another folder with the same name in different case exists)\";\n }\n });\n }\n }\n\n fragment.appendChild(accountEl);\n }\n\n // Atomic swap \u2014 single reflow, no intermediate empty state\n container.replaceChildren(fragment);\n\n // Restore scroll position\n container.scrollTop = savedScroll;\n\n // Re-select previous folder, or auto-select on first load\n const allFolderEls = container.querySelectorAll('.ft-folder');\n let target: HTMLElement | null = null;\n\n if (selectedFolderId === -1) {\n // Unified inbox was selected \u2014 just re-highlight it, don't click\n const unified = container.querySelector('.ft-unified') as HTMLElement;\n if (unified) {\n unified.classList.add(\"selected\");\n selectedElement = unified;\n target = unified;\n }\n } else if (selectedAccountId && selectedFolderId !== null && selectedFolderId >= 0) {\n for (const f of allFolderEls) {\n const el = f as HTMLElement;\n if (el.dataset.accountId === selectedAccountId && el.dataset.folderId === String(selectedFolderId)) {\n el.classList.add(\"selected\");\n selectedElement = el;\n target = el;\n break;\n }\n }\n }\n\n // Auto-select on first load OR until we successfully auto-selected at least once\n // (handles Android where folders don't exist on first load \u2014 they arrive after sync)\n if (!target && (isFirstLoad || !hasAutoSelected)) {\n // Auto-select only on first load \u2014 not on refresh (prevents jumping)\n const unified = container.querySelector('.ft-unified') as HTMLElement;\n if (unified) {\n target = unified;\n } else {\n let bestInbox: HTMLElement | null = null;\n let bestCount = -1;\n for (const f of allFolderEls) {\n const name = f.querySelector('.ft-folder-name')?.textContent ?? \"\";\n if (name.toLowerCase() === \"inbox\") {\n const badge = f.querySelector('.ft-badge');\n const count = badge ? parseInt(badge.textContent || \"0\") : 0;\n if (count > bestCount) {\n bestCount = count;\n bestInbox = f as HTMLElement;\n }\n }\n }\n target = bestInbox;\n }\n if (!target && allFolderEls.length > 0) target = allFolderEls[0] as HTMLElement;\n if (target) {\n target.click();\n hasAutoSelected = true;\n }\n }\n isFirstLoad = false;\n // Dismiss startup overlay once tree is loaded\n const overlay = document.getElementById(\"startup-overlay\");\n if (overlay) overlay.classList.add(\"hidden\");\n // Remove from DOM after transition\n setTimeout(() => overlay?.remove(), 400);\n\n } catch (e: any) {\n // Don't destroy existing folder tree on error \u2014 just log it\n console.error(`Folder tree error: ${e.message}`);\n // Only show error if tree is completely empty (first load failure)\n if (container.children.length === 0 || container.querySelector(\".folder-loading\")) {\n const errEl = document.createElement(\"div\");\n errEl.className = \"folder-loading\";\n errEl.textContent = `Error loading folders: ${e.message}`;\n container.replaceChildren(errEl);\n }\n // Dismiss overlay on error too\n const overlay = document.getElementById(\"startup-overlay\");\n if (overlay) {\n const status = document.getElementById(\"startup-status\");\n if (status) status.textContent = `Error: ${e.message}`;\n setTimeout(() => { overlay.classList.add(\"hidden\"); setTimeout(() => overlay.remove(), 400); }, 2000);\n }\n }\n}\n\n/** Refresh folder tree (e.g., after sync) \u2014 debounced to prevent rapid rebuilds */\nexport function refreshFolderTree(): void {\n if (refreshDebounceTimer) clearTimeout(refreshDebounceTimer);\n refreshDebounceTimer = setTimeout(() => {\n refreshDebounceTimer = null;\n const container = document.getElementById(\"folder-tree\");\n if (container) loadFolderTree(container);\n }, 300);\n}\n\n/**\n * Incremental count update \u2014 patches badge counts in-place without rebuilding the DOM.\n * Used for folderCountsChanged events to avoid jitter. Falls back to full rebuild\n * if the folder structure has changed.\n */\nexport async function updateFolderCounts(): Promise<void> {\n const container = document.getElementById(\"folder-tree\");\n if (!container) return;\n\n // If tree hasn't loaded yet, do a full load\n if (container.children.length === 0 || container.querySelector(\".folder-loading\")) {\n refreshFolderTree();\n return;\n }\n\n try {\n const accounts = await getAccounts();\n\n // Fetch all folder data in parallel\n const allFolderData = await Promise.all(\n accounts.map(async (account: any) => {\n const folders = await getFolders(account.id);\n return { accountId: account.id, folders };\n })\n );\n\n // Build a lookup: accountId+folderId \u2192 { unreadCount, totalCount }\n // Also rebuild trees to get aggregated counts\n const countMap = new Map<string, { unread: number; total: number }>();\n for (const { accountId, folders } of allFolderData) {\n const delimiter = folders[0]?.delimiter || \".\";\n const tree = buildTree(folders, delimiter, accountId);\n // Walk the tree and collect counts (buildTree already aggregates)\n function collectCounts(nodes: FolderNode[]): void {\n for (const n of nodes) {\n countMap.set(`${n.accountId}:${n.id}`, { unread: n.unreadCount, total: n.totalCount });\n collectCounts(n.children);\n }\n }\n collectCounts(tree);\n }\n\n // Patch existing DOM elements in-place\n const folderEls = container.querySelectorAll(\".ft-folder[data-account-id][data-folder-id]\");\n let structureChanged = false;\n\n for (const el of folderEls) {\n const htmlEl = el as HTMLElement;\n const key = `${htmlEl.dataset.accountId}:${htmlEl.dataset.folderId}`;\n const counts = countMap.get(key);\n if (!counts) continue; // folder not found \u2014 structure may have changed\n\n // Update unread badge\n const isOutbox = htmlEl.dataset.specialUse === \"outbox\" || htmlEl.dataset.folderPath?.toLowerCase() === \"outbox\";\n let badge = htmlEl.querySelector(\".ft-badge\") as HTMLElement;\n const outboxBadge = htmlEl.querySelector(\".ft-badge-outbox\") as HTMLElement;\n\n if (isOutbox) {\n if (counts.total > 0) {\n if (outboxBadge) {\n outboxBadge.textContent = String(counts.total);\n } else {\n const b = document.createElement(\"span\");\n b.className = \"ft-badge ft-badge-outbox\";\n b.textContent = String(counts.total);\n htmlEl.querySelector(\".ft-folder-name\")?.after(b);\n }\n } else if (outboxBadge) {\n outboxBadge.remove();\n }\n } else {\n if (counts.unread > 0) {\n if (badge) {\n badge.textContent = String(counts.unread);\n } else {\n const b = document.createElement(\"span\");\n b.className = \"ft-badge\";\n b.textContent = String(counts.unread);\n htmlEl.querySelector(\".ft-folder-name\")?.after(b);\n }\n } else if (badge && !badge.classList.contains(\"ft-badge-outbox\")) {\n badge.remove();\n }\n }\n\n // Update total count\n let totalEl = htmlEl.querySelector(\".ft-total-count\") as HTMLElement;\n if (counts.total > 0) {\n if (totalEl) {\n totalEl.textContent = String(counts.total);\n } else {\n const t = document.createElement(\"span\");\n t.className = \"ft-total-count\";\n t.textContent = String(counts.total);\n htmlEl.appendChild(t);\n }\n } else if (totalEl) {\n totalEl.remove();\n }\n }\n\n // Check if folder count changed (new folders added or removed)\n const existingCount = folderEls.length;\n let serverCount = 0;\n for (const { folders } of allFolderData) serverCount += folders.length;\n if (Math.abs(existingCount - serverCount) > 2) {\n // Structure changed significantly \u2014 do a full rebuild\n refreshFolderTree();\n }\n } catch {\n // If count update fails, fall back to full rebuild\n refreshFolderTree();\n }\n}\n", "/** View tabs \u2014 multiple views over one mailbox / one backend.\n *\n * See docs/multi-view.md. A tab is a self-contained *view descriptor*: which\n * folder / unified-inbox / search it shows. The expensive things (DB, the\n * live sync stream, folder counts, contacts) stay shared in the daemon and\n * the one IPC channel \u2014 a tab is just the cheap \"which folder, restored how\"\n * bundle. That bundle is also exactly what a future tear-off window needs,\n * so this module is the seam for Stage 3.\n *\n * Stage 1: snapshot/restore. The DOM has ONE three-pane; switching tabs\n * re-invokes the existing load functions for the target tab's view, and the\n * list's own `positionMemory` restores selection + scroll per view. Inactive\n * tabs hold only their descriptor \u2014 no detached DOM, no second list state.\n */\n\nexport type TabView =\n | { kind: \"unified\" }\n | { kind: \"folder\"; accountId: string; folderId: number; specialUse: string }\n | { kind: \"search\"; query: string; scope: string; accountId: string; folderId: number; includeTrash: boolean };\n\nexport interface ViewTab {\n id: string;\n title: string;\n view: TabView;\n}\n\nconst STORAGE_KEY = \"mailx-view-tabs\";\nlet tabs: ViewTab[] = [];\nlet activeId = \"\";\nlet stripEl: HTMLElement | null = null;\n/** Applies a tab's view to the single three-pane. Set by initTabs; defined in\n * app.ts because it must touch app-level view state + the load functions. */\nlet applyView: (tab: ViewTab) => void = () => { /* */ };\nlet _nextId = 1;\nfunction newId(): string { return `t${_nextId++}`; }\n\nfunction persist(): void {\n try {\n sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ tabs, activeId }));\n } catch { /* sessionStorage unavailable \u2014 tabs just won't survive reload */ }\n}\n\nfunction render(): void {\n if (!stripEl) return;\n stripEl.innerHTML = \"\";\n for (const tab of tabs) {\n const chip = document.createElement(\"div\");\n chip.className = \"view-tab\" + (tab.id === activeId ? \" active\" : \"\");\n chip.dataset.tabId = tab.id;\n const label = document.createElement(\"span\");\n label.className = \"view-tab-label\";\n label.textContent = tab.title || \"(view)\";\n chip.appendChild(label);\n // Close affordance \u2014 hidden when only one tab remains (can't close\n // the last one; the window always shows something).\n if (tabs.length > 1) {\n const close = document.createElement(\"button\");\n close.className = \"view-tab-close\";\n close.textContent = \"\u00D7\";\n close.title = \"Close tab\";\n close.addEventListener(\"click\", (e) => { e.stopPropagation(); closeTab(tab.id); });\n chip.appendChild(close);\n }\n chip.addEventListener(\"click\", () => activate(tab.id));\n stripEl.appendChild(chip);\n }\n const plus = document.createElement(\"button\");\n plus.className = \"view-tab-new\";\n plus.textContent = \"+\";\n plus.title = \"New tab (Ctrl+T)\";\n plus.addEventListener(\"click\", () => openTab({ kind: \"unified\" }, \"All Inboxes\", true));\n stripEl.appendChild(plus);\n // Strip stays visible even with a single tab so the \"+\" button is a\n // discoverable affordance for opening another (Bob 2026-05-18: with the\n // strip hidden there was no hint that Ctrl+T creates a tab). Hidden only\n // before the first tab exists \u2014 nothing to show yet.\n stripEl.hidden = tabs.length < 1;\n}\n\n/** Wire the strip. `apply` is the app.ts callback that loads a tab's view. */\nexport function initTabs(strip: HTMLElement, apply: (tab: ViewTab) => void): void {\n stripEl = strip;\n applyView = apply;\n try {\n const raw = sessionStorage.getItem(STORAGE_KEY);\n if (raw) {\n const parsed = JSON.parse(raw) as { tabs: ViewTab[]; activeId: string };\n if (Array.isArray(parsed?.tabs) && parsed.tabs.length) {\n tabs = parsed.tabs;\n activeId = parsed.activeId || tabs[0].id;\n for (const t of tabs) {\n const n = Number(String(t.id).replace(/^t/, \"\"));\n if (Number.isFinite(n) && n >= _nextId) _nextId = n + 1;\n }\n }\n }\n } catch { /* corrupt \u2014 start fresh */ }\n render();\n}\n\n/** The currently-active tab, or null before any tab exists. */\nexport function activeTab(): ViewTab | null {\n return tabs.find(t => t.id === activeId) || null;\n}\n\n/** Open a new tab for `view` and (by default) switch to it. */\nexport function openTab(view: TabView, title: string, activateIt = true): void {\n const tab: ViewTab = { id: newId(), title, view };\n tabs.push(tab);\n if (activateIt) activeId = tab.id;\n persist();\n render();\n if (activateIt) applyView(tab);\n}\n\n/** Switch to an existing tab and load its view. */\nexport function activate(id: string): void {\n const tab = tabs.find(t => t.id === id);\n if (!tab || id === activeId) return;\n activeId = id;\n persist();\n render();\n applyView(tab);\n}\n\n/** Close a tab. Never closes the last one. If the active tab is closed, the\n * neighbour to its left (or right) becomes active. */\nexport function closeTab(id: string): void {\n if (tabs.length < 2) return;\n const idx = tabs.findIndex(t => t.id === id);\n if (idx < 0) return;\n const wasActive = id === activeId;\n tabs.splice(idx, 1);\n if (wasActive) {\n const next = tabs[Math.max(0, idx - 1)];\n activeId = next.id;\n persist();\n render();\n applyView(next);\n } else {\n persist();\n render();\n }\n}\n\n/** Record the view the user just navigated to in the CURRENT tab \u2014 called\n * from the folder-tree / search handlers. Does NOT re-apply the view (the\n * navigation already loaded it); only keeps the active tab's descriptor +\n * title in sync so a later tab-switch restores the right thing. If no tab\n * exists yet (first navigation after boot), this creates the first tab \u2014\n * so the very first folder/inbox selection seeds the strip. */\nexport function setActiveView(view: TabView, title: string): void {\n let tab = activeTab();\n if (!tab) {\n tab = { id: newId(), title, view };\n tabs.push(tab);\n activeId = tab.id;\n } else {\n tab.view = view;\n tab.title = title;\n }\n persist();\n render();\n}\n", "/**\n * mailx client entry point.\n * Wires together all UI components and WebSocket connection.\n */\n\nimport { initFolderTree, refreshFolderTree, updateFolderCounts, setFolderSynced, getFolderSynced, setOutboxTotal } from \"./components/folder-tree.js\";\nimport { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder, clearSearchMode, getSelectedMessages, markBodiesCached, getCurrentFocused, releaseFocus, removeMessagesAndReconcile, setRowFlagged, scrollFocusedIntoView, refreshPriorityIndex } from \"./components/message-list.js\";\nimport { seenOf, flaggedOf, draftOf, setSeen, setFlagged } from \"@bobfrankston/mailx-types\";\nimport { initTabs, setActiveView as setActiveTabView, openTab, type ViewTab } from \"./components/tabs.js\";\nimport { showMessage, getCurrentMessage, initViewer, popOutCurrentMessage, printCurrentMessage, toggleFullscreenPreview, showPreviewBodyMenu, wrapHtmlBody } from \"./components/message-viewer.js\";\nimport { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessage, deleteMessages, undeleteMessage, restartServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts, updateFlags, markAsSpamMessages, logClientEvent, sendMessage as apiSendMessage, subscribeStore, cancelServerSearch } from \"./lib/api-client.js\";\nimport * as messageState from \"./lib/message-state.js\";\n\n// \u2500\u2500 New message badge (favicon + title) \u2500\u2500\n/** The user-visible app name. Single point of change for the rename;\n * every UI surface that shows \"rmfmail\" reads from here. Static HTML\n * uses placeholder text that `propagateAppName` (called immediately\n * below) overwrites \u2014 so the constant is the only place the literal\n * string lives. */\nexport const APP_NAME = \"rmfmail\";\n\n/** Stamp APP_NAME into every static HTML element that should show it.\n * Runs synchronously at module load \u2014 before paint when imports are\n * fast, with a sub-frame flash of the placeholder otherwise. Add new\n * surfaces here, never inline literals. */\nfunction propagateAppName(): void {\n const set = (id: string, fn: (v: string) => string): void => {\n const el = document.getElementById(id);\n if (el) el.textContent = fn(APP_NAME);\n };\n document.title = APP_NAME;\n set(\"startup-status\", n => `Starting ${n}\u2026`);\n set(\"status-version\", n => n);\n set(\"app-version\", n => n);\n // About-button label / restart-button hover-titles live in HTML\n // attributes; rewrite the ones that include the name.\n const aboutBtn = document.getElementById(\"btn-about\");\n if (aboutBtn) aboutBtn.title = `Show version and build info`;\n const aboutText = aboutBtn?.textContent || \"\";\n if (aboutBtn && aboutText.toLowerCase().includes(\"about\")) {\n aboutBtn.textContent = `About ${APP_NAME}...`;\n }\n}\npropagateAppName();\n(window as any).__btick && (window as any).__btick(\"app.ts module body executing\");\n\n// \u2500\u2500 App-vs-browser policy: this is a desktop app, not a web page. \u2500\u2500\n// Block browser-default accelerators (Reload, Save Page, Print, View\n// Source, history navigation, \u2026) and the right-click context menu so\n// mailx's keymap is the only way to do anything. F12 is preserved as\n// the developer-tools escape hatch (Bob 2026-05-11 explicitly).\n//\n// Capture-phase listener so we intercept BEFORE any inner widget can\n// observe the event \u2014 mailx's own keymap re-emits via specific element\n// handlers (Ctrl+N compose, Ctrl+R reply, etc.) which were never the\n// browser's accelerators in the first place.\n(function blockBrowserKeysAndMenu() {\n const isBlockedKey = (e: KeyboardEvent): boolean => {\n // Always-allow: F12 for DevTools, and any single non-modifier key\n // (typing, arrow nav, Tab/Esc/Enter) which is the user typing.\n if (e.key === \"F12\") return false;\n // Block reload paths: F5, Ctrl+R, Ctrl+Shift+R, Ctrl+F5.\n if (e.key === \"F5\" || e.key === \"F3\") return true;\n if ((e.ctrlKey || e.metaKey) && (e.key === \"r\" || e.key === \"R\")) return true;\n // Browser accelerators bound to Ctrl-letter combos that mailx\n // doesn't want to forward: P (print), S (save page), U (view\n // source), J (downloads), L (focus address bar \u2014 n/a in WebView\n // but harmless), G (find next). Note: O is \"open file\" in\n // browsers \u2014 mailx uses Ctrl+O nowhere meaningful, so block.\n if (e.ctrlKey || e.metaKey) {\n const k = e.key.toLowerCase();\n if ([\"p\", \"s\", \"u\", \"j\", \"l\", \"g\", \"o\"].includes(k)) return true;\n }\n // Alt+Left / Alt+Right = browser back / forward. No use in mailx.\n if (e.altKey && (e.key === \"ArrowLeft\" || e.key === \"ArrowRight\")) return true;\n // Backspace as nav-back when no input is focused \u2014 fires on some\n // WebView builds. Only block when target isn't text-editable.\n if (e.key === \"Backspace\") {\n const t = e.target as HTMLElement | null;\n const editable = t && (t.tagName === \"INPUT\" || t.tagName === \"TEXTAREA\"\n || (t as any).isContentEditable);\n if (!editable) return true;\n }\n return false;\n };\n document.addEventListener(\"keydown\", (e) => {\n if (isBlockedKey(e)) {\n e.preventDefault();\n e.stopPropagation();\n }\n }, true);\n // Right-click context menu \u2014 mailx has its own (showContextMenu) for\n // specific surfaces. Default WebView menu is \"Reload / Save As /\n // View Source / Inspect\" which doesn't belong in a desktop app.\n // Individual handlers that DO want a context menu must\n // `e.preventDefault()` themselves AFTER showing \u2014 this only\n // suppresses the default browser one when nothing else handles.\n document.addEventListener(\"contextmenu\", (e) => {\n // Allow contextmenu to bubble normally so mailx-internal handlers\n // (folder-tree right-click, address-pill right-click, link\n // right-click in preview) can react. We only kill it if it would\n // otherwise show the browser default \u2014 i.e., no other handler\n // called preventDefault.\n if (!e.defaultPrevented) e.preventDefault();\n });\n})();\n\nlet baseTitle = APP_NAME;\nlet lastSeenCount = 0;\nlet badgeCount = 0;\n\nfunction updateBadge(count: number): void {\n badgeCount = count;\n // Update title\n document.title = count > 0 ? `(${count}) ${baseTitle}` : baseTitle;\n // Generate a single badge bitmap used for both the favicon (visible on\n // browser tabs / mobile homescreen) AND the Windows taskbar overlay\n // icon (visible as a Thunderbird-style corner pill on the taskbar\n // button when running via msger). Rendered once, consumed twice.\n const canvas = document.createElement(\"canvas\");\n canvas.width = 32;\n canvas.height = 32;\n const ctx = canvas.getContext(\"2d\")!;\n // Base envelope icon (always drawn \u2014 so the favicon is a recognizable\n // mailx icon even at 0 count).\n ctx.fillStyle = \"#4a7ccc\";\n ctx.fillRect(2, 8, 28, 20);\n ctx.fillStyle = \"#6a9cec\";\n ctx.beginPath();\n ctx.moveTo(2, 8);\n ctx.lineTo(16, 20);\n ctx.lineTo(30, 8);\n ctx.fill();\n if (count > 0) {\n // Red badge circle with count\n ctx.fillStyle = \"#e33\";\n ctx.beginPath();\n ctx.arc(24, 8, 8, 0, Math.PI * 2);\n ctx.fill();\n ctx.fillStyle = \"#fff\";\n ctx.font = \"bold 11px sans-serif\";\n ctx.textAlign = \"center\";\n ctx.textBaseline = \"middle\";\n ctx.fillText(count > 99 ? \"99+\" : String(count), 24, 8);\n }\n // Set as favicon\n let link = document.querySelector(\"link[rel='icon']\") as HTMLLinkElement;\n if (!link) {\n link = document.createElement(\"link\");\n link.rel = \"icon\";\n document.head.appendChild(link);\n }\n const dataUrl = canvas.toDataURL(\"image/png\");\n link.href = dataUrl;\n\n // Also push to the Windows taskbar overlay via msger's IPC helper \u2014\n // no-op on Linux/Mac. For count=0, render a dedicated \"no-overlay\"\n // icon that's all-transparent so the base icon shows cleanly.\n try {\n const msgapi: any = (window as any).msgapi;\n if (msgapi?.setTaskbarOverlay) {\n if (count > 0) {\n // strip \"data:image/png;base64,\" prefix \u2192 base64 only\n const b64 = dataUrl.split(\",\")[1] || \"\";\n msgapi.setTaskbarOverlay(b64, `${count} unread`);\n } else {\n msgapi.setTaskbarOverlay(\"\", \"\");\n }\n }\n } catch { /* msgapi unavailable in browser fallback */ }\n}\n\nasync function updateNewMessageCount(): Promise<void> {\n try {\n const accounts = await getAccounts();\n // Fan out folder queries in parallel \u2014 earlier code awaited each\n // account's `getFolders` in series, so an N-account setup paid N\n // back-to-back IPC round-trips on every count refresh (folderCountsChanged,\n // sync events, IDLE updates).\n const folderLists = await Promise.all(\n accounts.map((acct: any) => getFolders(acct.id).catch(() => [] as any[])),\n );\n let totalUnread = 0;\n for (const folders of folderLists) {\n const inbox = folders.find((f: any) => f.specialUse === \"inbox\");\n if (inbox) totalUnread += inbox.unreadCount || 0;\n }\n // Rail badge: unread count on the Inbox and Unified-inbox rail buttons.\n // Visible even when those views aren't the active one \u2014 part of C33\n // \"rail icon badges for unread counts.\"\n updateRailBadge(\"rail-inbox\", totalUnread);\n updateRailBadge(\"rail-unified\", totalUnread);\n // First load: set baseline\n if (lastSeenCount === 0) { lastSeenCount = totalUnread; updateBadge(0); return; }\n const previousBadge = badgeCount;\n // New messages = increase since last seen\n const newCount = Math.max(0, totalUnread - lastSeenCount);\n updateBadge(newCount);\n // Flash the title when new mail arrives and the window isn't focused.\n // Windows' taskbar mirrors document.title so this acts as a taskbar flash.\n if (newCount > previousBadge && document.visibilityState !== \"visible\") {\n startTitleFlash();\n }\n } catch { /* offline */ }\n}\n\nfunction updateRailBadge(buttonId: string, count: number): void {\n const btn = document.getElementById(buttonId);\n if (!btn) return;\n let badge = btn.querySelector<HTMLElement>(\".rail-badge\");\n if (count <= 0) {\n if (badge) badge.remove();\n return;\n }\n if (!badge) {\n badge = document.createElement(\"span\");\n badge.className = \"rail-badge\";\n btn.appendChild(badge);\n }\n badge.textContent = count > 999 ? \"999+\" : String(count);\n}\n\n// \u2500\u2500 Taskbar flash via title alternation \u2500\u2500\nlet titleFlashTimer: ReturnType<typeof setInterval> | null = null;\nlet titleFlashPhase = false;\n\nfunction startTitleFlash(): void {\n stopTitleFlash();\n titleFlashPhase = true;\n titleFlashTimer = setInterval(() => {\n titleFlashPhase = !titleFlashPhase;\n if (titleFlashPhase) {\n document.title = `\u2709 NEW MAIL (${badgeCount})`;\n } else {\n document.title = badgeCount > 0 ? `(${badgeCount}) ${baseTitle}` : baseTitle;\n }\n }, 1000);\n}\n\nfunction stopTitleFlash(): void {\n if (titleFlashTimer) { clearInterval(titleFlashTimer); titleFlashTimer = null; }\n document.title = badgeCount > 0 ? `(${badgeCount}) ${baseTitle}` : baseTitle;\n}\n\ndocument.addEventListener(\"visibilitychange\", () => {\n if (document.visibilityState === \"visible\") stopTitleFlash();\n});\nwindow.addEventListener(\"focus\", stopTitleFlash);\n\n/** Call when user actively views messages \u2014 resets the badge */\nfunction markAsSeen(): void {\n getAccounts().then(async (accounts: any[]) => {\n // Parallel folder fetch \u2014 see updateNewMessageCount for rationale.\n const folderLists = await Promise.all(\n accounts.map((acct: any) => getFolders(acct.id).catch(() => [] as any[])),\n );\n let total = 0;\n for (const folders of folderLists) {\n const inbox = folders.find((f: any) => f.specialUse === \"inbox\");\n if (inbox) total += inbox.unreadCount || 0;\n }\n lastSeenCount = total;\n updateBadge(0);\n }).catch(() => {});\n}\n\nfunction setTitle(title: string): void {\n baseTitle = title;\n document.title = badgeCount > 0 ? `(${badgeCount}) ${baseTitle}` : baseTitle;\n}\n\n// \u2500\u2500 Alert banner \u2500\u2500\nconst alertBanner = document.getElementById(\"alert-banner\");\nconst alertText = document.getElementById(\"alert-text\");\nconst alertDismiss = document.getElementById(\"alert-dismiss\");\nconst dismissedAlerts = new Set<string>();\n\nlet alertAutoDismissTimer: ReturnType<typeof setTimeout> | null = null;\nfunction showAlert(message: string, key?: string, opts?: { sticky?: boolean }): void {\n if (key && dismissedAlerts.has(key)) return;\n if (alertBanner && alertText) {\n alertText.textContent = message;\n alertBanner.hidden = false;\n alertBanner.dataset.key = key || \"\";\n // Q65: auto-dismiss non-critical banners after 30s; sticky ones\n // (acct-*, ws-error, config-restart) keep showing until user acts.\n if (alertAutoDismissTimer) { clearTimeout(alertAutoDismissTimer); alertAutoDismissTimer = null; }\n const isCritical = !!opts?.sticky\n || (key?.startsWith(\"acct-\"))\n || key === \"ws-error\"\n || key === \"config-restart\";\n if (!isCritical) {\n alertAutoDismissTimer = setTimeout(() => {\n if (alertBanner && alertBanner.dataset.key === (key || \"\")) {\n alertBanner.hidden = true;\n }\n alertAutoDismissTimer = null;\n }, 30_000);\n }\n }\n}\n\n// Non-blocking alert channel for components that can't import showAlert\n// directly (message-viewer, etc.). They dispatch a `mailx-alert` CustomEvent\n// rather than calling the blocking window.alert().\nwindow.addEventListener(\"mailx-alert\", (e: Event) => {\n const d = (e as CustomEvent).detail || {};\n if (d.message) showAlert(String(d.message), d.key);\n});\n\nfunction hideAlert(opts?: { force?: boolean }): void {\n if (alertBanner) {\n // The \"update available\" notice is sticky: routine \"clear the\n // transient banner\" calls (after a successful sync, etc.) must not\n // wipe it \u2014 otherwise the next sync, seconds later, hides the\n // update banner and the user never sees it. Only the explicit X\n // (force) clears it.\n if (!opts?.force && alertBanner.dataset.key === \"update-available\") return;\n const key = alertBanner.dataset.key;\n if (key) dismissedAlerts.add(key);\n alertBanner.hidden = true;\n }\n}\n\nalertDismiss?.addEventListener(\"click\", () => hideAlert({ force: true }));\n\n/** Show the alert banner with a \"Restart\" button wired to the mailxapi\n * restartDaemon action. Used when a watched config file whose changes\n * don't apply live (accounts.jsonc) has been modified. */\nfunction showRestartForConfigBanner(): void {\n if (!alertBanner || !alertText) return;\n // Timestamp in the banner so repeated / spurious fires are visually\n // distinguishable (and the user can see when the change actually\n // happened, useful for debugging false triggers).\n const ts = new Date().toLocaleTimeString([], { hour12: false });\n alertText.textContent = `[${ts}] accounts.jsonc changed \u2014 restart to apply.`;\n alertBanner.hidden = false;\n alertBanner.dataset.key = \"config-restart\";\n // Avoid duplicate buttons across repeat changes.\n const existing = alertBanner.querySelector(\"#alert-restart-btn\");\n if (existing) return;\n const btn = document.createElement(\"button\");\n btn.id = \"alert-restart-btn\";\n btn.textContent = \"Restart now\";\n btn.style.cssText = \"margin-left: 12px; padding: 3px 12px; cursor: pointer;\";\n btn.addEventListener(\"click\", async () => {\n btn.disabled = true;\n btn.textContent = \"Restarting\u2026\";\n try {\n const ipc: any = (window as any).mailxapi;\n if (ipc?.restartDaemon) {\n await ipc.restartDaemon();\n // Service is going down; the WebView should reload shortly\n // when the replacement daemon takes over. Force a reload\n // after a short delay in case the event doesn't arrive.\n setTimeout(() => location.reload(), 2000);\n } else {\n // Non-IPC (server/browser mode) \u2014 location reload won't\n // restart the daemon but at least gives the user feedback.\n location.reload();\n }\n } catch (e: any) {\n btn.textContent = `Failed: ${e?.message || e}`;\n btn.disabled = false;\n }\n });\n alertText.after(btn);\n}\n\n// \u2500\u2500 Wire up components \u2500\u2500\n\nconst folderTree = document.getElementById(\"folder-tree\")!;\nlet currentFolderSpecialUse = \"\";\n\n// Selection / preview drift is now structurally impossible: the list owns\n// focus, the viewer is a passive renderer, and `releaseFocus()` is the\n// only way to deselect+clear. The old `mailx-clear-viewer` event and the\n// app-level clearViewer() shim are no longer needed.\n\nconst folderTitleEl = document.getElementById(\"ml-folder-title\");\nlet currentFolderName = \"\";\nlet currentFolderSyncedAt: number | undefined;\n\nfunction formatAge(ms: number): string {\n const s = Math.round(ms / 1000);\n if (s < 60) return `${s}s ago`;\n const m = Math.round(s / 60);\n if (m < 60) return `${m}m ago`;\n const h = Math.round(m / 60);\n if (h < 24) return `${h}h ago`;\n return `${Math.round(h / 24)}d ago`;\n}\n\nfunction renderNarrowFolderTitle(): void {\n if (!folderTitleEl) return;\n if (currentFolderSyncedAt) {\n const age = formatAge(Date.now() - currentFolderSyncedAt);\n folderTitleEl.innerHTML = `${currentFolderName}<span class=\"ml-folder-age\"> \u00B7 ${age}</span>`;\n folderTitleEl.title = `Last synced ${new Date(currentFolderSyncedAt).toLocaleTimeString()}`;\n } else {\n folderTitleEl.textContent = currentFolderName;\n folderTitleEl.title = \"\";\n }\n}\n\nfunction setNarrowFolderTitle(name: string): void {\n currentFolderName = name;\n currentFolderSyncedAt = getFolderSynced(currentAccountId, currentFolderId);\n renderNarrowFolderTitle();\n}\n\n// Tick the \"3m ago\" text every 30s so it stays truthful without flooding repaints.\nsetInterval(() => {\n if (currentFolderSyncedAt) renderNarrowFolderTitle();\n}, 30_000);\n\ninitFolderTree(folderTree, (accountId, folderId, folderName, specialUse) => {\n currentFolderSpecialUse = specialUse;\n currentAccountId = accountId;\n currentFolderId = folderId;\n // Drop search state on folder switch \u2014 input alone wasn't enough,\n // searchMode stayed true and the next loadMessages was rerouted.\n if (searchInput) { searchInput.value = \"\"; updateSearchHighlight(); }\n clearSearchMode();\n markAsSeen();\n releaseFocus();\n loadMessages(accountId, folderId, 1, specialUse);\n setTitle(`${APP_NAME} - ${folderName}`);\n setNarrowFolderTitle(folderName);\n // Record the navigation in the active tab so a later tab-switch restores\n // this folder. Folder navigation happens IN the current tab.\n setActiveTabView({ kind: \"folder\", accountId, folderId, specialUse }, folderName);\n document.dispatchEvent(new CustomEvent(\"mailx-folder-changed\", { detail: { accountId, folderId } }));\n}, () => {\n // Unified inbox handler\n currentFolderSpecialUse = \"inbox\";\n // Clear search state \u2014 switching folders should drop any active filter.\n // Pre-fix: the search input retained its old value AND search mode\n // stayed active, so \"All Inboxes\" appeared to be ignoring its own\n // contents (Bob 2026-05-09: \"I just switched to all inboxes but the\n // search is still showing even though all the entries are there.\").\n if (searchInput) { searchInput.value = \"\"; updateSearchHighlight(); }\n clearSearchMode();\n releaseFocus();\n loadUnifiedInbox();\n setTitle(`${APP_NAME} - All Inboxes`);\n setNarrowFolderTitle(\"All Inboxes\");\n setActiveTabView({ kind: \"unified\" }, \"All Inboxes\");\n});\n\n// \u2500\u2500 View tabs (docs/multi-view.md) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// `applyTabView` loads a tab's view into the single three-pane when the user\n// switches tabs. It mirrors the folder-tree handlers above but must NOT call\n// setActiveTabView (that would be a no-op update, but keeping them separate\n// keeps \"user navigated\" distinct from \"tab restored\").\nfunction applyTabView(tab: ViewTab): void {\n if (searchInput) { searchInput.value = \"\"; updateSearchHighlight(); }\n clearSearchMode();\n releaseFocus();\n const v = tab.view;\n if (v.kind === \"unified\") {\n currentFolderSpecialUse = \"inbox\";\n loadUnifiedInbox();\n setTitle(`${APP_NAME} - All Inboxes`);\n setNarrowFolderTitle(\"All Inboxes\");\n } else if (v.kind === \"folder\") {\n currentFolderSpecialUse = v.specialUse;\n currentAccountId = v.accountId;\n currentFolderId = v.folderId;\n loadMessages(v.accountId, v.folderId, 1, v.specialUse);\n setTitle(`${APP_NAME} - ${tab.title}`);\n setNarrowFolderTitle(tab.title);\n } else {\n if (searchInput) { searchInput.value = v.query; updateSearchHighlight(); }\n loadSearchResults(v.query, v.scope, v.accountId, v.folderId, v.includeTrash);\n setTitle(`${APP_NAME} - Search`);\n setNarrowFolderTitle(`Search: ${v.query}`);\n }\n}\nconst tabStripEl = document.getElementById(\"view-tab-strip\");\nif (tabStripEl) initTabs(tabStripEl, applyTabView);\n\ninitMessageList((_accountId, _uid, _folderId) => {\n // The list row's setFocus() already drove the viewer with its own\n // envelope (header + preview paint synchronously, body fetches in\n // background). Calling showMessage here a SECOND time without the\n // envelope wiped the good render and forced a bare \"Loading\u2026\"\n // placeholder until the body IPC returned \u2014 making every click feel\n // like a network round-trip. This callback now only handles the\n // narrow-screen layout switch.\n if (window.innerWidth <= 768) {\n document.getElementById(\"message-viewer\")?.classList.add(\"narrow-active\");\n document.getElementById(\"message-list\")?.classList.add(\"narrow-hidden\");\n // Selecting a message means the user is done with the rail / folder\n // drawers \u2014 auto-dismiss either if left open. Without this the rail\n // floats over the message body (the \"rail on top of the letter\" bug).\n document.querySelector(\".icon-rail\")?.classList.remove(\"open\");\n document.querySelector(\".folder-panel\")?.classList.remove(\"open\");\n }\n});\ninitViewer();\n(window as any).__btick && (window as any).__btick(\"init* done \u2014 first interactive moment\");\nrequestAnimationFrame(() => (window as any).__btick && (window as any).__btick(\"first rAF after init\"));\n\n// Status bar: show focused message UID/folder for debugging. The list\n// fires mailx-focus-changed with the focused envelope (or null). Pure\n// observation \u2014 this listener never drives the viewer.\ndocument.addEventListener(\"mailx-focus-changed\", (e: any) => {\n const acctEl = document.getElementById(\"status-accounts\");\n if (!acctEl) return;\n const sel = e.detail as { accountId: string; uid: number; folderId: number } | null;\n if (sel) {\n acctEl.textContent = `${sel.accountId}/uid:${sel.uid} folder:${sel.folderId}`;\n acctEl.style.color = \"\";\n } else {\n acctEl.textContent = \"\";\n }\n});\n\n// Q53: per-account last-sync timestamps surfaced via the status-sync hover.\nconst lastSyncByAccount: Record<string, number> = {};\nfunction recordAccountSync(accountId: string): void {\n lastSyncByAccount[accountId] = Date.now();\n refreshSyncTooltip();\n}\nfunction refreshSyncTooltip(): void {\n const el = document.getElementById(\"status-sync\");\n if (!el) return;\n const accts = Object.keys(lastSyncByAccount).sort();\n if (accts.length === 0) { el.title = \"\"; return; }\n el.title = \"Last sync:\\n\" + accts.map(a => {\n const ts = lastSyncByAccount[a];\n const d = new Date(ts);\n return ` ${a}: ${d.toLocaleTimeString()} (${formatAge(Date.now() - ts)})`;\n }).join(\"\\n\");\n}\n// Refresh the tooltip every 30s so the \"(12m ago)\" stays current even with\n// no new sync events.\nsetInterval(refreshSyncTooltip, 30_000);\n\n// \u2500\u2500 Auto two-line when message list is narrow \u2500\u2500\nconst messageList = document.getElementById(\"message-list\");\nif (messageList) {\n const twoLineThreshold = 600; // px \u2014 switch to two-line below this width\n const userTwoLine = localStorage.getItem(\"mailx-two-line\") === \"true\";\n new ResizeObserver(([entry]) => {\n const narrow = entry.contentRect.width < twoLineThreshold;\n // Auto two-line when narrow, respect user preference when wide\n if (narrow) {\n messageList.classList.add(\"two-line\");\n } else if (!userTwoLine) {\n messageList.classList.remove(\"two-line\");\n }\n }).observe(messageList);\n}\n\n// \u2500\u2500 Narrow/medium drawer toggles \u2500\u2500\n// Hamburger (\u2630): rail drawer on narrow; on wider tiers the rail is already\n// visible so this is a no-op visually (the toggle still fires but the rail\n// has no `.open` style to invoke).\n// Folder (\uD83D\uDCC1): folder-panel drawer on any tier where it's positioned as an\n// overlay (medium + narrow).\ndocument.getElementById(\"btn-menu\")?.addEventListener(\"click\", () => {\n document.querySelector(\".icon-rail\")?.classList.toggle(\"open\");\n // Rail drawer and folder drawer are mutually exclusive \u2014 opening one\n // closes the other so they don't fight for the left edge.\n document.querySelector(\".folder-panel\")?.classList.remove(\"open\");\n});\ndocument.getElementById(\"btn-folder-toggle\")?.addEventListener(\"click\", () => {\n document.querySelector(\".folder-panel\")?.classList.toggle(\"open\");\n document.querySelector(\".icon-rail\")?.classList.remove(\"open\");\n});\n\nconst backToList = (e: Event) => {\n e.preventDefault();\n e.stopPropagation();\n // If user is in full-screen-viewer mode, the first back tap should exit\n // full-screen and return to the normal narrow split (list + active\n // viewer). It shouldn't also deselect \u2014 that would yank the user out two\n // levels in one tap.\n if (document.body.classList.contains(\"viewer-fullscreen\")) {\n document.body.classList.remove(\"viewer-fullscreen\");\n return;\n }\n document.getElementById(\"message-viewer\")?.classList.remove(\"narrow-active\");\n document.getElementById(\"message-list\")?.classList.remove(\"narrow-hidden\");\n // Release focus so the viewer clears. Without this a subsequent sync\n // reload could re-show the same message and re-trigger narrow-active.\n releaseFocus();\n};\ndocument.getElementById(\"btn-back\")?.addEventListener(\"click\", backToList);\n// Android WebView sometimes drops synthetic clicks after a touchend inside a\n// header bar layered above the iframe \u2014 handle touchend explicitly too.\ndocument.getElementById(\"btn-back\")?.addEventListener(\"touchend\", backToList);\n\n// Pop-out viewer button \u2014 desktop spawns a floating overlay (multiple at\n// once), mobile toggles `body.viewer-fullscreen` for full-screen reading.\n// Threshold and behavior live in popOutCurrentMessage.\ndocument.getElementById(\"mv-popout\")?.addEventListener(\"click\", () => popOutCurrentMessage());\ndocument.getElementById(\"btn-print\")?.addEventListener(\"click\", () => printCurrentMessage());\n\n// Close folder panel when a folder is selected (narrow mode)\n// Also reset narrow navigation: show message list, hide viewer\ndocument.getElementById(\"folder-tree\")?.addEventListener(\"click\", (e) => {\n if (window.innerWidth <= 768 && (e.target as HTMLElement).closest(\".ft-folder\")) {\n document.querySelector(\".folder-panel\")?.classList.remove(\"open\");\n document.getElementById(\"message-viewer\")?.classList.remove(\"narrow-active\");\n document.getElementById(\"message-list\")?.classList.remove(\"narrow-hidden\");\n }\n});\n\n// Close folder overlay when user clicks outside it (narrow mode OR\n// medium-width mode where the folder panel slides in as an overlay).\n// Uses capture phase so it beats any child handler that might stopPropagation.\ndocument.addEventListener(\"pointerdown\", (e) => {\n const panel = document.querySelector(\".folder-panel\");\n if (!panel || !panel.classList.contains(\"open\")) return;\n const target = e.target as HTMLElement;\n // Ignore clicks inside the panel itself and on either toggle button.\n // Without `#btn-folder-toggle` in this list, clicking the folder icon\n // while the panel is open closed it here (capture phase) then the click\n // handler reopened it \u2014 net effect: panel stuck open, \"doesn't toggle\".\n if (target.closest(\".folder-panel\")\n || target.closest(\"#btn-menu\")\n || target.closest(\"#btn-folder-toggle\")) return;\n // Only auto-dismiss when we're in overlay mode (small or medium screens).\n // On wide screens the panel is a permanent column and the \"open\" class\n // is irrelevant.\n if (window.innerWidth <= 1100 || window.innerHeight <= 600) {\n panel.classList.remove(\"open\");\n }\n}, true);\n\n// Same auto-dismiss for the icon-rail drawer (narrow only \u2014 on medium/wide\n// the rail is a permanent column and `.open` has no visual effect).\ndocument.addEventListener(\"pointerdown\", (e) => {\n const rail = document.querySelector(\".icon-rail\");\n if (!rail || !rail.classList.contains(\"open\")) return;\n const target = e.target as HTMLElement;\n if (target.closest(\".icon-rail\") || target.closest(\"#btn-menu\")) return;\n if (window.innerWidth <= 768) rail.classList.remove(\"open\");\n}, true);\n\n// The rail stays open until the user clicks outside it \u2014 handled by the\n// document-level outside-click handler above. No per-button close: chaining\n// rail actions (toggle theme, then check About, then change a setting)\n// without having to re-open the rail each time was the explicit ask.\n\n// \u2500\u2500 Toolbar actions \u2500\u2500\n\ndocument.getElementById(\"btn-sync\")?.addEventListener(\"click\", async () => {\n const btn = document.getElementById(\"btn-sync\") as HTMLButtonElement;\n btn.disabled = true;\n btn.classList.add(\"syncing\");\n const statusSync = document.getElementById(\"status-sync\");\n if (statusSync) statusSync.textContent = \"Syncing...\";\n\n try {\n await triggerSync();\n // Button stays spinning \u2014 WebSocket syncProgress/folderCountsChanged will update UI\n // Set a timeout to re-enable if no WebSocket response\n setTimeout(() => {\n btn.disabled = false;\n btn.classList.remove(\"syncing\");\n refreshFolderTree();\n reloadCurrentFolder();\n if (statusSync && statusSync.textContent === \"Syncing...\") {\n statusSync.textContent = `Synced ${new Date().toLocaleTimeString(undefined, { hour: \"2-digit\", minute: \"2-digit\", hour12: false })}`;\n }\n }, 30000);\n } catch (e: any) {\n if (statusSync) statusSync.textContent = `Sync error: ${e.message}`;\n btn.disabled = false;\n btn.classList.remove(\"syncing\");\n }\n});\n\n// Restart menu dropdown\nconst restartBtn = document.getElementById(\"btn-restart\");\nconst restartDropdown = document.getElementById(\"restart-dropdown\");\nrestartBtn?.addEventListener(\"click\", () => {\n restoreToolbarDropdown(\"restart-dropdown\", \"restart-menu\");\n if (restartDropdown) restartDropdown.hidden = !restartDropdown.hidden;\n});\ndocument.addEventListener(\"click\", (e) => {\n if (restartDropdown && !restartDropdown.hidden && !(e.target as HTMLElement).closest(\"#restart-menu\")) {\n restartDropdown.hidden = true;\n }\n});\n\ndocument.getElementById(\"btn-restart-quick\")?.addEventListener(\"click\", async () => {\n if (restartDropdown) restartDropdown.hidden = true;\n if (isApp) {\n // Android has no daemon \u2014 only the WebView. Reload-the-page is the\n // right action there. Desktop IPC mode is a different story below.\n if ((window as any).mailxapi?.platform === \"android\") {\n const f = document.createElement(\"iframe\");\n f.style.display = \"none\";\n f.src = \"mailxapi://checkUpdate\";\n document.body.appendChild(f);\n setTimeout(() => f.remove(), 100);\n location.reload();\n return;\n }\n // Desktop IPC mode: there IS a daemon (the --daemon child of mailx)\n // running mailx-service / mailx-imap / mailx-store. Just calling\n // location.reload() reloads the WebView but the daemon keeps running\n // the old code, so daemon-side changes (sync, store, IPC handlers)\n // don't get picked up. Trigger restartDaemon \u2014 it spawns a fresh\n // `mailx` process, hands off the instance.json slot, then gracefully\n // shuts down the current daemon. The UI reloads after a short delay\n // so the new daemon's WebView replaces this one.\n const statusSync = document.getElementById(\"status-sync\");\n if (statusSync) statusSync.textContent = \"Restarting...\";\n const ipc = (window as any).mailxapi;\n if (ipc?.restartDaemon) {\n try { await ipc.restartDaemon(); } catch { /* daemon shutting down */ }\n setTimeout(() => location.reload(), 2000);\n } else {\n // Older host with no restartDaemon IPC \u2014 fall back to UI reload.\n location.reload();\n }\n } else {\n const statusSync = document.getElementById(\"status-sync\");\n if (statusSync) statusSync.textContent = \"Restarting...\";\n try { await restartServer(); } catch { /* server is shutting down */ }\n }\n});\n\ndocument.getElementById(\"btn-update\")?.addEventListener(\"click\", async () => {\n if (restartDropdown) restartDropdown.hidden = true;\n const statusSync = document.getElementById(\"status-sync\");\n if (statusSync) statusSync.textContent = \"Checking for updates...\";\n const ipc = (window as any).mailxapi || (window as any).opener?.mailxapi;\n if (ipc?.performUpdate) {\n if (statusSync) statusSync.textContent = \"Updating... mailx will restart when done\";\n ipc.performUpdate();\n } else if (statusSync) {\n statusSync.textContent = \"Update not available in this mode\";\n }\n});\n\ndocument.getElementById(\"btn-rebuild\")?.addEventListener(\"click\", async () => {\n if (restartDropdown) restartDropdown.hidden = true;\n if (!confirm(\"Rebuild local cache?\\n\\nThis wipes the local database and message store, then re-downloads everything.\\nAccounts and settings are preserved.\\n\\nThis is safe and usually takes just a few minutes.\")) return;\n const statusSync = document.getElementById(\"status-sync\");\n if (statusSync) statusSync.textContent = \"Rebuilding...\";\n try { await restartServer(); } catch { /* restarting */ }\n});\n\ndocument.getElementById(\"btn-factory-reset\")?.addEventListener(\"click\", async () => {\n if (restartDropdown) restartDropdown.hidden = true;\n if (!confirm(\"Factory reset?\\n\\nThis deletes ALL data \u2014 accounts, settings, messages, cache.\\nYou will need to set up your account again.\")) return;\n const ipc = (window as any).mailxapi;\n if (ipc?.resetAll) {\n await ipc.resetAll();\n } else {\n // Fallback: clear IndexedDB + localStorage manually\n const dbs = await indexedDB.databases();\n for (const db of dbs) { if (db.name) indexedDB.deleteDatabase(db.name); }\n localStorage.clear();\n location.reload();\n }\n});\n\n// \u2500\u2500 Compose / Reply / Forward \u2500\u2500\n\ntype ComposeMode = \"new\" | \"reply\" | \"replyAll\" | \"forward\";\n\nasync function openCompose(mode: ComposeMode, overrideMsg?: any, overrideAccountId?: string): Promise<void> {\n logClientEvent(\"openCompose-entry\", { mode });\n // `overrideMsg` lets a detached surface (the message pop-out) compose a\n // reply/forward for ITS message rather than whatever the main viewer has\n // selected. Same `{ message, accountId }` shape getCurrentMessage returns.\n const current = overrideMsg\n ? { message: overrideMsg, accountId: overrideAccountId || currentAccountId }\n : getCurrentMessage();\n // Local-first: if the row is selected we already have its headers in the\n // local DB. Populate the compose form unconditionally; the user can edit\n // anything missing. Don't show \"still loading\" alerts \u2014 the message IS\n // loaded (it's in the list), body is a separate fetch that isn't needed\n // for Reply's headers. Missing fields become empty strings.\n if ((mode === \"reply\" || mode === \"replyAll\" || mode === \"forward\") && !current) {\n // Only true blocker: no message selected at all.\n console.warn(`[compose] ${mode} \u2014 no message selected`);\n return;\n }\n // Parallel-load: kick off getAccounts AND open the iframe in the same\n // tick. The iframe doesn't need the account list until after its editor\n // bootstraps (200-500 ms for TinyMCE, less for Quill); by then the IPC\n // round-trip has resolved. Earlier code awaited getAccounts FIRST,\n // adding the IPC latency to the perceived Ctrl+N \u2192 editor-visible time.\n // We post `compose-init-ready` to the iframe once init is in\n // sessionStorage so compose.ts's IIFE can read synchronously without\n // polling.\n const accountsP = getAccounts();\n const msg = current?.message;\n // Title bar text needs the subject from msg (no IPC dependency) \u2014 build\n // it now so the iframe can be opened with the final title and avoid a\n // flash of placeholder text.\n const titlePrefix =\n mode === \"reply\" ? \"Reply\" :\n mode === \"replyAll\" ? \"Reply All\" :\n mode === \"forward\" ? \"Forward\" :\n \"Compose\";\n const titleSubject = mode === \"new\" ? \"\" : (msg?.subject || \"\");\n const frame = showComposeOverlay(titleSubject ? `${titlePrefix}: ${titleSubject}` : titlePrefix);\n // Now finish initialisation off the critical path \u2014 editor bootstrap\n // inside the iframe runs concurrently with this await.\n const accounts = await accountsP;\n const accountId = current?.accountId || accounts[0]?.id || \"\";\n const rePrefix = /^(re|fwd?):\\s*/i;\n const cleanSubject = msg ? msg.subject.replace(rePrefix, \"\") : \"\";\n\n const init: any = {\n mode,\n accountId,\n to: [],\n cc: [],\n subject: \"\",\n bodyHtml: \"\",\n inReplyTo: \"\",\n references: [],\n accounts: accounts.map((a: any) => ({ id: a.id, name: a.name, email: a.email, signature: a.signature, sig: a.sig })),\n };\n\n // Auto-detect reply From: if the message was delivered to an identity address\n // (an alias on the account's domain, or the explicit `identityDomains` list\n // in accounts.jsonc), reply from that address instead of the account's\n // primary. Always derive identityDomains from the account email's domain\n // when not configured \u2014 explicit list was a regression source (users would\n // see Reply pick the wrong From silently when the list was missing).\n const account = accounts.find((a: any) => a.id === accountId);\n const explicitDomains: string[] = (account?.identityDomains || []).map((d: string) => d.toLowerCase());\n const accountDomain = (account?.email || \"\").split(\"@\")[1]?.toLowerCase();\n // Fold subsumed subdomains. If the list has both `frankston.com` and\n // `bobf.frankston.com`, drop the longer entry \u2014 the matcher's\n // `endsWith(\".frankston.com\")` test already catches it, so listing\n // both is wasted clutter. Same-pair dedup + parent-shadow filter in\n // one pass.\n function foldDomains(list: string[]): string[] {\n const unique = Array.from(new Set(list));\n return unique.filter(d => !unique.some(p => p !== d && d.endsWith(`.${p}`)));\n }\n const identityDomains: string[] = foldDomains(\n explicitDomains.length > 0\n ? explicitDomains\n : (accountDomain ? [accountDomain] : []),\n );\n function detectReplyFrom(): string | undefined {\n if (!msg) return undefined;\n // Delivered-To is set by the receiving server \u2014 it IS an identity at this\n // account, by definition. Trust it unconditionally when present (after\n // deliveredToPrefix stripping in the service). Fall back to To/Cc only\n // when their domain matches the account's identityDomains, since To/Cc\n // can be set by the sender and aren't authoritative.\n if (msg.deliveredTo) {\n console.log(`[compose] reply From \u2192 ${msg.deliveredTo} (Delivered-To)`);\n return msg.deliveredTo;\n }\n if (identityDomains.length === 0) return undefined;\n const candidates: string[] = [\n ...((msg.to || []).map((a: any) => a.address)),\n ...((msg.cc || []).map((a: any) => a.address)),\n ].filter(Boolean);\n for (const addr of candidates) {\n const domain = addr.split(\"@\")[1]?.toLowerCase();\n if (domain && identityDomains.some(d => domain === d || domain.endsWith(`.${d}`))) {\n console.log(`[compose] reply From \u2192 ${addr} (To/Cc match)`);\n return addr;\n }\n }\n console.log(`[compose] no identity match`);\n return undefined;\n }\n\n // Trace what we're feeding the reply \u2014 `[reply-init]` on the daemon log\n // will show the exact `from`/`to`/`cc` values present on the source\n // message at construction time, so a \"To field empty\" report can be\n // diagnosed without re-deriving state. Bob 2026-05-13: had empty To on\n // a clear-From mailing list message; need to see whether msg.from was\n // populated or arrived as null.\n if (msg) {\n try {\n const dump = {\n mode,\n accountId,\n hasMsg: true,\n from: msg.from || null,\n toLen: Array.isArray(msg.to) ? msg.to.length : -1,\n ccLen: Array.isArray(msg.cc) ? msg.cc.length : -1,\n deliveredTo: msg.deliveredTo || \"\",\n identityDomains,\n subject: msg.subject || \"\",\n };\n const apiClient = (window as any).mailxapi;\n if (apiClient?.logClientEvent) {\n apiClient.logClientEvent(\"reply-init\", dump);\n } else {\n console.log(\"[reply-init]\", dump);\n }\n } catch { /* tracing must never break compose */ }\n } else {\n try {\n (window as any).mailxapi?.logClientEvent?.(\"reply-init\", { mode, accountId, hasMsg: false });\n } catch { /* */ }\n }\n\n // Defensive: msg.from / msg.to may be missing on rows that arrived before\n // headers finished loading. Don't push undefined into init.to \u2014 that\n // bubbles to the compose form as literal \"undefined\". Empty-out gracefully.\n if (msg && mode === \"reply\") {\n init.to = msg.from ? [msg.from] : [];\n init.subject = `Re: ${cleanSubject}`;\n init.bodyHtml = quoteBody(msg);\n init.inReplyTo = msg.messageId || \"\";\n init.references = [...(msg.references || []), msg.messageId].filter(Boolean);\n init.fromAddress = detectReplyFrom();\n } else if (msg && mode === \"replyAll\") {\n const toList: any[] = msg.from ? [msg.from] : [];\n if (Array.isArray(msg.to)) {\n for (const a of msg.to) {\n if (a?.address && a.address !== msg.from?.address) toList.push(a);\n }\n }\n init.to = toList;\n init.cc = Array.isArray(msg.cc) ? msg.cc : [];\n init.subject = `Re: ${cleanSubject}`;\n init.bodyHtml = quoteBody(msg);\n init.inReplyTo = msg.messageId || \"\";\n init.references = [...(msg.references || []), msg.messageId].filter(Boolean);\n init.fromAddress = detectReplyFrom();\n } else if (msg && mode === \"forward\") {\n init.subject = `Fwd: ${cleanSubject}`;\n init.bodyHtml = forwardBody(msg);\n init.fromAddress = detectReplyFrom();\n }\n\n\n // Store init data for compose window to pick up. sessionStorage is the\n // canonical handoff path \u2014 same origin between parent and iframe; the\n // compose IIFE reads it after the editor finishes booting. We also\n // postMessage the iframe so it can short-circuit the listen-for-message\n // wait if it's already past editor init.\n sessionStorage.setItem(\"composeInit\", JSON.stringify(init));\n try { frame?.contentWindow?.postMessage({ type: \"compose-init-ready\" }, \"*\"); } catch { /* */ }\n}\n\nfunction showComposeOverlay(title = \"Compose\"): HTMLIFrameElement {\n const wrapper = document.createElement(\"div\");\n wrapper.className = \"compose-overlay\";\n // Full-screen on small/short screens, floating on larger\n const isSmall = window.innerWidth <= 768 || window.innerHeight <= 600;\n if (isSmall) {\n wrapper.style.cssText = \"position:fixed;inset:0;z-index:1600;display:flex;flex-direction:column;background:#fff;\";\n } else {\n // CSS `resize:both` only gives a single lower-right grip. To allow\n // dragging any edge or corner, we omit it here and attach eight\n // manual handles via addComposeResizeHandles() below.\n // Open horizontally centred, near the top \u2014 not docked to the\n // lower-right corner (Bob 2026-05-18). The title bar still drags it\n // anywhere; this is just the initial placement.\n wrapper.style.cssText = \"position:fixed;top:48px;left:calc((100vw - min(900px,55vw)) / 2);width:min(900px,55vw);height:min(700px,70vh);z-index:1600;border-radius:8px;box-shadow:0 4px 24px rgba(0,0,0,0.3);display:flex;flex-direction:column;overflow:hidden;\";\n }\n\n // Title bar \u2014 drag to move; right-side cluster holds discard, popout, close.\n const titleBar = document.createElement(\"div\");\n titleBar.style.cssText = \"display:flex;align-items:center;justify-content:space-between;padding:4px 8px;background:#e8ecf0;border-radius:8px 8px 0 0;cursor:move;user-select:none;flex-shrink:0;\";\n const titleText = document.createElement(\"span\");\n titleText.textContent = title;\n titleBar.appendChild(titleText);\n\n const btnCluster = document.createElement(\"div\");\n btnCluster.style.cssText = \"display:flex;align-items:center;gap:2px;\";\n titleBar.appendChild(btnCluster);\n\n // Outline SVG icons matched to the rest of the toolbar \u2014 16px, 1.6 stroke,\n // currentColor so hover styles work via plain CSS.\n const SVG_ATTRS = `width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"`;\n const TRASH_SVG = `<svg ${SVG_ATTRS}><path d=\"M3 6h18\"/><path d=\"M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6\"/><path d=\"M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2\"/></svg>`;\n const POPOUT_SVG = `<svg ${SVG_ATTRS}><path d=\"M15 3h6v6\"/><path d=\"M10 14 21 3\"/><path d=\"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\"/></svg>`;\n const RESTORE_SVG = `<svg ${SVG_ATTRS}><path d=\"M21 9v-6h-6\"/><path d=\"M3 15v6h6\"/><path d=\"M21 3l-7 7\"/><path d=\"M3 21l7-7\"/></svg>`;\n const makeIconBtn = (svg: string, titleStr: string, hoverColor: string): HTMLButtonElement => {\n const b = document.createElement(\"button\");\n b.title = titleStr;\n b.style.cssText = \"background:none;border:none;cursor:pointer;color:#666;padding:4px 6px;border-radius:4px;display:inline-flex;align-items:center;justify-content:center;line-height:0;\";\n b.innerHTML = svg;\n b.addEventListener(\"mouseenter\", () => b.style.color = hoverColor);\n b.addEventListener(\"mouseleave\", () => b.style.color = \"#666\");\n return b;\n };\n\n const discardBtn = makeIconBtn(TRASH_SVG, \"Discard draft\", \"#c00\");\n discardBtn.addEventListener(\"click\", () => {\n try {\n const win = frame.contentWindow;\n if (win) win.dispatchEvent(new Event(\"compose-discard\"));\n } catch { /* */ }\n });\n btnCluster.appendChild(discardBtn);\n\n // Popout = toggle between the floating overlay and a host-window-filling\n // layout. The compose iframe can't open in a real OS window (msger custom\n // protocol doesn't propagate to child windows), so \"popout\" here means\n // \"fill the host window\" \u2014 second click restores the floating geometry.\n const popoutBtn = makeIconBtn(POPOUT_SVG, \"Maximize\", \"#000\");\n let maximized = false;\n let savedCss = \"\";\n popoutBtn.addEventListener(\"click\", () => {\n if (!maximized) {\n savedCss = wrapper.style.cssText;\n wrapper.style.cssText = \"position:fixed;inset:0;z-index:1600;display:flex;flex-direction:column;background:#fff;overflow:hidden;\";\n popoutBtn.innerHTML = RESTORE_SVG;\n popoutBtn.title = \"Restore\";\n maximized = true;\n } else {\n wrapper.style.cssText = savedCss;\n popoutBtn.innerHTML = POPOUT_SVG;\n popoutBtn.title = \"Maximize\";\n maximized = false;\n }\n });\n btnCluster.appendChild(popoutBtn);\n\n const closeBtn = document.createElement(\"button\");\n closeBtn.textContent = \"\u2715\";\n closeBtn.title = \"Save draft and close\";\n closeBtn.style.cssText = \"background:none;border:none;font-size:16px;cursor:pointer;color:#666;padding:2px 6px;border-radius:4px;\";\n closeBtn.addEventListener(\"mouseenter\", () => closeBtn.style.color = \"#c00\");\n closeBtn.addEventListener(\"mouseleave\", () => closeBtn.style.color = \"#666\");\n closeBtn.addEventListener(\"click\", () => {\n // compose.ts handles the prompt (Save/Discard/Cancel) and then calls\n // window.close() which is redirected to wrapper.remove() at line below.\n // If the user cancels the prompt, closeCompose() is never called and\n // the wrapper stays. Don't force-remove on a timer \u2014 that defeats Cancel.\n try {\n const win = frame.contentWindow;\n if (win) win.dispatchEvent(new Event(\"compose-save-and-close\"));\n } catch { /* */ }\n });\n btnCluster.appendChild(closeBtn);\n\n // Drag to move. While dragging we set pointer-events:none on the iframe\n // so mouse events don't get swallowed by the inner document the moment\n // the cursor crosses into the iframe region. Without that, drag only\n // worked if you stayed on the title bar pixels, which is why it felt\n // broken except at the lower-right (resize grip) corner.\n let dragX = 0, dragY = 0;\n titleBar.addEventListener(\"mousedown\", (e: MouseEvent) => {\n if (e.target === closeBtn) return;\n e.preventDefault();\n const rect = wrapper.getBoundingClientRect();\n dragX = e.clientX - rect.left;\n dragY = e.clientY - rect.top;\n // Clamp movement to the viewport so the title bar stays grabbable.\n const clamp = (val: number, min: number, max: number) => Math.max(min, Math.min(max, val));\n const shield = installDragShield(\"move\");\n const onMove = (ev: MouseEvent) => {\n ev.preventDefault();\n const w = wrapper.offsetWidth;\n const h = wrapper.offsetHeight;\n const left = clamp(ev.clientX - dragX, 0, window.innerWidth - 40);\n const top = clamp(ev.clientY - dragY, 0, window.innerHeight - 40);\n wrapper.style.left = `${left}px`;\n wrapper.style.top = `${top}px`;\n wrapper.style.bottom = \"auto\";\n wrapper.style.right = \"auto\";\n };\n const onUp = () => {\n shield.remove();\n document.removeEventListener(\"mousemove\", onMove);\n document.removeEventListener(\"mouseup\", onUp);\n };\n document.addEventListener(\"mousemove\", onMove);\n document.addEventListener(\"mouseup\", onUp);\n });\n\n const frame = document.createElement(\"iframe\");\n frame.src = \"compose/compose.html\";\n frame.style.cssText = \"flex:1;border:none;background:#fff;width:100%;\";\n\n // Close when compose calls window.close()\n frame.addEventListener(\"load\", () => {\n try {\n const win = frame.contentWindow;\n if (win) {\n (win as any).close = () => wrapper.remove();\n }\n } catch { /* cross-origin safety */ }\n });\n\n // Bring to front on click\n wrapper.addEventListener(\"mousedown\", () => {\n document.querySelectorAll(\".compose-overlay\").forEach(el => (el as HTMLElement).style.zIndex = \"1000\");\n wrapper.style.zIndex = \"1001\";\n });\n\n wrapper.appendChild(titleBar);\n wrapper.appendChild(frame);\n if (!isSmall) addComposeResizeHandles(wrapper, frame);\n document.body.appendChild(wrapper);\n return frame;\n}\n\n/** Drop a transparent full-viewport shield in front of every other element\n * so mousemove events stay in the document during a drag. Setting\n * pointer-events:none on the compose iframe alone wasn't enough \u2014 the\n * message-list / preview iframes underneath still captured the cursor\n * when it crossed their boundaries, freezing the drag at random\n * midpoints (most visibly at the list\u2194preview seam). One shield blocks\n * every iframe at once. Caller removes it on mouseup. */\nfunction installDragShield(cursor: string): HTMLDivElement {\n const shield = document.createElement(\"div\");\n shield.style.cssText = `position:fixed;inset:0;z-index:9999;background:transparent;cursor:${cursor};user-select:none;`;\n document.body.appendChild(shield);\n return shield;\n}\n\n/** Attach eight resize grippers (4 edges + 4 corners) to a positioned wrapper.\n * CSS `resize:both` only supports a single lower-right corner; replacing it\n * with manual handles is the only way to make any side / corner draggable.\n * Each handle captures the starting geometry on mousedown, then mousemove\n * adjusts width/height/left/top in the directions implied by which edge. */\nfunction addComposeResizeHandles(wrapper: HTMLElement, frame: HTMLIFrameElement): void {\n const MIN_W = 320;\n const MIN_H = 200;\n const dirs: { [k: string]: { cursor: string; style: string } } = {\n n: { cursor: \"ns-resize\", style: \"top:-3px;left:12px;right:12px;height:6px;\" },\n s: { cursor: \"ns-resize\", style: \"bottom:-3px;left:12px;right:12px;height:6px;\" },\n e: { cursor: \"ew-resize\", style: \"right:-3px;top:12px;bottom:12px;width:6px;\" },\n w: { cursor: \"ew-resize\", style: \"left:-3px;top:12px;bottom:12px;width:6px;\" },\n ne: { cursor: \"nesw-resize\", style: \"top:-3px;right:-3px;width:14px;height:14px;\" },\n nw: { cursor: \"nwse-resize\", style: \"top:-3px;left:-3px;width:14px;height:14px;\" },\n se: { cursor: \"nwse-resize\", style: \"bottom:-3px;right:-3px;width:14px;height:14px;\" },\n sw: { cursor: \"nesw-resize\", style: \"bottom:-3px;left:-3px;width:14px;height:14px;\" },\n };\n for (const [dir, conf] of Object.entries(dirs)) {\n const h = document.createElement(\"div\");\n h.style.cssText = `position:absolute;z-index:2;background:transparent;cursor:${conf.cursor};${conf.style}`;\n h.addEventListener(\"mousedown\", (e: MouseEvent) => {\n e.preventDefault();\n e.stopPropagation();\n const rect = wrapper.getBoundingClientRect();\n const startX = e.clientX, startY = e.clientY;\n const startL = rect.left, startT = rect.top;\n const startW = rect.width, startH = rect.height;\n // Pin to top-left so left/top can be adjusted to grow upward/leftward.\n wrapper.style.left = `${startL}px`;\n wrapper.style.top = `${startT}px`;\n wrapper.style.right = \"auto\";\n wrapper.style.bottom = \"auto\";\n const shield = installDragShield(conf.cursor);\n const onMove = (ev: MouseEvent) => {\n const dx = ev.clientX - startX;\n const dy = ev.clientY - startY;\n let newW = startW, newH = startH, newL = startL, newT = startT;\n if (dir.includes(\"e\")) newW = Math.max(MIN_W, startW + dx);\n if (dir.includes(\"w\")) { newW = Math.max(MIN_W, startW - dx); newL = startL + (startW - newW); }\n if (dir.includes(\"s\")) newH = Math.max(MIN_H, startH + dy);\n if (dir.includes(\"n\")) { newH = Math.max(MIN_H, startH - dy); newT = startT + (startH - newH); }\n wrapper.style.width = `${newW}px`;\n wrapper.style.height = `${newH}px`;\n wrapper.style.left = `${newL}px`;\n wrapper.style.top = `${newT}px`;\n };\n const onUp = () => {\n shield.remove();\n document.removeEventListener(\"mousemove\", onMove);\n document.removeEventListener(\"mouseup\", onUp);\n };\n document.addEventListener(\"mousemove\", onMove);\n document.addEventListener(\"mouseup\", onUp);\n });\n wrapper.appendChild(h);\n }\n}\n\n// Marketing-email layout tables (deeply nested, fixed widths) collapse to\n// 30-40px columns inside a phone-width compose pane and wrap text\n// character-by-character. Strip styles + flatten tables before quoting.\nfunction sanitizeQuotedBody(msg: any): string {\n // Two-mode quote: plain-text gets a pre-wrap wrapper so line breaks\n // render as breaks; HTML is preserved verbatim modulo a minimal scrub\n // for tags that have no legitimate place inside a quoted reply.\n //\n // We do NOT strip inline styles, classes, or table attributes any\n // more \u2014 that earlier aggressive strip destroyed 95% of the original\n // sender's formatting (CSS in email is almost entirely inline; see\n // discussion 2026-05-11 about how every mail client preserves the\n // source HTML). Wide content is now clamped via CSS (.reply *\n // { max-width: 100% } in compose.css) instead of by rewriting the\n // DOM. Tables stay tables, paragraphs stay paragraphs, font sizes\n // and colors survive.\n //\n // Script-class tags (<script>, <style>, <link>, <base>, on*= attrs,\n // javascript: URLs) are belt-and-braces \u2014 sanitizeHtml (mailx-types)\n // already strips them at body-store time, so msg.bodyHtml shouldn't\n // contain them. Stripping again here is cheap insurance against a\n // future provider/path that didn't go through that pipeline.\n const isPlainText = !msg.bodyHtml;\n if (isPlainText) {\n // Full HTML escape. Leaving `>` unescaped was tempting for source\n // readability but breaks HTML in edge cases \u2014 TinyMCE's normalize-\n // on-paste re-interprets the input, and stray `>` near sequences\n // like `<!--` / `-->` / `<!` in plain-text bodies can be misread\n // by the parser. Per Bob 2026-05-12: \"not just ugly, it breaks\n // the HTML.\" Trivial source-clutter is the lesser evil.\n //\n // CRLF \u2192 <br>. `white-space:pre-wrap` alone is not enough: TinyMCE\n // (and other HTML editors) normalize text-node whitespace when\n // setContent ingests the HTML, collapsing `\\n` to spaces BEFORE\n // CSS runs. The literal `<br>` survives the normalization, so we\n // get one line break per source line whether the editor preserves\n // raw whitespace or not. pre-wrap stays as belt-and-braces and to\n // keep multi-space runs (alignment, indented quote-markers like\n // `> > >`) visible.\n const escaped = String(msg.bodyText || \"\")\n .replace(/&/g, \"&\").replace(/</g, \"<\").replace(/>/g, \">\")\n .replace(/\\r\\n?/g, \"\\n\")\n .replace(/\\n/g, \"<br>\");\n return `<div style=\"white-space:pre-wrap;font-family:inherit;margin:0\">${escaped}</div>`;\n }\n let body: string = msg.bodyHtml;\n // Minimal defense-in-depth strip. <style> blocks would leak global\n // CSS into the compose document; <link> / <base> would fetch remote\n // resources; <script> would be inert in contenteditable but the tag\n // would persist into the sent message which is rude.\n body = body.replace(/<script[^>]*>[\\s\\S]*?<\\/script>/gi, \"\");\n body = body.replace(/<style[^>]*>[\\s\\S]*?<\\/style>/gi, \"\");\n body = body.replace(/<link[^>]*>/gi, \"\");\n body = body.replace(/<base[^>]*>/gi, \"\");\n body = body.replace(/\\s+on\\w+=\"[^\"]*\"/gi, \"\");\n body = body.replace(/\\s+on\\w+='[^']*'/gi, \"\");\n return body;\n}\n\nfunction quoteBody(msg: any): string {\n const date = new Date(msg.date).toLocaleString();\n const from = msg.from.name ? `${msg.from.name} <${msg.from.address}>` : msg.from.address;\n const body = sanitizeQuotedBody(msg);\n // Lead with an empty paragraph so every editor has a real block for the\n // caret to land in and the user's reply to flow into \u2014 bare <br>s aren't\n // a block container, so TinyMCE's caret fell through to the quote (Bob\n // 2026-05-21). The blank-line spacing lives in <br>s AFTER the </p>, not\n // inside it.\n return `<p></p><br><br><div class=\"reply\"><p>On ${date}, ${from} wrote:</p><blockquote>${body}</blockquote></div>`;\n}\n\nfunction forwardBody(msg: any): string {\n const date = new Date(msg.date).toLocaleString();\n const from = msg.from.name ? `${msg.from.name} <${msg.from.address}>` : msg.from.address;\n const to = msg.to.map((a: any) => a.name ? `${a.name} <${a.address}>` : a.address).join(\", \");\n const body = sanitizeQuotedBody(msg);\n return `<p></p><br><br><div class=\"reply\"><p>---------- Forwarded message ----------<br>From: ${from}<br>Date: ${date}<br>Subject: ${msg.subject}<br>To: ${to}</p>${body}</div>`;\n}\n\n// \u2500\u2500 Delete with undo \u2500\u2500\n\ninterface DeletedMessage {\n accountId: string;\n uid: number;\n folderId: number;\n subject: string;\n}\n\ninterface MovedBatch {\n messages: { accountId: string; uid: number; sourceFolderId: number }[];\n targetAccountId: string;\n targetFolderId: number;\n}\n\nlet lastDeleted: DeletedMessage | null = null;\nlet lastMoved: MovedBatch | null = null;\nlet undoTimeout: ReturnType<typeof setTimeout> | null = null;\n\n/** Route a \"delete the selection\" action (Delete key, Ctrl+D, top trash\n * button) to whichever pane has a selection. Tasks take priority \u2014 if\n * any task rows are selected, delete those; otherwise fall through to\n * messages. The previous behavior was to always delete messages, which\n * surprised users who'd just selected a few tasks and hit Delete. */\nasync function deleteSelection(): Promise<void> {\n try {\n const sidebar = await import(\"./components/calendar-sidebar.js\");\n if (sidebar.getSelectedTaskUuids().length > 0) {\n await sidebar.deleteSelectedTasks();\n return;\n }\n } catch { /* sidebar module not loaded yet \u2014 fall through to messages */ }\n await deleteSelectedMessages();\n}\n\nasync function deleteSelectedMessages(): Promise<void> {\n const selected = getSelectedMessages();\n\n // Fall back to single message from viewer if nothing selected in list\n if (selected.length === 0) {\n const current = getCurrentMessage();\n if (!current) return;\n selected.push({ accountId: current.accountId, uid: current.message.uid, folderId: current.message.folderId });\n }\n\n const statusSync = document.getElementById(\"status-sync\");\n\n // Optimistic UI: remove from list IMMEDIATELY, then queue the IPC.\n // Old order awaited the daemon round-trip (IPC + DB updates) before\n // the rows disappeared, which felt sluggish on bigger selections or\n // when the IPC was congested. Spam button already worked this way;\n // trash now matches. If the IPC fails, the next folder reload\n // re-populates the row and the catch block surfaces the error.\n const snapshot = [...selected];\n removeMessagesAndReconcile(selected);\n\n // Undo support set immediately too \u2014 Ctrl+Z works the moment rows\n // disappear from the list, not only after the daemon ACKs.\n if (snapshot.length === 1) {\n lastDeleted = { ...snapshot[0], subject: \"\" };\n if (statusSync) statusSync.textContent = `Trashed 1 message (syncing) \u2014 Ctrl+Z to undo`;\n } else {\n lastDeleted = null;\n if (statusSync) statusSync.textContent = `Trashed ${snapshot.length} messages (syncing)`;\n }\n if (undoTimeout) clearTimeout(undoTimeout);\n undoTimeout = setTimeout(() => {\n lastDeleted = null;\n if (statusSync?.textContent?.includes(\"undo\")) statusSync.textContent = \"\";\n }, 30000);\n\n // Fire-and-forget per local-first: optimistic remove above already\n // updated the UI; the daemon-side trash is sync DB + queued IMAP.\n // An IPC 120s timeout doesn't mean the trash failed \u2014 surfacing it\n // as a status-bar error would only mislead. Real errors are still\n // reported by next sync's diagnostics.\n const byAccount = new Map<string, number[]>();\n for (const msg of snapshot) {\n const uids = byAccount.get(msg.accountId) || [];\n uids.push(msg.uid);\n byAccount.set(msg.accountId, uids);\n }\n for (const [accountId, uids] of byAccount) {\n deleteMessages(accountId, uids).catch((e: any) => {\n console.error(`Delete failed for ${accountId}: ${e?.message || e}`);\n if (statusSync) statusSync.textContent = `Delete sync issue (${accountId}): ${e?.message || e}`;\n });\n }\n}\n\nasync function undoDelete(): Promise<void> {\n if (!lastDeleted) return;\n const { accountId, uid, folderId } = lastDeleted;\n\n try {\n await undeleteMessage(accountId, uid, folderId);\n\n const statusSync = document.getElementById(\"status-sync\");\n if (statusSync) statusSync.textContent = \"Message restored\";\n lastDeleted = null;\n if (undoTimeout) clearTimeout(undoTimeout);\n reloadCurrentFolder();\n } catch (e: any) {\n console.error(`Undo failed: ${e.message}`);\n }\n}\n\nasync function undoMove(): Promise<void> {\n if (!lastMoved) return;\n const { messages } = lastMoved;\n const statusSync = document.getElementById(\"status-sync\");\n try {\n // Group by (sourceAccountId, sourceFolderId) and move each group back\n const byDest = new Map<string, { accountId: string; folderId: number; uids: number[] }>();\n for (const m of messages) {\n const key = `${m.accountId}:${m.sourceFolderId}`;\n if (!byDest.has(key)) byDest.set(key, { accountId: m.accountId, folderId: m.sourceFolderId, uids: [] });\n byDest.get(key)!.uids.push(m.uid);\n }\n const { moveMessages, moveMessage } = await import(\"./lib/api-client.js\");\n for (const group of byDest.values()) {\n if (group.uids.length === 1) await moveMessage(group.accountId, group.uids[0], group.folderId);\n else await moveMessages(group.accountId, group.uids, group.folderId);\n }\n if (statusSync) statusSync.textContent = `Undid move of ${messages.length} message${messages.length !== 1 ? \"s\" : \"\"}`;\n lastMoved = null;\n if (undoTimeout) clearTimeout(undoTimeout);\n reloadCurrentFolder();\n } catch (e: any) {\n console.error(`Undo move failed: ${e.message}`);\n if (statusSync) statusSync.textContent = `Undo move failed: ${e.message}`;\n }\n}\n\n// Listen for the \"mailx-moved\" custom event emitted by folder-tree's drop\n// handler so Ctrl+Z can reverse the most recent move.\ndocument.addEventListener(\"mailx-moved\", (e: any) => {\n lastMoved = e.detail as MovedBatch;\n lastDeleted = null; // Ctrl+Z undoes whichever came last\n if (undoTimeout) clearTimeout(undoTimeout);\n undoTimeout = setTimeout(() => { lastMoved = null; }, 60000);\n});\n\ndocument.getElementById(\"btn-delete\")?.addEventListener(\"click\", deleteSelection);\n// Same handlers also bound to the top-toolbar icons so delete/spam work\n// regardless of whether a message is open in the viewer. Useful for quick\n// triage from a list-only view. Top trash button uses deleteSelection so\n// it follows the user's task selection when present (otherwise messages).\ndocument.getElementById(\"btn-tb-delete\")?.addEventListener(\"click\", deleteSelection);\ndocument.getElementById(\"btn-tb-spam\")?.addEventListener(\"click\", spamSelectedMessages);\n\n// \u2500\u2500 Flag toggle \u2500\u2500\n/** Sync the toolbar Flag button (glyph + gold colour) to the viewed\n * message's flagged state, so it mirrors the message-list row star. */\nfunction updateFlagButton(): void {\n const btn = document.getElementById(\"btn-flag\");\n if (!btn) return;\n const sel = getCurrentFocused();\n const yes = !!sel && flaggedOf(sel);\n btn.classList.toggle(\"flagged\", yes);\n btn.textContent = yes ? \"\u2605\" : \"\u2606\";\n}\ndocument.addEventListener(\"mailx-message-shown\", updateFlagButton);\n\ndocument.getElementById(\"btn-flag\")?.addEventListener(\"click\", async () => {\n const sel = getCurrentFocused();\n if (!sel) return;\n const wasFlagged = flaggedOf(sel);\n setFlagged(sel, !wasFlagged);\n updateFlagButton();\n try {\n await updateFlags(sel.accountId, sel.uid, sel.flags);\n messageState.updateMessageFlags(sel.accountId, sel.uid, sel.flags);\n // Row owns its own DOM \\u2014 go through the row object so class + star\n // update atomically and the list/preview stay in sync.\n setRowFlagged(sel.accountId, sel.uid, !wasFlagged);\n } catch (e: unknown) {\n // Revert local state on failure so visual + data stay consistent.\n setFlagged(sel, wasFlagged);\n updateFlagButton();\n console.error(`Flag toggle failed: ${(e as Error).message}`);\n }\n});\n\nasync function spamSelectedMessages(): Promise<void> {\n console.log(\"[spam] click \u2014 finding selection\");\n const selected = getSelectedMessages();\n if (selected.length === 0) {\n const current = getCurrentMessage();\n if (!current) {\n console.warn(\"[spam] no message selected and none in viewer \u2014 nothing to do\");\n alert(\"No message selected. Click a message first, then the spam button.\");\n return;\n }\n selected.push({ accountId: current.accountId, uid: current.message.uid, folderId: current.message.folderId });\n }\n console.log(`[spam] marking ${selected.length} message(s):`, selected);\n const statusSync = document.getElementById(\"status-sync\");\n // Optimistic: remove from list immediately so the user sees action happen.\n // If the IPC fails, put them back. This matches local-first \u2014 the server\n // sync is a background detail, the user's action should feel instant.\n const snapshot = [...selected];\n removeMessagesAndReconcile(selected);\n // Fire-and-forget per local-first: the optimistic remove above has\n // already updated the UI; the service-side move is sync DB + queued\n // IMAP. An IPC 120s timeout here doesn't mean the move failed \u2014 it\n // means the response is stuck behind a long-running prior op (e.g.\n // simpleParser blocking the event loop). The local commit and server\n // sync still happen. Surfacing it as a failure with an alert lies to\n // the user; the next folder reload reconciles either way.\n const byAccount = new Map<string, number[]>();\n for (const msg of snapshot) {\n const uids = byAccount.get(msg.accountId) || [];\n uids.push(msg.uid);\n byAccount.set(msg.accountId, uids);\n }\n if (statusSync) statusSync.textContent = `Spam: ${snapshot.length} queued \u2014 pending server sync`;\n for (const [accountId, uids] of byAccount) {\n markAsSpamMessages(accountId, uids)\n .then(result => {\n console.log(`[spam] ${accountId}: moved ${result?.moved ?? uids.length} to folderId=${result?.targetFolderId}`);\n })\n .catch(e => {\n console.error(`[spam] ${accountId} failed:`, e);\n if (statusSync) statusSync.textContent = `Spam sync issue (${accountId}): ${e?.message || e}`;\n });\n }\n}\n\ndocument.getElementById(\"btn-spam\")?.addEventListener(\"click\", spamSelectedMessages);\n\n/** Show/hide the Spam button based on whether the current account has \"spam\" configured. */\nasync function refreshSpamButtonVisibility(): Promise<void> {\n const btn = document.getElementById(\"btn-spam\") as HTMLButtonElement | null;\n if (!btn) return;\n const current = getCurrentMessage();\n const accountId = current?.accountId || currentAccountId;\n if (!accountId) { btn.hidden = true; return; }\n try {\n const accounts = await getAccounts();\n const acct = accounts.find((a: any) => a.id === accountId);\n btn.hidden = !acct?.spam;\n } catch { btn.hidden = true; }\n}\n\ndocument.addEventListener(\"mailx-message-shown\", refreshSpamButtonVisibility);\ndocument.addEventListener(\"mailx-folder-changed\", refreshSpamButtonVisibility);\n\n// Q100 placeholder \u2014 append a row to ~/.mailx/spam.csv for later analysis.\n// No folder move, no flag change, no auto-delete. Button is always visible\n// (no configuration required; unlike btn-spam which needs a junk folder).\ndocument.getElementById(\"btn-spam-report\")?.addEventListener(\"click\", async () => {\n const current = getCurrentMessage();\n const msg = current?.message;\n const accountId = current?.accountId;\n if (!msg || !accountId) return;\n const btn = document.getElementById(\"btn-spam-report\") as HTMLButtonElement;\n const originalLabel = btn.textContent;\n btn.disabled = true;\n btn.textContent = \"\u2026\";\n try {\n const { recordSpamReport } = await import(\"./lib/api-client.js\");\n await recordSpamReport(accountId, msg.uid, msg.folderId);\n btn.textContent = \"\u2713\";\n const status = document.getElementById(\"status-sync\");\n if (status) status.textContent = \"Logged to ~/.rmfmail/spam.csv\";\n setTimeout(() => { btn.textContent = originalLabel; btn.disabled = false; }, 1500);\n } catch (e: any) {\n btn.textContent = \"\u2717\";\n const status = document.getElementById(\"status-sync\");\n if (status) status.textContent = `Spam log failed: ${e?.message || e}`;\n setTimeout(() => { btn.textContent = originalLabel; btn.disabled = false; }, 2500);\n }\n});\n\ndocument.getElementById(\"btn-compose\")?.addEventListener(\"click\", () => openCompose(\"new\"));\n\ndocument.getElementById(\"btn-mark-unread\")?.addEventListener(\"click\", () => {\n // Toggle \\Seen on the currently-focused message. Mirrors the R\n // keyboard shortcut and the right-click \"Mark unread\" menu item, but\n // as a visible toolbar button so users discover the behavior.\n const sel = getCurrentFocused();\n if (!sel) return;\n const wasSeen = seenOf(sel);\n setSeen(sel, !wasSeen);\n updateFlags(sel.accountId, sel.uid, sel.flags).then(() => {\n messageState.updateMessageFlags(sel.accountId, sel.uid, sel.flags);\n const row = document.querySelector(`.ml-row[data-uid=\"${sel.uid}\"][data-account-id=\"${sel.accountId}\"]`) as HTMLElement;\n if (row) row.classList.toggle(\"unread\", wasSeen);\n }).catch(() => { setSeen(sel, wasSeen); });\n});\n\ndocument.getElementById(\"btn-reply\")?.addEventListener(\"click\", () => openCompose(\"reply\"));\ndocument.getElementById(\"btn-reply-all\")?.addEventListener(\"click\", () => openCompose(\"replyAll\"));\ndocument.getElementById(\"btn-forward\")?.addEventListener(\"click\", () => openCompose(\"forward\"));\n\n// \u2500\u2500 Icon rail wiring \u2500\u2500\n// Rail is the always-visible vertical bar on the far left (Thunderbird/Dovecot\n// style). Mostly mirrors toolbar/menu actions for one-click access; calendar /\n// tasks / contacts buttons are placeholders until those features ship.\ndocument.getElementById(\"rail-compose\")?.addEventListener(\"click\", () => openCompose(\"new\"));\ndocument.getElementById(\"rail-inbox\")?.addEventListener(\"click\", () => {\n // Trigger the existing folder-tree click on the first inbox folder.\n const inbox = document.querySelector('.folder-tree .folder-item[data-special-use=\"inbox\"]') as HTMLElement | null;\n inbox?.click();\n});\ndocument.getElementById(\"rail-unified\")?.addEventListener(\"click\", () => {\n const unified = document.querySelector('.folder-tree .all-inboxes') as HTMLElement | null\n || document.getElementById(\"ft-all-inboxes\");\n unified?.click();\n});\ndocument.getElementById(\"rail-contacts\")?.addEventListener(\"click\", async () => {\n const { openAddressBook } = await import(\"./components/address-book.js\");\n openAddressBook();\n setRailActive(\"rail-contacts\");\n});\n// Q114 decided 2026-04-24: full-screen calendar/tasks modals are\n// temporarily retired \u2014 the right-docked sidebar (calendar-sidebar.ts)\n// owns both views. Rail buttons now just reveal the sidebar. Files kept\n// (`calendar.ts`, `tasks.ts`) for potential revival; not imported.\ndocument.getElementById(\"rail-calendar\")?.addEventListener(\"click\", async () => {\n const { showCalendarSidebar } = await import(\"./components/calendar-sidebar.js\");\n await showCalendarSidebar();\n // Flip the View-menu checkbox so the on-state stays coherent across paths.\n const optSidebar = document.getElementById(\"opt-calendar-sidebar\") as HTMLInputElement | null;\n if (optSidebar) optSidebar.checked = true;\n setRailActive(\"rail-calendar\");\n});\ndocument.getElementById(\"rail-tasks\")?.addEventListener(\"click\", async () => {\n const { showCalendarSidebar } = await import(\"./components/calendar-sidebar.js\");\n await showCalendarSidebar();\n // Scroll the sidebar to the tasks section if possible.\n document.getElementById(\"cal-side-tasks\")?.scrollIntoView({ block: \"start\", behavior: \"smooth\" });\n const optSidebar = document.getElementById(\"opt-calendar-sidebar\") as HTMLInputElement | null;\n if (optSidebar) optSidebar.checked = true;\n setRailActive(\"rail-tasks\");\n});\n/** Open a toolbar dropdown (View / Settings) anchored to a rail icon.\n *\n * In wide mode the toolbar buttons own the menu and do their own toggle.\n * In narrow mode the toolbar is `display:none`, so the dropdown \u2014 which\n * lives as a child of `.tb-menu` inside the toolbar \u2014 is invisible no\n * matter what `hidden` is set to. Forwarding the rail click to the\n * toolbar button toggled `hidden` but the user still saw nothing, hence\n * the \"setup icon does nothing\" bug report.\n *\n * The fix: when a rail icon opens the menu, reparent the dropdown to\n * `<body>` (so the toolbar's display:none can't hide it), switch to\n * `position: fixed` anchored to the icon, and let the existing menu\n * handlers fire normally \u2014 same dropdown DOM, same listeners, same\n * content. We never put the dropdown back: the toolbar handler also\n * works on a body-attached dropdown (it's just toggling .hidden), and\n * re-parenting on every toggle would defeat any focus state inside. */\nfunction openMenuFromRail(dropdownId: string, _anchor: HTMLElement): void {\n const dd = document.getElementById(dropdownId);\n if (!dd) return;\n if (!dd.hidden) { dd.hidden = true; return; }\n // Close sibling rail-opened menus so two don't stack.\n for (const id of [\"settings-dropdown\", \"view-dropdown\", \"restart-dropdown\"]) {\n const other = document.getElementById(id);\n if (other && other !== dd) other.hidden = true;\n }\n if (dd.parentElement?.classList.contains(\"tb-menu\")) {\n document.body.appendChild(dd);\n }\n // Uniform behavior across desktop and Android: render rail-opened menus\n // as a centered modal with a dimmed backdrop. Anchor-based positioning\n // was inconsistent (worked on Android narrow but not Mac/desktop wide;\n // bottom-rail icons sit at the viewport floor and the menu spilled off\n // or landed under chrome). A centered card is always visible, always\n // dismissible by tapping the backdrop, and the same on every platform.\n dd.style.cssText = \"\";\n dd.style.position = \"fixed\";\n dd.style.top = \"50%\";\n dd.style.left = \"50%\";\n dd.style.transform = \"translate(-50%, -50%)\";\n dd.style.maxHeight = \"85vh\";\n dd.style.maxWidth = \"92vw\";\n dd.style.overflowY = \"auto\";\n dd.style.boxShadow = \"0 8px 32px rgba(0,0,0,0.4)\";\n dd.style.zIndex = \"10000\";\n dd.hidden = false;\n ensureRailMenuBackdrop();\n}\n\n/** Restore a body-parented rail-opened dropdown back to its toolbar parent\n * and clear inline positioning, so the toolbar button's plain\n * `dd.hidden = !dd.hidden` toggle renders the dropdown in its usual\n * position-absolute place. Without this, the toolbar dropdown opens at\n * the rail-modal's centered position (or somewhere else stale) the first\n * time a user mixes rail-open and toolbar-open in the same session. */\nfunction restoreToolbarDropdown(ddId: string, tbMenuId: string): void {\n const dd = document.getElementById(ddId);\n const tb = document.getElementById(tbMenuId);\n if (!dd || !tb) return;\n if (dd.parentElement === document.body) {\n tb.appendChild(dd);\n dd.style.cssText = \"\";\n }\n}\n\n/** Narrow-tier rail menus need a tap-anywhere-to-close backdrop because the\n * rail itself stays open under the modal. The document outside-click handler\n * hides the dropdown but doesn't visually convey \"modal\" \u2014 the backdrop\n * dims the page, makes the modal look like one, and gives a single\n * unambiguous click target for dismissal. Auto-removes when the dropdown\n * hides (we observe the [hidden] flip via MutationObserver). */\nfunction ensureRailMenuBackdrop(): void {\n if (document.getElementById(\"rail-menu-backdrop\")) return;\n const bd = document.createElement(\"div\");\n bd.id = \"rail-menu-backdrop\";\n bd.style.cssText = \"position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9999;\";\n bd.addEventListener(\"click\", () => {\n for (const id of [\"settings-dropdown\", \"view-dropdown\", \"restart-dropdown\"]) {\n const dd = document.getElementById(id);\n if (dd && !dd.hidden) dd.hidden = true;\n }\n bd.remove();\n });\n document.body.appendChild(bd);\n // Self-clean when no rail menu is open\n const cleanup = () => {\n const anyOpen = [\"settings-dropdown\", \"view-dropdown\", \"restart-dropdown\"]\n .some(id => { const el = document.getElementById(id); return el && !el.hidden; });\n if (!anyOpen) bd.remove();\n };\n const obs = new MutationObserver(cleanup);\n for (const id of [\"settings-dropdown\", \"view-dropdown\", \"restart-dropdown\"]) {\n const el = document.getElementById(id);\n if (el) obs.observe(el, { attributes: true, attributeFilter: [\"hidden\"] });\n }\n}\n\ndocument.getElementById(\"rail-settings\")?.addEventListener(\"click\", (e) => {\n e.stopPropagation();\n openMenuFromRail(\"settings-dropdown\", e.currentTarget as HTMLElement);\n});\ndocument.getElementById(\"rail-view\")?.addEventListener(\"click\", (e) => {\n e.stopPropagation();\n openMenuFromRail(\"view-dropdown\", e.currentTarget as HTMLElement);\n});\ndocument.getElementById(\"rail-help\")?.addEventListener(\"click\", () => {\n document.getElementById(\"btn-about\")?.click();\n});\n// rail-theme button removed 2026-05-04 \u2014 theme picker lives in the Settings\n// dropdown (collapsed into a single \"Theme: <current> \u25B8\" item with a popout).\n// Removing the dedicated rail icon was the user's call; keeping the radios in\n// Settings is enough since the picker is rare-use.\n\nfunction applyTheme(theme: \"system\" | \"light\" | \"dark\"): void {\n document.documentElement.setAttribute(\"data-theme\", theme);\n try { localStorage.setItem(\"mailx-theme\", theme); } catch { /* private mode */ }\n // Reflect in the Settings menu radios so the two paths stay in sync.\n const radio = document.getElementById(`opt-theme-${theme}`) as HTMLInputElement | null;\n if (radio) radio.checked = true;\n // Update the submenu's \"current\" badge so the user sees the choice\n // without expanding.\n const cur = document.getElementById(\"theme-submenu-current\");\n if (cur) cur.textContent = theme.charAt(0).toUpperCase() + theme.slice(1);\n}\n\n/** Set the document root font-size in px. The CSS uses rem-based sizes, so\n * changing the root scales the entire UI proportionally. Persisted to\n * localStorage and to preferences.jsonc.ui.fontSize (the latter syncs\n * across devices via GDrive). The pref already existed in the schema\n * with a 15px default \u2014 it just wasn't wired up. */\nfunction applyFontSize(px: number): void {\n const clamped = Math.max(10, Math.min(24, px));\n document.documentElement.style.fontSize = `${clamped}px`;\n try { localStorage.setItem(\"mailx-font-size\", String(clamped)); } catch { /* private mode */ }\n}\n\n// Restore saved font size from localStorage on startup. preferences.jsonc\n// is the cloud-synced source of truth but reading it requires async IPC;\n// localStorage gives us a synchronous, instant first paint at the right\n// size. The async path can update later if the cloud value differs.\n(() => {\n const saved = (() => {\n try { return parseInt(localStorage.getItem(\"mailx-font-size\") || \"15\", 10); } catch { return 15; }\n })();\n applyFontSize(Number.isFinite(saved) ? saved : 15);\n})();\n\n// Restore saved theme + wire the Settings radios. Defaults to \"system\".\n(() => {\n const saved = (() => { try { return localStorage.getItem(\"mailx-theme\") || \"system\"; } catch { return \"system\"; } })();\n applyTheme(saved as \"system\" | \"light\" | \"dark\");\n for (const t of [\"system\", \"light\", \"dark\"] as const) {\n document.getElementById(`opt-theme-${t}`)?.addEventListener(\"change\", (e) => {\n if ((e.target as HTMLInputElement).checked) applyTheme(t);\n });\n }\n // Wire the submenu toggle. Click to expand; click outside / on a radio closes.\n const togBtn = document.getElementById(\"theme-submenu-toggle\");\n const popout = document.getElementById(\"theme-submenu-popout\");\n if (togBtn && popout) {\n togBtn.addEventListener(\"click\", (e) => {\n e.stopPropagation();\n const open = popout.hidden;\n popout.hidden = !open;\n togBtn.setAttribute(\"aria-expanded\", String(open));\n });\n // Close on radio change so the menu doesn't linger after the choice.\n popout.querySelectorAll<HTMLInputElement>('input[type=\"radio\"]').forEach(r => {\n r.addEventListener(\"change\", () => {\n popout.hidden = true;\n togBtn.setAttribute(\"aria-expanded\", \"false\");\n });\n });\n }\n})();\n// Highlight the current rail target. For now just inbox is the default; once\n// calendar/tasks ship, update this on view change.\nfunction setRailActive(id: string): void {\n document.querySelectorAll(\".rail-btn[data-active]\").forEach(el => el.removeAttribute(\"data-active\"));\n document.getElementById(id)?.setAttribute(\"data-active\", \"true\");\n}\ndocument.addEventListener(\"mailx-folder-changed\", () => setRailActive(\"rail-inbox\"));\n\n// Context menu events from message-list right-click\ndocument.addEventListener(\"mailx-compose\", ((e: CustomEvent) => {\n if (e.detail.mode === \"draft\" && sessionStorage.getItem(\"composeInit\")) {\n // Draft already stored by viewer \u2014 just show overlay\n showComposeOverlay();\n } else {\n openCompose(e.detail.mode);\n }\n}) as EventListener);\ndocument.addEventListener(\"mailx-delete\", () => deleteSelection());\n\n// Share / mailto intents (Android-side: MainActivity routes share-sheet\n// invocations and mailto: links here. Desktop has no equivalent today \u2014\n// MSIX / Windows share-target requires packaging changes we deferred).\n// Payload: { action, text, subject, mailto }. Mailto URLs are parsed\n// (RFC 2368: mailto:to?subject=...&body=...&cc=...&bcc=...) and used to\n// pre-fill compose. Plain shares (text/url) drop into the body / subject.\ndocument.addEventListener(\"mailx-share-intent\", ((e: CustomEvent) => {\n const detail = e.detail || {};\n const init: any = { mode: \"new\", to: [], cc: [], bcc: [], subject: \"\", bodyHtml: \"\" };\n if (detail.mailto) {\n try {\n const url = new URL(detail.mailto);\n const to = decodeURIComponent(url.pathname).split(\",\").map((s: string) => s.trim()).filter(Boolean);\n init.to = to;\n const sp = url.searchParams;\n const subj = sp.get(\"subject\"); if (subj) init.subject = subj;\n const body = sp.get(\"body\"); if (body) init.bodyHtml = `<p>${body.replace(/\\n/g, \"<br>\")}</p>`;\n const cc = sp.get(\"cc\"); if (cc) init.cc = cc.split(\",\").map(s => s.trim()).filter(Boolean);\n const bcc = sp.get(\"bcc\"); if (bcc) init.bcc = bcc.split(\",\").map(s => s.trim()).filter(Boolean);\n } catch { /* malformed mailto: \u2014 drop the URL into body so user can fix */ init.bodyHtml = `<p>${escapeHtml(detail.mailto)}</p>`; }\n } else {\n if (detail.subject) init.subject = detail.subject;\n if (detail.text) init.bodyHtml = `<p>${escapeHtml(String(detail.text)).replace(/\\n/g, \"<br>\")}</p>`;\n }\n sessionStorage.setItem(\"composeInit\", JSON.stringify(init));\n showComposeOverlay(init.subject || \"Compose\");\n}) as EventListener);\n\n// \u2500\u2500 Search \u2500\u2500\n\nlet searchTimeout: ReturnType<typeof setTimeout>;\nconst searchInput = document.getElementById(\"search-input\") as HTMLInputElement;\nconst searchScope = document.getElementById(\"search-scope\") as HTMLSelectElement;\n\n// Recent-searches dropdown \u2014 backed by localStorage, populated into the\n// existing <datalist id=\"search-history\"> so the browser's native picker\n// shows on focus/click. No edit / delete UI; editing is implicit (pick a\n// past query, modify, hit Enter \u2014 the modified version is recorded).\n// Cap at 25 to keep the list usable.\nconst SEARCH_HISTORY_KEY = \"mailx-search-history\";\nconst SEARCH_HISTORY_MAX = 25;\nfunction loadSearchHistory(): string[] {\n try { return JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) || \"[]\"); }\n catch { return []; }\n}\nfunction saveSearchHistory(list: string[]): void {\n try { localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(list)); }\n catch { /* private mode */ }\n}\nfunction refreshSearchHistoryDatalist(): void {\n const dl = document.getElementById(\"search-history\") as HTMLDataListElement | null;\n if (!dl) return;\n const items = loadSearchHistory();\n dl.innerHTML = items.map(q => `<option value=\"${q.replace(/\"/g, \""\")}\"></option>`).join(\"\");\n}\nfunction recordSearchHistory(query: string): void {\n const trimmed = query.trim();\n if (!trimmed) return;\n const cur = loadSearchHistory();\n // Move-to-front dedup, AND drop any entry that is a prefix of this\n // query \u2014 that collapses the in-progress typing (\"Hod\", \"Hodd\") into\n // the final \"Hoddie\", so a live search the user never pressed Enter on\n // still lands in history exactly once.\n const filtered = cur.filter(q => q !== trimmed && !trimmed.startsWith(q));\n filtered.unshift(trimmed);\n if (filtered.length > SEARCH_HISTORY_MAX) filtered.length = SEARCH_HISTORY_MAX;\n saveSearchHistory(filtered);\n refreshSearchHistoryDatalist();\n}\nrefreshSearchHistoryDatalist();\n\n// \u2500\u2500 Live search-syntax highlighting \u2500\u2500\n// An overlay behind the search input colors recognized qualifiers\n// (from:/to:/subject:/date:/after:/before:/has:/is:/folder:), FTS boolean\n// operators (OR/NOT/AND), quoted phrases, and /regex/ literals \u2014 reparsed\n// on every keystroke so the user sees how mailx will interpret the query.\n// An invalid /regex/ only goes red after a brief settle delay, so it\n// doesn't flash red while the pattern is still being typed.\nconst searchHl = document.getElementById(\"search-hl\");\nconst SEARCH_QUALIFIERS = new Set([\"from\", \"to\", \"subject\", \"date\", \"after\", \"before\", \"has\", \"is\", \"folder\"]);\nconst SEARCH_REGEX_SETTLE_MS = 500;\nlet searchRegexSettleTimer: ReturnType<typeof setTimeout> | null = null;\n\nfunction escapeHl(s: string): string {\n return s.replace(/[&<>]/g, c => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\" }[c] || c));\n}\n\nfunction renderSearchHighlight(settled: boolean): void {\n if (!searchHl || !searchInput) return;\n const text = searchInput.value;\n // Tokenize keeping whitespace runs so the overlay lines up glyph-for-\n // glyph with the input's own text.\n const tokens = text.match(/\\s+|\"[^\"]*\"?|\\S+/g) || [];\n let html = \"\";\n for (const tok of tokens) {\n if (/^\\s+$/.test(tok)) { html += escapeHl(tok); continue; }\n // /regex/ literal \u2014 leading slash, body, optional closing slash.\n if (tok.startsWith(\"/\") && tok.length > 1) {\n let bad = false;\n if (tok.endsWith(\"/\")) {\n try { new RegExp(tok.slice(1, -1), \"i\"); } catch { bad = true; }\n }\n const cls = (bad && settled) ? \"sh-regex-bad\" : \"sh-regex\";\n html += `<span class=\"${cls}\">${escapeHl(tok)}</span>`;\n continue;\n }\n // qualifier:value \u2014 color the keyword: prefix when recognized.\n const qm = tok.match(/^([a-z]+):(.*)$/i);\n if (qm && SEARCH_QUALIFIERS.has(qm[1].toLowerCase())) {\n html += `<span class=\"sh-key\">${escapeHl(qm[1] + \":\")}</span>${escapeHl(qm[2])}`;\n continue;\n }\n // FTS boolean operator.\n if (/^(OR|NOT|AND)$/.test(tok)) {\n html += `<span class=\"sh-op\">${escapeHl(tok)}</span>`;\n continue;\n }\n // Quoted phrase.\n if (tok.startsWith(\"\\\"\")) {\n html += `<span class=\"sh-phrase\">${escapeHl(tok)}</span>`;\n continue;\n }\n html += escapeHl(tok);\n }\n searchHl.innerHTML = html;\n searchHl.scrollLeft = searchInput.scrollLeft;\n}\n\nfunction updateSearchHighlight(): void {\n renderSearchHighlight(false);\n if (searchRegexSettleTimer) clearTimeout(searchRegexSettleTimer);\n searchRegexSettleTimer = setTimeout(() => renderSearchHighlight(true), SEARCH_REGEX_SETTLE_MS);\n}\n\n// Keep the overlay scroll-aligned when the input scrolls horizontally,\n// and seed it once at startup.\nsearchInput?.addEventListener(\"scroll\", () => {\n if (searchHl) searchHl.scrollLeft = searchInput.scrollLeft;\n});\nsearchInput?.addEventListener(\"change\", () => updateSearchHighlight());\nupdateSearchHighlight();\n\n// Server search is slow (per-folder IMAP SEARCH across ~90 folders). It must\n// never gate the local result display, and it should only fire once typing\n// has settled. Local search runs immediately on every doSearch; the server\n// pass is scheduled `SERVER_SEARCH_DALLY_MS` later and any new keystroke\n// clears+reschedules it. message-list's loadGen counter discards a stale\n// server pass's results, so a restart is effectively an abort at the UI.\nconst SERVER_SEARCH_DALLY_MS = 700;\nlet serverSearchTimer: ReturnType<typeof setTimeout> | null = null;\n\nfunction doSearch(immediate = false): void {\n const query = searchInput.value.trim();\n if (query.length === 0) { reloadCurrentFolder(); return; }\n if (query.length < 2 && !immediate) return;\n\n // P20: orthogonal \"Server\" checkbox. When checked, the search ALSO spans\n // every folder on the server \u2014 but as a deferred second phase, never a\n // replacement for the instant local pass.\n const serverCheck = document.getElementById(\"search-server-too\") as HTMLInputElement | null;\n const trashCheck = document.getElementById(\"search-include-trash\") as HTMLInputElement | null;\n const localScope = searchScope?.value || \"all\";\n const includeTrash = !!trashCheck?.checked;\n const serverOn = !!serverCheck?.checked;\n\n // Any re-run aborts a pending server pass \u2014 it'll be rescheduled below\n // if still wanted. This is the \"editing the search aborts + restarts\"\n // behavior: each keystroke cancels the prior server search. clearTimeout\n // kills a not-yet-fired pass; cancelServerSearch() aborts one that's\n // already mid-sweep on the daemon (generation bump \u2192 loop bails).\n if (serverSearchTimer) { clearTimeout(serverSearchTimer); serverSearchTimer = null; }\n cancelServerSearch();\n\n // \"This folder\" scope: instant client-side filter of the visible rows.\n // Only when the server checkbox is OFF \u2014 with it on we want the real\n // local+server search, not a row filter.\n if (localScope === \"current\" && !serverOn && !immediate) {\n const body = document.getElementById(\"ml-body\");\n if (body) {\n const lower = query.toLowerCase();\n for (const row of body.querySelectorAll(\".ml-row\")) {\n const text = row.textContent?.toLowerCase() || \"\";\n (row as HTMLElement).classList.toggle(\"filter-hidden\", !text.includes(lower));\n }\n }\n return;\n }\n\n // \u2500\u2500 Phase 1: LOCAL search \u2014 always, immediately \u2500\u2500\n // The local SQLite store is fast; results paint as the user types.\n const localScopeEff = localScope === \"current\" ? \"current\" : \"all\";\n loadSearchResults(query, localScopeEff, currentAccountId, currentFolderId, includeTrash);\n setTitle(`${APP_NAME} - Search: ${query}`);\n setActiveTabView(\n { kind: \"search\", query, scope: serverOn ? \"server\" : localScopeEff, accountId: currentAccountId, folderId: currentFolderId, includeTrash },\n `Search: ${query}`,\n );\n // Record every executed search \u2014 Enter AND a settled debounced search\n // alike. The prefix-pruning in recordSearchHistory() collapses the\n // keystroke progression, so a search the user typed without pressing\n // Enter (\"Hoddie\") still shows up in history.\n recordSearchHistory(query);\n\n // \u2500\u2500 Phase 2: SERVER search \u2014 deferred, augments phase 1 \u2500\u2500\n // Fires after the dally so a burst of keystrokes only triggers one IMAP\n // sweep. The local rows from phase 1 stay on screen the whole time\n // (loadSearchResults won't blank a list that already has rows), so the\n // server pass never makes the user stare at an empty list.\n if (serverOn) {\n serverSearchTimer = setTimeout(() => {\n serverSearchTimer = null;\n loadSearchResults(query, \"server\", currentAccountId, currentFolderId, includeTrash);\n }, SERVER_SEARCH_DALLY_MS);\n }\n}\n\n// Track current folder for scoped search\nlet currentAccountId = \"\";\nlet currentFolderId = 0;\nlet reloadDebounceTimer: ReturnType<typeof setTimeout> | null = null;\n\nsearchInput?.addEventListener(\"input\", () => {\n clearTimeout(searchTimeout);\n if (serverSearchTimer) { clearTimeout(serverSearchTimer); serverSearchTimer = null; cancelServerSearch(); }\n updateSearchHighlight();\n if (searchInput.value.trim() === \"\") {\n // Cleared \u2014 reset immediately, no debounce. Must exit search mode\n // first; otherwise reloadCurrentFolder() sees searchMode=true and\n // re-runs the stale query (user-reported regression 2026-04-24).\n clearSearchMode();\n const body = document.getElementById(\"ml-body\");\n if (body) body.querySelectorAll(\".filter-hidden\").forEach(r => r.classList.remove(\"filter-hidden\"));\n reloadCurrentFolder();\n setTitle(APP_NAME);\n } else {\n // Short debounce \u2014 just enough to coalesce a keystroke burst. The\n // local pass is cheap; the server pass has its own longer dally\n // inside doSearch, so this stays snappy.\n searchTimeout = setTimeout(() => doSearch(false), 180);\n }\n});\nsearchInput?.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") {\n clearTimeout(searchTimeout);\n doSearch(true);\n }\n if (e.key === \"Escape\") {\n searchInput.value = \"\";\n if (serverSearchTimer) { clearTimeout(serverSearchTimer); serverSearchTimer = null; }\n cancelServerSearch();\n updateSearchHighlight();\n clearSearchMode();\n // Clear any client-side filters\n const body = document.getElementById(\"ml-body\");\n if (body) body.querySelectorAll(\".filter-hidden\").forEach(r => r.classList.remove(\"filter-hidden\"));\n reloadCurrentFolder();\n setTitle(APP_NAME);\n }\n});\n\n// Re-run the active search when the scope dropdown or \"server too\" checkbox\n// flips. Without this, switching all/current/server after typing the query\n// left the old result set on screen \u2014 the controls looked like they did\n// nothing. Treat the change as `immediate=true` so the user sees the new\n// scope's results without having to retype Enter; clear any client-side\n// filter-hidden flags from the prior \"current folder\" path so the row set\n// resets cleanly.\nfunction rerunActiveSearch(): void {\n if (!searchInput || searchInput.value.trim() === \"\") return;\n const body = document.getElementById(\"ml-body\");\n if (body) body.querySelectorAll(\".filter-hidden\").forEach(r => r.classList.remove(\"filter-hidden\"));\n clearTimeout(searchTimeout);\n doSearch(true);\n}\nsearchScope?.addEventListener(\"change\", rerunActiveSearch);\ndocument.getElementById(\"search-server-too\")?.addEventListener(\"change\", rerunActiveSearch);\ndocument.getElementById(\"search-include-trash\")?.addEventListener(\"change\", rerunActiveSearch);\n\n// Message state handles move/delete \u2014 no manual event listener needed\n\n// \u2500\u2500 Folder filter \u2500\u2500\nconst ftFilterInput = document.getElementById(\"ft-filter-input\") as HTMLInputElement;\nif (ftFilterInput) {\n ftFilterInput.addEventListener(\"input\", () => {\n const query = ftFilterInput.value.toLowerCase();\n const tree = document.getElementById(\"folder-tree\");\n if (!tree) return;\n\n if (!query) {\n // Clear filter \u2014 show everything\n tree.querySelectorAll(\".ft-filter-hidden\").forEach(el => el.classList.remove(\"ft-filter-hidden\"));\n return;\n }\n\n // Hide all folders first, then show matches + their parent accounts\n const folders = tree.querySelectorAll(\".ft-folder\");\n const accounts = tree.querySelectorAll(\".ft-account\");\n\n for (const acct of accounts) (acct as HTMLElement).classList.add(\"ft-filter-hidden\");\n for (const f of folders) (f as HTMLElement).classList.add(\"ft-filter-hidden\");\n\n for (const f of folders) {\n const name = f.querySelector(\".ft-folder-name\")?.textContent?.toLowerCase() || \"\";\n if (name.includes(query)) {\n (f as HTMLElement).classList.remove(\"ft-filter-hidden\");\n // Show parent account\n const acct = f.closest(\".ft-account\");\n if (acct) (acct as HTMLElement).classList.remove(\"ft-filter-hidden\");\n }\n }\n\n // Also show unified inbox if it matches\n const unified = tree.querySelector(\".ft-unified\");\n if (unified) {\n const text = unified.textContent?.toLowerCase() || \"\";\n (unified as HTMLElement).classList.toggle(\"ft-filter-hidden\", !text.includes(query));\n }\n });\n\n ftFilterInput.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Escape\") {\n ftFilterInput.value = \"\";\n ftFilterInput.dispatchEvent(new Event(\"input\"));\n }\n });\n}\n\n// \u2500\u2500 Open links from email body in system browser \u2500\u2500\n\nwindow.addEventListener(\"message\", (e) => {\n // Relay traces from iframes (compose) to Node via our working bridge.\n // The iframe calls logClientEvent which tries its own bridge first; if\n // that path is broken it also posts here as backup. Tag gets a `via-relay`\n // suffix when the iframe couldn't reach its own bridge \u2014 that alone\n // diagnoses whether the iframe bridge works.\n if (e.data?.type === \"mailx-trace\" && typeof e.data.tag === \"string\") {\n const relayTag = e.data.bridged ? e.data.tag : `${e.data.tag} (via-relay)`;\n logClientEvent(relayTag, e.data.data);\n return;\n }\n // Compose-send relay: iframe posts the send request here because its own\n // bridge call to sendMessage was failing to reach Node. This window's\n // bridge is proven (getAccounts / getOutboxStatus run every few seconds\n // with no failures), so we do the IPC from here and post the result back\n // to the iframe via its source. `e.source` is the iframe's window; use it\n // so targeting works even if the iframe moves in the DOM.\n // S61 2026-04-24: parent-relay compose close. On Android the\n // window.close() override applied in `frame.onload` doesn't always fire\n // (WebView2 / MAUI WebView dispatches close to the shell in some cases),\n // leaving the compose overlay stuck after Send. postMessage is reliable;\n // compose.ts's closeCompose() posts this, and we find-and-remove the\n // overlay whose iframe window matches e.source.\n if (e.data?.type === \"mailx-compose-close\") {\n const src = e.source as Window | null;\n document.querySelectorAll<HTMLElement>(\".compose-overlay\").forEach(el => {\n const iframe = el.querySelector<HTMLIFrameElement>(\"iframe\");\n if (!src || iframe?.contentWindow === src) el.remove();\n });\n return;\n }\n if (e.data?.type === \"mailx-compose-send\" && e.data.id && e.data.body) {\n const src = e.source as Window | null;\n const id = e.data.id;\n logClientEvent(\"relay-compose-send-received\", { id });\n (async () => {\n try {\n await apiSendMessage(e.data.body);\n logClientEvent(\"relay-compose-send-ok\", { id });\n src?.postMessage({ type: \"mailx-compose-send-result\", id, ok: true }, \"*\" as any);\n } catch (err: any) {\n const msg = err?.message || String(err);\n logClientEvent(\"relay-compose-send-error\", { id, error: msg });\n src?.postMessage({ type: \"mailx-compose-send-result\", id, ok: false, error: msg }, \"*\" as any);\n }\n })();\n return;\n }\n // Generic IPC relay: the iframe's api-client routes every IPC call through\n // postMessage when it's running in a child frame. Same reason as the\n // compose-send relay \u2014 sendMessage wasn't the only method the iframe's\n // bridge dropped; saveDraft hit the same wall (\"Draft save failed: mailxapi\n // timeout\"). This handler invokes the named method on THIS window's\n // mailxapi and posts the result back to the iframe.\n if (e.data?.type === \"mailx-ipc\" && e.data.id && e.data.method) {\n const src = e.source as Window | null;\n const { id, method, args } = e.data;\n const bridge = (window as any).mailxapi;\n const fn = bridge?.[method];\n if (typeof fn !== \"function\") {\n src?.postMessage({ type: \"mailx-ipc-result\", id, ok: false, error: `parent bridge has no method \"${method}\"` }, \"*\" as any);\n return;\n }\n try {\n const result = fn.apply(bridge, args || []);\n Promise.resolve(result).then(\n (value) => src?.postMessage({ type: \"mailx-ipc-result\", id, ok: true, result: value }, \"*\" as any),\n (err) => src?.postMessage({ type: \"mailx-ipc-result\", id, ok: false, error: err?.message || String(err) }, \"*\" as any),\n );\n } catch (err: any) {\n src?.postMessage({ type: \"mailx-ipc-result\", id, ok: false, error: err?.message || String(err) }, \"*\" as any);\n }\n return;\n }\n if (e.data?.type === \"openLink\" && e.data.url) {\n window.open(e.data.url, \"_blank\", \"noopener,noreferrer\");\n }\n if (e.data?.type === \"linkClick\" && e.data.url) {\n const url = e.data.url;\n if ((window as any).mailxapi?.platform === \"android\") {\n // Android: use mailxapi:// bridge scheme \u2014 OnNavigating intercepts it\n // and opens in system browser. Raw http:// in sub-frames doesn't trigger OnNavigating.\n const f = document.createElement(\"iframe\");\n f.style.display = \"none\";\n f.src = `mailxapi://openurl?url=${encodeURIComponent(url)}`;\n document.body.appendChild(f);\n setTimeout(() => f.remove(), 500);\n } else {\n window.open(url, \"_blank\", \"noopener,noreferrer\");\n }\n }\n if (e.data?.type === \"previewToggleFullscreen\") {\n toggleFullscreenPreview();\n return;\n }\n if (e.data?.type === \"previewContextMenu\") {\n // Translate iframe-relative coords to viewport. Find the iframe whose\n // contentWindow matches the source so we map correctly even when\n // multiple iframes are present (compose iframe + viewer iframe).\n let iframeRect: DOMRect | null = null;\n for (const f of Array.from(document.querySelectorAll(\"iframe\"))) {\n if ((f as HTMLIFrameElement).contentWindow === e.source) { iframeRect = f.getBoundingClientRect(); break; }\n }\n const x = (iframeRect?.left || 0) + (e.data.x || 0);\n const y = (iframeRect?.top || 0) + (e.data.y || 0);\n showPreviewBodyMenu(\n x, y,\n String(e.data.selectedText || \"\"),\n e.source as Window | null,\n String(e.data.linkUrl || \"\") || undefined,\n String(e.data.linkText || \"\") || undefined,\n );\n return;\n }\n if (e.data?.type === \"linkContextMenu\") {\n // C29: right-click in body iframe \u2192 Open / Save / Copy URL menu.\n // Iframe's clientX/Y is relative to the iframe; translate to viewport.\n let iframeRect: DOMRect | null = null;\n for (const f of Array.from(document.querySelectorAll(\"iframe\"))) {\n if ((f as HTMLIFrameElement).contentWindow === e.source) { iframeRect = f.getBoundingClientRect(); break; }\n }\n const x = (iframeRect?.left || 0) + (e.data.x || 0);\n const y = (iframeRect?.top || 0) + (e.data.y || 0);\n const url: string = e.data.url || \"\";\n // Find a sensible filename for the Save action.\n const guessName = (() => {\n try {\n const u = new URL(url);\n const last = u.pathname.split(\"/\").pop() || \"\";\n return last && last.includes(\".\") ? last : \"\";\n } catch { return \"\"; }\n })();\n const items: { label: string; action: () => void }[] = [\n { label: \"Open in browser\", action: () => {\n window.open(url, \"_blank\", \"noopener,noreferrer\");\n }},\n { label: guessName ? `Save \"${guessName}\"\u2026` : \"Save link as\u2026\", action: () => {\n // Trigger a download via anchor with download attr.\n const a = document.createElement(\"a\");\n a.href = url;\n if (guessName) a.download = guessName;\n else a.download = \"\";\n a.style.display = \"none\";\n document.body.appendChild(a);\n a.click();\n setTimeout(() => a.remove(), 1000);\n }},\n { label: \"Copy URL\", action: async () => {\n try { await navigator.clipboard.writeText(url); }\n catch { prompt(\"URL:\", url); }\n }},\n { label: \"Copy link text\", action: async () => {\n try { await navigator.clipboard.writeText(e.data.text || url); }\n catch { prompt(\"Text:\", e.data.text || url); }\n }},\n ];\n // Build a tiny inline menu (showContextMenu would do but it's in components/).\n const menu = document.createElement(\"div\");\n menu.style.cssText = `position:fixed;z-index:2400;background:var(--color-bg);border:1px solid var(--color-border);border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,0.2);padding:4px 0;font-size:13px;min-width:180px;`;\n menu.style.left = `${Math.min(x, window.innerWidth - 200)}px`;\n menu.style.top = `${Math.min(y, window.innerHeight - 200)}px`;\n // mousedown inside the menu must NOT reach the document-level\n // dismiss handler \u2014 otherwise the menu is removed before click\n // fires on the row and the action silently no-ops (user report\n // 2026-04-24). Stop propagation at the menu root covers every row.\n menu.addEventListener(\"mousedown\", (ev) => ev.stopPropagation());\n for (const it of items) {\n const row = document.createElement(\"div\");\n row.textContent = it.label;\n row.style.cssText = `padding:6px 12px;cursor:pointer;`;\n row.addEventListener(\"mouseenter\", () => row.style.background = \"var(--color-bg-hover)\");\n row.addEventListener(\"mouseleave\", () => row.style.background = \"\");\n row.addEventListener(\"click\", () => { menu.remove(); it.action(); });\n menu.appendChild(row);\n }\n document.body.appendChild(menu);\n const dismiss = () => { menu.remove(); document.removeEventListener(\"mousedown\", dismiss); document.removeEventListener(\"keydown\", dismiss); };\n setTimeout(() => {\n document.addEventListener(\"mousedown\", dismiss);\n document.addEventListener(\"keydown\", dismiss);\n }, 0);\n return;\n }\n if (e.data?.type === \"mailx-send-error\") {\n // Send failed AFTER compose closed (fire-and-forget model). Surface in\n // the status bar so the user sees something instead of the silence.\n const statusSync = document.getElementById(\"status-sync\");\n if (statusSync) {\n statusSync.textContent = `Send failed: ${e.data.message}`;\n statusSync.style.color = \"oklch(0.65 0.2 25)\";\n }\n return;\n }\n if (e.data?.type === \"linkHover\") {\n // Cancel any pending show \u2014 every hoverover/hoverout from the iframe\n // triggers this branch. Without the timer, the popover appears\n // instantly and lingers when the user moves to do anything else,\n // including punching through the compose overlay (which sits at\n // z-index 1000 \u2014 popover was at 10000, hence the bug in the\n // screenshot). Now: 1500ms hover delay; suppressed entirely when\n // any overlay (compose, modal) is open; auto-dismissed on click,\n // scroll, blur, or any keypress.\n const w = window as any;\n if (w._linkHoverShowTimer) { clearTimeout(w._linkHoverShowTimer); w._linkHoverShowTimer = null; }\n let pop = document.getElementById(\"link-hover-popover\") as HTMLDivElement | null;\n const hidePop = () => { if (pop) pop.style.display = \"none\"; };\n if (!e.data.url) { hidePop(); return; }\n // Suppress when compose / modal overlay is up \u2014 user shouldn't see\n // a tooltip for a link they can't reach without dismissing first.\n if (document.querySelector(\".compose-overlay, .mailx-modal-backdrop\")) { hidePop(); return; }\n const data = e.data;\n const source = e.source;\n w._linkHoverShowTimer = setTimeout(() => {\n // Re-check overlay state at fire time \u2014 overlay may have appeared\n // during the 1500ms wait.\n if (document.querySelector(\".compose-overlay, .mailx-modal-backdrop\")) return;\n if (!pop) {\n pop = document.createElement(\"div\");\n pop.id = \"link-hover-popover\";\n // z-index 500 \u2014 above the message body iframe (no z-index)\n // but BELOW the compose overlay (z-index 1000) and modals (2000).\n pop.style.cssText = \"position:fixed;z-index:500;max-width:520px;padding:6px 10px;background:var(--color-surface,#fff);color:var(--color-text,#000);border:1px solid var(--color-border,#888);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.18);font-size:12px;line-height:1.4;word-break:break-all;pointer-events:none;\";\n document.body.appendChild(pop);\n // One-time dismissers on the popover lifetime.\n const dismiss = () => hidePop();\n document.addEventListener(\"mousedown\", dismiss, true);\n document.addEventListener(\"scroll\", dismiss, true);\n document.addEventListener(\"keydown\", dismiss, true);\n window.addEventListener(\"blur\", dismiss);\n }\n pop.textContent = data.url;\n pop.style.display = \"block\";\n let iframeRect: DOMRect | null = null;\n for (const f of Array.from(document.querySelectorAll(\"iframe\"))) {\n if ((f as HTMLIFrameElement).contentWindow === source) { iframeRect = f.getBoundingClientRect(); break; }\n }\n const r = data.rect;\n if (iframeRect && r) {\n const x = Math.max(4, Math.min(window.innerWidth - 528, iframeRect.left + r.left));\n let y = iframeRect.top + r.bottom + 4;\n if (y + 60 > window.innerHeight) y = Math.max(4, iframeRect.top + r.top - 60);\n pop.style.left = x + \"px\";\n pop.style.top = y + \"px\";\n }\n }, 1500);\n }\n if (e.data?.type === \"previewKey\" && typeof e.data.key === \"string\") {\n // Re-dispatch as a real keydown on document so the hotkey handler\n // below runs the same code path as a list-focused keypress. Used\n // when focus is inside the sandboxed preview iframe \u2014 works on\n // platforms where parent-side contentDocument listeners don't.\n const ev = new KeyboardEvent(\"keydown\", {\n key: e.data.key, code: e.data.code || \"\",\n ctrlKey: !!e.data.ctrlKey, shiftKey: !!e.data.shiftKey,\n altKey: !!e.data.altKey, metaKey: !!e.data.metaKey,\n bubbles: true, cancelable: true,\n });\n document.dispatchEvent(ev);\n }\n});\n\n// \u2500\u2500 Splitter drag \u2500\u2500\n\nconst splitter = document.getElementById(\"splitter-h\");\nif (splitter) {\n // Restore saved position\n const saved = localStorage.getItem(\"mailx-split\");\n if (saved) document.documentElement.style.setProperty(\"--list-viewer-split\", saved);\n\n let dragging = false;\n let startX: number;\n let startSplit: number;\n\n splitter.addEventListener(\"pointerdown\", (e: PointerEvent) => {\n dragging = true;\n startX = e.clientX;\n const mainArea = document.querySelector(\".main-area\") as HTMLElement;\n startSplit = mainArea.getBoundingClientRect().width * (parseFloat(getComputedStyle(document.documentElement).getPropertyValue(\"--list-viewer-split\")) / 100);\n splitter.setPointerCapture(e.pointerId);\n });\n\n splitter.addEventListener(\"pointermove\", (e: PointerEvent) => {\n if (!dragging) return;\n const mainArea = document.querySelector(\".main-area\") as HTMLElement;\n const totalWidth = mainArea.getBoundingClientRect().width;\n const newSplit = ((startSplit + (e.clientX - startX)) / totalWidth) * 100;\n const clamped = Math.max(15, Math.min(85, newSplit));\n const val = `${clamped}%`;\n document.documentElement.style.setProperty(\"--list-viewer-split\", val);\n localStorage.setItem(\"mailx-split\", val);\n });\n\n splitter.addEventListener(\"pointerup\", () => { dragging = false; });\n}\n\n// \u2500\u2500 WebSocket for live updates \u2500\u2500\n\nconnectWebSocket();\n\n// Priority-sender index \u2014 fetch once on load. Refreshed on the contacts\n// configChanged event (below) so changes from another device propagate\n// without a full reload.\nrefreshPriorityIndex().catch(() => { /* service may not be up yet */ });\n\nonWsEvent((event) => {\n const statusSync = document.getElementById(\"status-sync\");\n const startupStatus = document.getElementById(\"startup-status\");\n\n switch (event.type) {\n case \"connected\":\n if (statusSync) statusSync.textContent = \"Connected\";\n if (startupStatus) startupStatus.textContent = \"Loading accounts...\";\n // Don't refresh folder tree on connect \u2014 it's already loaded by initFolderTree\n break;\n case \"syncProgress\": {\n // Aggregate folders phases (\"folders:<path>\" when starting a folder,\n // \"folders-done\" between folders) print as a proportion so the user\n // can see forward progress instead of a meaningless \"47%\". Older\n // phase strings (\"sync:<path>\", \"folders\") still render raw.\n let label = `${event.phase} ${event.progress || 0}%`;\n if (typeof event.phase === \"string\" && event.phase.startsWith(\"folders:\")) {\n const folderPath = event.phase.slice(\"folders:\".length);\n label = `folders \u2014 ${folderPath} (${event.progress || 0}%)`;\n } else if (event.phase === \"folders-done\") {\n label = `folders ${event.progress || 0}% done`;\n }\n if (statusSync) statusSync.textContent = `Syncing ${event.accountId}: ${label}`;\n if (startupStatus) startupStatus.textContent = `Syncing ${event.accountId}: ${label}`;\n // Mark syncing folder in tree \u2014 bubble up to visible parent if collapsed\n const syncPath = event.phase?.startsWith(\"sync:\") ? event.phase.slice(5) : null;\n // Clear previous syncing markers for this account\n document.querySelectorAll(`.ft-folder.ft-syncing[data-account-id=\"${event.accountId}\"]`).forEach(el => el.classList.remove(\"ft-syncing\"));\n if (syncPath && event.progress < 100) {\n // Try exact match first\n let folderEl = document.querySelector(`.ft-folder[data-account-id=\"${event.accountId}\"][data-folder-path=\"${CSS.escape(syncPath)}\"]`);\n if (!folderEl) {\n // Folder not visible (parent collapsed) \u2014 find nearest visible ancestor\n const parts = syncPath.split(/[./]/);\n for (let i = parts.length - 1; i >= 1; i--) {\n const parentPath = parts.slice(0, i).join(\".\");\n folderEl = document.querySelector(`.ft-folder[data-account-id=\"${event.accountId}\"][data-folder-path=\"${CSS.escape(parentPath)}\"]`);\n if (folderEl) break;\n }\n }\n if (folderEl) folderEl.classList.add(\"ft-syncing\");\n }\n break;\n }\n case \"syncComplete\":\n // After sync completes, refresh the folder tree (critical for first-run on Android\n // where folders don't exist until sync fetches them from Gmail API)\n refreshFolderTree();\n // Q53: track per-account last-sync timestamp for the status-bar hover.\n recordAccountSync(event.accountId);\n // Earlier I added reloadCurrentFolder() here to fix the\n // \"phone INBOX stays on placeholder\" report. That broke desktop\n // because syncComplete fires repeatedly on the desktop sync\n // loop, and each reload rebuilds the message-list DOM\n // mid-read \u2014 the focusedRow Row object becomes a stale\n // pointer to a detached element, and the viewer's \"Select a\n // message to read\" placeholder stays up while the new row\n // shows .selected. Removed: folderCountsChanged already\n // triggers a debounced reload when new messages actually\n // arrive (which is what the phone case really needs anyway).\n break;\n case \"folderSynced\":\n // Per-folder timestamps \u2014 drives the tooltip + freshness dot.\n for (const entry of event.entries || []) {\n setFolderSynced(event.accountId, entry.folderId, entry.syncedAt);\n if (currentFolderId === entry.folderId && currentAccountId === event.accountId) {\n currentFolderSyncedAt = entry.syncedAt;\n renderNarrowFolderTitle();\n }\n }\n break;\n case \"folderCountsChanged\": {\n // Incremental update only \u2014 updateFolderCounts patches badge counts\n // in-place and falls back to a full refreshFolderTree() when the\n // folder structure has actually changed. Calling both was doing a\n // 300 ms debounced rebuild on every sync tick even when just the\n // unread count moved \u2014 visible as folder-tree flicker on Dovecot\n // accounts where STATUS polls fire frequently.\n updateFolderCounts();\n updateNewMessageCount();\n // Debounced silent reload \u2014 preserves scroll position, selection, and viewer\n if (reloadDebounceTimer) clearTimeout(reloadDebounceTimer);\n reloadDebounceTimer = setTimeout(() => {\n reloadDebounceTimer = null;\n reloadCurrentFolder();\n }, 2000);\n // Sync succeeded \u2014 clear any transient error banner and re-enable sync button\n hideAlert();\n const syncBtn = document.getElementById(\"btn-sync\") as HTMLButtonElement;\n if (syncBtn) { syncBtn.disabled = false; syncBtn.classList.remove(\"syncing\"); }\n if (statusSync) statusSync.textContent = `Synced ${new Date().toLocaleTimeString(undefined, { hour: \"2-digit\", minute: \"2-digit\", hour12: false })}`;\n break;\n }\n case \"updateAvailable\": {\n const banner = document.getElementById(\"alert-banner\");\n const text = document.getElementById(\"alert-text\");\n if (banner && text) {\n banner.hidden = false;\n // Mark as the sticky update banner so hideAlert() (called\n // after every successful sync) leaves it alone.\n banner.dataset.key = \"update-available\";\n // Green update banner \u2014 matches the Android update bar Bob\n // likes (was blue, oklch hue 250).\n banner.style.background = \"oklch(0.52 0.14 150)\";\n // Stash the update banner contents so updateFailed can restore\n // it (offering a retry) instead of leaving \"Updating...\" pinned.\n const restoreHtml = `${APP_NAME} ${event.latest} available (you have ${event.current}) \u2014 <button id=\"btn-do-update\" style=\"background:none;border:1px solid #fff;color:#fff;padding:0.15em 0.5em;border-radius:3px;cursor:pointer;margin-left:0.5em\">Update now</button>`;\n (window as any).__mailxUpdateBannerHtml = restoreHtml;\n text.innerHTML = restoreHtml;\n document.getElementById(\"btn-do-update\")?.addEventListener(\"click\", () => {\n text.textContent = \"Updating... mailx will restart when done\";\n // performUpdate runs npm install then restarts the service\n const ipc = (window as any).mailxapi || (window as any).opener?.mailxapi;\n if (ipc?.performUpdate) ipc.performUpdate();\n });\n }\n break;\n }\n case \"updateFailed\": {\n // Service tried to install but failed (typically offline). Restore\n // the \"Update now\" banner so the user can retry \u2014 and prefix it\n // with a short status so they know why the previous tap silently\n // came back. mailx itself keeps running on the current version.\n const banner = document.getElementById(\"alert-banner\");\n const text = document.getElementById(\"alert-text\");\n if (banner && text) {\n const restoreHtml = (window as any).__mailxUpdateBannerHtml as string | undefined;\n const prefix = event.offline ? \"No connection \u2014 update postponed. \" : \"Update failed \u2014 \";\n banner.hidden = false;\n banner.dataset.key = \"update-available\"; // sticky \u2014 survives sync\n banner.style.background = event.offline ? \"oklch(0.42 0.06 70)\" : \"oklch(0.45 0.12 25)\";\n text.innerHTML = `${prefix}${restoreHtml ?? \"\"}`;\n document.getElementById(\"btn-do-update\")?.addEventListener(\"click\", () => {\n text.textContent = \"Updating... mailx will restart when done\";\n const ipc = (window as any).mailxapi || (window as any).opener?.mailxapi;\n if (ipc?.performUpdate) ipc.performUpdate();\n });\n }\n break;\n }\n case \"syncActionFailed\": {\n // Surface sync failures (move/delete/flag not applied on server)\n // so the user knows local-first actions haven't propagated yet.\n // Status-bar hint always; banner only when the service is telling\n // us it has GIVEN UP (message starts with \"Gave up\"). A full sync\n // over a flaky link fires syncActionFailed once per attempt, and\n // a banner per attempt floods DOM work on the WebView main thread.\n const action = event.action === \"move\" ? \"Move\" : event.action === \"delete\" ? \"Delete\" : event.action;\n if (statusSync) statusSync.textContent = `Sync failed: ${action}`;\n if (typeof event.error === \"string\" && /gave up/i.test(event.error)) {\n showAlert(\n `Couldn't ${action.toLowerCase()} message on the server after retries: ${event.error}. Local change kept.`,\n \"conflict\",\n );\n }\n break;\n }\n case \"reload\":\n location.reload();\n break;\n case \"bodyCached\":\n // Prefetch (or on-demand fetch) downloaded a body \u2014 flip the\n // \"not-downloaded\" indicator to the teal dot for any rows in view.\n markBodiesCached(event.items || []);\n break;\n // bodyAvailable and messageRemoved migrated to subscribeStore below.\n case \"syncStateChanged\": {\n // Drives the bottom-right sync-status pill. Same surface used by\n // syncProgress / folderSynced; reconciler ticks this every few\n // seconds so the pill reflects current queue depth.\n if (!statusSync) break;\n const msgs = event.messageActions ?? 0;\n const bodies = event.bodyFetches ?? 0;\n if (msgs === 0 && bodies === 0) {\n // Don't clobber an in-progress per-account \"Syncing accountX\u2026\"\n // status if reconciler ticks while a fresh sync is happening.\n if (!/syncing|fetching/i.test(statusSync.textContent || \"\")) {\n statusSync.textContent = \"Sync OK\";\n statusSync.removeAttribute(\"data-sync-state\");\n }\n } else {\n const parts: string[] = [];\n if (msgs) parts.push(`${msgs} action${msgs === 1 ? \"\" : \"s\"}`);\n if (bodies) parts.push(`${bodies} bod${bodies === 1 ? \"y\" : \"ies\"}`);\n statusSync.textContent = `Syncing ${parts.join(\" + \")}`;\n statusSync.setAttribute(\"data-sync-state\", \"syncing\");\n }\n break;\n }\n case \"configChanged\":\n // A watched config file was modified \u2014 could be user edit via the\n // JSONC editor, a GDrive sync, or mailx itself saving (e.g.\n // allowlist update on \"allow sender\").\n //\n // For accounts.jsonc specifically, surface a sticky banner with a\n // Restart button \u2014 the file change has no effect on the running\n // daemon (IMAP connections, token caches, sync loops use the old\n // config snapshot), and users shouldn't need `mailx -kill` just\n // to apply an edit. For other files (allowlist / clients /\n // config) the service handles live, a status-bar flash suffices.\n if (statusSync) {\n statusSync.textContent = `${event.filename} updated`;\n setTimeout(() => {\n if (statusSync.textContent === `${event.filename} updated`) statusSync.textContent = \"\";\n }, 8000);\n }\n if (event.filename && /accounts\\.jsonc/i.test(String(event.filename))) {\n showRestartForConfigBanner();\n }\n break;\n case \"cloudError\":\n // Cloud read/write failed (Google Drive auth/network/etc.). Show a\n // sticky banner so the user knows the change wasn't synced. When\n // error is null, the next successful op cleared it \u2014 hide it.\n if (event.error) {\n const where = event.filename ? ` (${event.op || \"sync\"} ${event.filename})` : \"\";\n showAlert(`Cloud sync error${where}: ${event.error}`, \"cloud-error\");\n } else {\n // Only hide if the visible banner is the cloud-error one\n if (alertBanner && alertBanner.dataset.key === \"cloud-error\") {\n alertBanner.hidden = true;\n dismissedAlerts.delete(\"cloud-error\");\n }\n }\n break;\n case \"error\":\n if (statusSync) statusSync.textContent = `Error: ${event.message}`;\n showAlert(event.message, \"ws-error\");\n break;\n case \"fatal\":\n // Unrecoverable startup failure \u2014 sticky red banner, no auto-dismiss.\n // Used for GDrive-folder-missing and similar conditions where the\n // app cannot function. console.warn would be invisible in the GUI.\n if (alertBanner) alertBanner.style.background = \"oklch(0.45 0.18 25)\";\n showAlert(event.message || \"Fatal error\", event.key || \"fatal\", { sticky: true });\n if (statusSync) statusSync.textContent = `FATAL: ${event.message}`;\n break;\n case \"outboxStatus\":\n renderOutboxStatus(event);\n break;\n case \"calendarUpdated\":\n case \"tasksUpdated\":\n // Reauth succeeded (or was never broken): clear any lingering\n // scope banner for this feature. Handled here (not just in the\n // sidebar) because the global fallback banner isn't tied to the\n // sidebar's lifecycle.\n if (alertBanner && /^scope-(calendar|tasks|google)$/.test(alertBanner.dataset.key || \"\")) {\n alertBanner.hidden = true;\n alertBanner.dataset.key = \"\";\n alertBanner.querySelector(\".status-action\")?.remove();\n }\n break;\n case \"authScopeError\": {\n // Fallback banner: calendar-sidebar.ts already shows this inline\n // when the sidebar is visible, but if the user has the sidebar\n // off or is on a narrow tier where it's hidden, the error would\n // otherwise be invisible. Global banner with the same button.\n const feat = event.feature || \"google\";\n const key = `scope-${feat}`;\n const msg = event.message || `Google ${feat} access needs re-consent.`;\n showAlert(msg, key, { sticky: true });\n const bannerText = document.getElementById(\"alert-text\");\n if (bannerText && bannerText.textContent === msg) {\n const existing = bannerText.parentElement?.querySelector(\".status-action\");\n if (!existing) {\n const btn = document.createElement(\"button\");\n btn.className = \"status-action\";\n btn.textContent = \"Re-authenticate\";\n btn.addEventListener(\"click\", async () => {\n btn.disabled = true;\n btn.textContent = \"Opening browser\u2026\";\n try {\n const { reauthGoogleScopes } = await import(\"./lib/api-client.js\");\n await reauthGoogleScopes();\n btn.textContent = \"Consent opened \u2014 finish in browser\";\n } catch (err: any) {\n btn.disabled = false;\n btn.textContent = `Failed: ${err?.message || err}`;\n }\n });\n bannerText.parentElement?.insertBefore(btn, document.getElementById(\"alert-dismiss\"));\n }\n }\n break;\n }\n case \"accountError\": {\n // Show actual error + hint in banner\n const msg = `${event.accountId}: ${event.error}`;\n showAlert(msg, `acct-${event.accountId}`);\n // Add action button: Re-authenticate for OAuth, Retry for password accounts\n const bannerText = document.getElementById(\"alert-text\");\n if (bannerText && bannerText.textContent === msg) {\n const existing = bannerText.parentElement?.querySelector(\".status-action\");\n if (!existing) {\n const btn = document.createElement(\"button\");\n btn.className = \"status-action\";\n if (event.isOAuth) {\n btn.textContent = \"Re-authenticate\";\n btn.addEventListener(\"click\", async () => {\n btn.disabled = true;\n btn.textContent = \"Authenticating...\";\n try {\n const data = await reauthenticate(event.accountId);\n if (data.ok) {\n hideAlert();\n const acctEl = document.getElementById(\"status-accounts\");\n if (acctEl) { acctEl.textContent = `${event.accountId}: reconnected`; acctEl.style.color = \"\"; }\n } else {\n btn.textContent = \"Re-authenticate\";\n btn.disabled = false;\n }\n } catch {\n btn.textContent = \"Re-authenticate\";\n btn.disabled = false;\n }\n });\n } else {\n btn.textContent = \"Retry\";\n btn.addEventListener(\"click\", async () => {\n btn.disabled = true;\n btn.textContent = \"Syncing...\";\n try {\n const data = await syncAccount(event.accountId);\n if (data.ok) {\n hideAlert();\n const acctEl = document.getElementById(\"status-accounts\");\n if (acctEl) { acctEl.textContent = `${event.accountId}: reconnected`; acctEl.style.color = \"\"; }\n } else {\n btn.textContent = \"Retry\";\n btn.disabled = false;\n }\n } catch {\n btn.textContent = \"Retry\";\n btn.disabled = false;\n }\n });\n }\n bannerText.parentElement?.insertBefore(btn, document.getElementById(\"alert-dismiss\"));\n }\n }\n // Also show in status bar\n const acctEl = document.getElementById(\"status-accounts\");\n if (acctEl) {\n acctEl.textContent = `${event.accountId}: ${event.hint}`;\n acctEl.style.color = \"oklch(0.65 0.2 25)\";\n }\n break;\n }\n case \"openMailto\": {\n // P115: OS-level mailto: handler. CLI dropped a pending file; the\n // daemon picked it up and forwarded the parsed fields. Open compose\n // pre-populated. Treat as a fresh New compose, not a reply \u2014 the\n // browser doesn't know about a parent message.\n openComposeFromMailto({\n to: Array.isArray(event.to) ? event.to : [],\n cc: Array.isArray(event.cc) ? event.cc : [],\n bcc: Array.isArray(event.bcc) ? event.bcc : [],\n subject: event.subject || \"\",\n body: event.body || \"\",\n inReplyTo: event.inReplyTo || \"\",\n }).catch((e: any) => console.error(\"openComposeFromMailto failed:\", e?.message || e));\n break;\n }\n }\n});\n\n// Store-bus consumers (post-refactor). The list's mailx-remove-stale\n// listener handles row-removal + focus-advance atomically; we just\n// translate bus events into that one signal so there's a single\n// owner of \"row is no longer here.\"\nsubscribeStore(\"*\", (ev: any) => {\n if (ev.kind === \"bodyAvailable\" && ev.accountId && ev.uid) {\n // Flip the row's \"not-downloaded\" dot. Viewer subscribes separately\n // (in message-viewer.ts) to re-render the focused message.\n markBodiesCached([{ accountId: ev.accountId, uid: ev.uid }]);\n } else if (ev.kind === \"messageRemoved\" && ev.accountId && ev.uid) {\n // Server-side EXPUNGE (or another device deleted).\n document.dispatchEvent(new CustomEvent(\"mailx-remove-stale\", {\n detail: { accountId: ev.accountId, uid: ev.uid },\n }));\n } else if (ev.kind === \"messageMoved\" && ev.accountId && ev.uid) {\n // Moved to a different folder \u2014 from the current folder's view\n // it's the same as removal. The list's remove-stale handler\n // figures out whether it was actually in the current view; if not,\n // it's a no-op. Local optimistic delete also flows here when the\n // user hits Del on another device \u2014 same handler, same advance.\n document.dispatchEvent(new CustomEvent(\"mailx-remove-stale\", {\n detail: { accountId: ev.accountId, uid: ev.uid },\n }));\n }\n});\n\n/** Open a compose window pre-filled from a mailto: URL. Same plumbing as\n * openCompose(\"new\") but the init shape comes from the parsed URL instead\n * of the currently-selected message. The body arrives as plain text from\n * the URL \u2014 we wrap it in a single <p> so Quill renders it as paragraphs\n * rather than a single line, escaping `<` so embedded angle brackets in a\n * signature/template don't get interpreted as tags. */\nasync function openComposeFromMailto(m: {\n to: string[]; cc: string[]; bcc: string[];\n subject: string; body: string; inReplyTo: string;\n}): Promise<void> {\n // Open the iframe immediately and load accounts in parallel \u2014 same\n // pattern as openCompose. The mailto handler should never feel like\n // \"waiting for the system\" to a user who clicked a link.\n const accountsP = getAccounts();\n const frame = showComposeOverlay(m.subject ? `Compose: ${m.subject}` : \"Compose\");\n const accounts = await accountsP;\n const accountId = accounts[0]?.id || \"\";\n const escape = (s: string) => s.replace(/[&<>]/g, c =>\n ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\" }[c]!));\n const bodyHtml = m.body\n ? \"<p>\" + escape(m.body).replace(/\\r?\\n/g, \"</p><p>\") + \"</p>\"\n : \"\";\n const init: any = {\n mode: \"new\",\n accountId,\n to: m.to.map(addr => ({ name: \"\", address: addr })),\n cc: m.cc.map(addr => ({ name: \"\", address: addr })),\n bcc: m.bcc.map(addr => ({ name: \"\", address: addr })),\n subject: m.subject,\n bodyHtml,\n inReplyTo: m.inReplyTo,\n references: m.inReplyTo ? [m.inReplyTo] : [],\n accounts: accounts.map((a: any) => ({ id: a.id, name: a.name, email: a.email, signature: a.signature, sig: a.sig })),\n };\n sessionStorage.setItem(\"composeInit\", JSON.stringify(init));\n try { frame?.contentWindow?.postMessage({ type: \"compose-init-ready\" }, \"*\"); } catch { /* */ }\n}\n\n// \u2500\u2500 Keyboard shortcuts \u2500\u2500\n\n// Capture-phase pre-handler: intercept WebView accelerator keys that would\n// otherwise trigger a browser action (Ctrl+R / Ctrl+Shift+R = reload). Call\n// the action directly here \u2014 earlier version re-dispatched a synthetic\n// keydown to document, which bubbled back up to this window listener and\n// fired again, breaking unrelated shortcuts (user-reported: Ctrl+N\n// stopped composing). Direct dispatch avoids the recursion.\n//\n// Whether preventDefault actually suppresses WebView2's native reload\n// depends on AreBrowserAcceleratorKeysEnabled \u2014 if it still reloads,\n// the msger Rust side needs the flag flipped (option 1 in the original\n// design notes).\nwindow.addEventListener(\"keydown\", (e) => {\n if (!e.ctrlKey || e.altKey || e.metaKey) return;\n if (e.key !== \"r\" && e.key !== \"R\") return;\n // Skip when the user is typing in an input (compose textarea etc.) so\n // we don't hijack their typing to fire reply-all.\n const t = e.target as HTMLElement | null;\n const tag = t?.tagName;\n if (tag === \"INPUT\" || tag === \"TEXTAREA\" || tag === \"SELECT\" || t?.isContentEditable) return;\n e.preventDefault();\n e.stopImmediatePropagation();\n if (e.shiftKey) openCompose(\"replyAll\");\n else openCompose(\"reply\");\n}, { capture: true });\n\ndocument.addEventListener(\"keydown\", (e) => {\n // Ctrl+N or Ctrl+Shift+M = Compose\n if ((e.ctrlKey && e.key === \"n\") || (e.ctrlKey && e.shiftKey && e.key === \"M\")) {\n e.preventDefault();\n openCompose(\"new\");\n }\n // Ctrl+P = Print the focused message. preventDefault stops the host\n // browser from printing the whole app window instead of the letter.\n if (e.ctrlKey && (e.key === \"p\" || e.key === \"P\") && !e.shiftKey && !e.altKey) {\n const t = e.target as HTMLElement | null;\n const inText = t && (t.tagName === \"INPUT\" || t.tagName === \"TEXTAREA\" || t.isContentEditable);\n if (!inText) {\n e.preventDefault();\n printCurrentMessage();\n }\n }\n // Ctrl+T = new view tab. The strip is hidden with a single tab (no wasted\n // band), so this is how the second tab gets opened; once 2+ exist the\n // strip's \"+\" is also available.\n if (e.ctrlKey && (e.key === \"t\" || e.key === \"T\") && !e.shiftKey && !e.altKey) {\n e.preventDefault();\n openTab({ kind: \"unified\" }, \"All Inboxes\", true);\n }\n // Ctrl+R = Reply (without Shift)\n if (e.ctrlKey && e.key === \"r\" && !e.shiftKey && !e.altKey && !e.metaKey) {\n e.preventDefault();\n openCompose(\"reply\");\n }\n // Ctrl+Shift+R = Reply All\n if (e.ctrlKey && e.shiftKey && e.key === \"R\" && !e.altKey && !e.metaKey) {\n e.preventDefault();\n openCompose(\"replyAll\");\n }\n // Ctrl+F = Forward (without Shift). Use toLowerCase so a Caps-Lock or\n // shifted state doesn't bypass us. Single handler \u2014 the previous\n // duplicate fired openCompose twice, which double-loaded the compose\n // iframe and the second copy got an empty sessionStorage (the first\n // had already consumed it), producing an empty Forward form.\n if (e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && (e.key === \"f\" || e.key === \"F\")) {\n e.preventDefault();\n openCompose(\"forward\");\n }\n // Ctrl/Cmd +/-/0 = UI zoom in / out / reset. Reads the current font\n // size from the root style and steps by 1px. Skip when the focus is\n // inside an input / contenteditable so the user's text isn't hijacked\n // (compose autocomplete already handles its own zoom in the Quill\n // editor via Ctrl+wheel). The browser's own Ctrl+/Ctrl- is also\n // suppressed because we preventDefault.\n const zoomKey = (e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey;\n if (zoomKey && (e.key === \"=\" || e.key === \"+\" || e.key === \"-\" || e.key === \"0\")) {\n const t = e.target as HTMLElement | null;\n const tag = t?.tagName;\n if (tag === \"INPUT\" || tag === \"TEXTAREA\" || tag === \"SELECT\" || t?.isContentEditable) return;\n const cur = parseFloat(document.documentElement.style.fontSize) ||\n parseFloat(getComputedStyle(document.documentElement).fontSize) || 15;\n let next = cur;\n if (e.key === \"0\") next = 15; // reset to default\n else if (e.key === \"-\") next = cur - 1; // smaller\n else next = cur + 1; // = or +\n e.preventDefault();\n applyFontSize(next);\n }\n // Ctrl+A = Select all visible messages\n if (e.ctrlKey && e.key === \"a\") {\n const t = e.target as HTMLElement | null;\n const tag = t?.tagName;\n // In a text field / editor, Ctrl+A means \"select the text\" \u2014 never\n // hijack it to select-all-messages. The old guard's `.closest(...,\n // body)` always matched (everything is inside <body>), so Ctrl+A in\n // the search box selected every message instead of the box's text,\n // and the field couldn't be selected+cleared (Bob 2026-05-18).\n if (tag === \"INPUT\" || tag === \"TEXTAREA\" || tag === \"SELECT\" || t?.isContentEditable) return;\n const mlBody = document.getElementById(\"ml-body\");\n if (mlBody) {\n e.preventDefault();\n mlBody.querySelectorAll(\".ml-row\").forEach(r => r.classList.add(\"selected\"));\n }\n }\n // Ctrl+D or Delete = Delete selected messages.\n // Focus is the only signal. Text input has focus \u2192 Delete is a native\n // character edit, ALWAYS. List has focus (a row, the list container,\n // anything outside a text editor) \u2192 Delete acts on the list selection,\n // single or multi. The 2026-05-09 \"multi-select while typing\" carve-out\n // is gone \u2014 it cost Bob real messages 2026-05-20 by hard-deleting the\n // auto-selected row every time he hit Delete to backspace in the search\n // input.\n if ((e.ctrlKey && e.key === \"d\") || e.key === \"Delete\") {\n const t = e.target as HTMLElement | null;\n const tag = t?.tagName;\n const editable = t?.isContentEditable;\n const inEditable = tag === \"INPUT\" || tag === \"TEXTAREA\" || tag === \"SELECT\" || editable;\n if (inEditable) return;\n e.preventDefault();\n deleteSelection();\n }\n // Ctrl+Z = Undo the most recent delete or move\n if (e.ctrlKey && e.key === \"z\") {\n if (lastMoved) {\n e.preventDefault();\n undoMove();\n } else if (lastDeleted) {\n e.preventDefault();\n undoDelete();\n }\n }\n // F5 = Sync\n if (e.key === \"F5\") {\n e.preventDefault();\n document.getElementById(\"btn-sync\")?.click();\n }\n // R (no modifiers) or Ctrl+Q = Toggle read/unread on the focused row.\n // Ctrl+Q mirrors the Outlook-style \"mark read/unread\" combo so it works\n // even when focus is in a text input (Ctrl-modifier guarantees no\n // collision with typing). Bare R defers when typing.\n const isToggleSeen =\n (e.key.toLowerCase() === \"r\" && !e.ctrlKey && !e.metaKey && !e.altKey) ||\n (e.ctrlKey && !e.metaKey && !e.altKey && e.key.toLowerCase() === \"q\");\n if (isToggleSeen) {\n const active = document.activeElement;\n const inText = active && (active.tagName === \"INPUT\" || active.tagName === \"TEXTAREA\" || active.tagName === \"SELECT\" || (active as HTMLElement).isContentEditable);\n // Bare R yields to text inputs; Ctrl+Q overrides them so it's reachable\n // from the search box or compose draft list.\n if (!e.ctrlKey && inText) return;\n const sel = getCurrentFocused();\n if (!sel) return;\n e.preventDefault();\n const wasSeen = seenOf(sel);\n setSeen(sel, !wasSeen);\n updateFlags(sel.accountId, sel.uid, sel.flags).then(() => {\n messageState.updateMessageFlags(sel.accountId, sel.uid, sel.flags);\n const row = document.querySelector(`.ml-row[data-uid=\"${sel.uid}\"][data-account-id=\"${sel.accountId}\"]`) as HTMLElement;\n if (row) row.classList.toggle(\"unread\", wasSeen);\n }).catch(() => { setSeen(sel, wasSeen); });\n }\n // Z = locate the focused row in the list (scroll-to-selected). After\n // scrolling the list out of sync with the preview, this snaps back.\n if (e.key.toLowerCase() === \"z\" && !e.ctrlKey && !e.metaKey && !e.altKey) {\n const active = document.activeElement;\n if (active && (active.tagName === \"INPUT\" || active.tagName === \"TEXTAREA\" || active.tagName === \"SELECT\")) return;\n const editable = (active as HTMLElement | null)?.isContentEditable;\n if (editable) return;\n e.preventDefault();\n scrollFocusedIntoView();\n }\n // F6 / Shift+F6 \u2014 standard pane-switch shortcut. Cycles focus among\n // folder tree \u2192 message list \u2192 message viewer.\n if (e.key === \"F6\") {\n e.preventDefault();\n cyclePaneFocus(e.shiftKey);\n }\n // ? = show keyboard shortcuts help. Skip when typing in inputs so a\n // literal \"?\" in search/compose doesn't pop the dialog.\n if (e.key === \"?\" && !e.ctrlKey && !e.metaKey && !e.altKey) {\n const active = document.activeElement;\n if (active && (active.tagName === \"INPUT\" || active.tagName === \"TEXTAREA\" || active.tagName === \"SELECT\")) return;\n const editable = (active as HTMLElement | null)?.isContentEditable;\n if (editable) return;\n e.preventDefault();\n openShortcutsDialog();\n }\n // Arrow keys + Home/End/PgUp/PgDn \u2014 navigate message list (Q58).\n if ([\"ArrowDown\", \"ArrowUp\", \"Home\", \"End\", \"PageDown\", \"PageUp\"].includes(e.key)) {\n const active = document.activeElement;\n if (active && (active.tagName === \"INPUT\" || active.tagName === \"TEXTAREA\" || active.tagName === \"SELECT\")) return;\n const body = document.getElementById(\"ml-body\");\n if (!body) return;\n const rows = Array.from(body.querySelectorAll<HTMLElement>(\".ml-row\"));\n if (rows.length === 0) return;\n const selected = body.querySelector<HTMLElement>(\".ml-row.selected\");\n const idx = selected ? rows.indexOf(selected) : -1;\n let target: HTMLElement | undefined;\n if (e.key === \"ArrowDown\") target = rows[idx + 1] || rows[idx];\n else if (e.key === \"ArrowUp\") target = rows[Math.max(0, idx - 1)];\n else if (e.key === \"Home\") target = rows[0];\n else if (e.key === \"End\") target = rows[rows.length - 1];\n else if (e.key === \"PageDown\") target = rows[Math.min(rows.length - 1, idx + 10)];\n else if (e.key === \"PageUp\") target = rows[Math.max(0, idx - 10)];\n if (target && (!selected || target !== selected)) {\n e.preventDefault();\n target.click();\n target.scrollIntoView({ block: \"nearest\" });\n }\n }\n});\n\n// \u2500\u2500 Double-click viewer chrome \u2192 fullscreen preview \u2500\u2500\n// The iframe's own contentDocument has its own dblclick handler (in\n// message-viewer.ts). This catches dblclicks on the headers / from row /\n// reply-to row of the viewer that aren't inside the iframe.\ndocument.getElementById(\"message-viewer\")?.addEventListener(\"dblclick\", (e) => {\n const target = e.target as HTMLElement | null;\n // Don't toggle when double-clicking interactive controls (chips, buttons,\n // links, address widgets) \u2014 only on the chrome / empty space.\n if (target?.closest(\"a, button, input, select, textarea, [contenteditable]\")) return;\n toggleFullscreenPreview();\n});\n\n// \u2500\u2500 F6 pane cycling \u2500\u2500\n\nfunction cyclePaneFocus(reverse: boolean): void {\n // Major panes in tab order. Skip ones not currently visible (folder\n // tree is hidden in narrow tier when the rail/drawer is closed; message\n // viewer is only meaningful with a message open).\n const panes: { id: string; el: HTMLElement | null }[] = [\n { id: \"folder-tree\", el: document.getElementById(\"folder-tree\") },\n { id: \"ml-body\", el: document.getElementById(\"ml-body\") },\n { id: \"message-viewer\", el: document.getElementById(\"message-viewer\") },\n ].filter(p => {\n if (!p.el) return false;\n const r = p.el.getBoundingClientRect();\n return r.width > 0 && r.height > 0;\n });\n if (panes.length === 0) return;\n const active = document.activeElement as HTMLElement | null;\n const currentIdx = panes.findIndex(p => p.el && (p.el === active || p.el.contains(active!)));\n const step = reverse ? -1 : 1;\n const nextIdx = currentIdx < 0 ? 0 : (currentIdx + step + panes.length) % panes.length;\n const next = panes[nextIdx];\n if (!next.el) return;\n // Make the pane focusable if it isn't already; tabindex=-1 lets us\n // focus it programmatically without inserting it into Tab order.\n if (!next.el.hasAttribute(\"tabindex\")) next.el.setAttribute(\"tabindex\", \"-1\");\n next.el.focus();\n // Visual hint \u2014 brief outline so the user can see which pane took focus.\n next.el.style.outline = \"2px solid var(--color-accent, #3b82f6)\";\n next.el.style.outlineOffset = \"-2px\";\n setTimeout(() => { next.el!.style.outline = \"\"; next.el!.style.outlineOffset = \"\"; }, 600);\n}\n\n// \u2500\u2500 Keyboard shortcuts help dialog \u2500\u2500\n\nfunction openShortcutsDialog(): void {\n if (document.querySelector(\".mailx-shortcuts-modal\")) return;\n const backdrop = document.createElement(\"div\");\n backdrop.className = \"mailx-modal-backdrop mailx-shortcuts-modal\";\n const panel = document.createElement(\"div\");\n panel.className = \"mailx-modal\";\n panel.style.maxWidth = \"640px\";\n const tabs = [\n {\n id: \"app\", label: \"Application\",\n rows: [\n [\"Compose\", \"Ctrl+N\"],\n [\"Reply\", \"Ctrl+R\"],\n [\"Reply all\", \"Ctrl+Shift+R\"],\n [\"Forward\", \"Ctrl+F\"],\n [\"Sync\", \"F5\"],\n [\"Delete selected\", \"Del or Ctrl+D\"],\n [\"Undo last delete/move\", \"Ctrl+Z\"],\n [\"Toggle read/unread\", \"R\"],\n [\"Toggle flag (\u2605)\", \"(\u2691 button in viewer)\"],\n [\"Select all visible\", \"Ctrl+A\"],\n [\"Navigate list\", \"\u2191 \u2193 Home End PgUp PgDn\"],\n [\"Scroll-to-focused row\", \"Z\"],\n [\"Switch pane\", \"F6 / Shift+F6\"],\n [\"Find / search\", \"(focus the search box)\"],\n [\"Show this help\", \"?\"],\n [\"Close dialog\", \"Esc\"],\n ],\n },\n {\n id: \"compose\", label: \"Compose\",\n rows: [\n [\"Send\", \"Ctrl+Enter\"],\n [\"Insert / edit link\", \"Ctrl+K\"],\n [\"Remove link\", \"Ctrl+Shift+K\"],\n [\"Bold / italic / underline\", \"Ctrl+B / Ctrl+I / Ctrl+U\"],\n [\"Strikethrough\", \"Ctrl+Shift+X\"],\n [\"Ordered list\", \"Ctrl+Shift+7\"],\n [\"Bulleted list\", \"Ctrl+Shift+8\"],\n [\"Indent / outdent\", \"Ctrl+] / Ctrl+[\"],\n [\"Clear formatting\", \"Ctrl+\\\\\"],\n [\"Color text\", \"Ctrl+Shift+C\"],\n [\"New line in To/Cc/Bcc\", \"(Enter inserts a comma)\"],\n [\"Editor reference\", \"(? button on compose toolbar \u2014 shows Quill/tiptap differences)\"],\n ],\n },\n {\n id: \"viewer\", label: \"Viewer\",\n rows: [\n [\"Toggle preview fullscreen\", \"double-click on body\"],\n [\"Exit fullscreen\", \"Esc / \u2715 button / double-click\"],\n [\"Zoom in / out / reset\", \"Ctrl+= / Ctrl+- / Ctrl+0\"],\n [\"Right-click in body\", \"Copy / Search / Translate / Zoom\"],\n [\"Right-click a link\", \"Open / Save / Copy URL / Copy text\"],\n ],\n },\n ];\n panel.innerHTML = `\n <div class=\"mailx-modal-title\">\n <span class=\"mailx-modal-title-text\">Keyboard shortcuts</span>\n <button type=\"button\" class=\"mailx-modal-close\" id=\"sc-x\" title=\"Close (Esc)\" aria-label=\"Close\">×</button>\n </div>\n <div class=\"mailx-shortcuts-tabs\" role=\"tablist\" style=\"display:flex;gap:0;border-bottom:1px solid var(--color-border, #ddd);margin:0 -16px 12px -16px;padding:0 16px;\">\n ${tabs.map((t, i) => `<button type=\"button\" role=\"tab\" class=\"mailx-shortcuts-tab\" data-tab=\"${t.id}\" aria-selected=\"${i === 0}\" style=\"background:none;border:0;padding:8px 14px;cursor:pointer;font:inherit;border-bottom:2px solid ${i === 0 ? \"var(--color-accent, #3b82f6)\" : \"transparent\"};color:${i === 0 ? \"var(--color-text)\" : \"var(--color-text-muted, #888)\"};\">${t.label}</button>`).join(\"\")}\n </div>\n <div class=\"mailx-about\">\n ${tabs.map((t, i) => `<div class=\"mailx-shortcuts-pane\" data-pane=\"${t.id}\" ${i === 0 ? \"\" : \"hidden\"}>\n <dl class=\"mailx-about-dl\">\n ${t.rows.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join(\"\")}\n </dl>\n </div>`).join(\"\")}\n <div class=\"mailx-about-foot\">On Android most shortcuts need a Bluetooth/USB keyboard. Touch equivalents: tap a row to view, tap the rail icons for navigation, long-press for context menu, double-tap the message body for fullscreen.</div>\n </div>\n <div class=\"mailx-modal-buttons\">\n <span class=\"mailx-modal-spacer\"></span>\n <button type=\"button\" class=\"mailx-modal-btn mailx-modal-btn-primary\" data-action=\"close\">Close</button>\n </div>`;\n backdrop.appendChild(panel);\n document.body.appendChild(backdrop);\n // Wire tab clicks\n panel.querySelectorAll<HTMLButtonElement>(\".mailx-shortcuts-tab\").forEach(tab => {\n tab.addEventListener(\"click\", () => {\n const which = tab.dataset.tab;\n panel.querySelectorAll<HTMLButtonElement>(\".mailx-shortcuts-tab\").forEach(t => {\n const sel = t.dataset.tab === which;\n t.setAttribute(\"aria-selected\", String(sel));\n t.style.borderBottom = `2px solid ${sel ? \"var(--color-accent, #3b82f6)\" : \"transparent\"}`;\n t.style.color = sel ? \"var(--color-text)\" : \"var(--color-text-muted, #888)\";\n });\n panel.querySelectorAll<HTMLElement>(\".mailx-shortcuts-pane\").forEach(p => {\n p.hidden = p.dataset.pane !== which;\n });\n });\n });\n const close = () => { backdrop.remove(); document.removeEventListener(\"keydown\", onKey, true); };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") { e.stopPropagation(); e.preventDefault(); close(); }\n };\n document.addEventListener(\"keydown\", onKey, true);\n const closeBtn = panel.querySelector<HTMLButtonElement>(\"#sc-x\");\n closeBtn?.addEventListener(\"click\", close);\n backdrop.addEventListener(\"mousedown\", (e) => { if (e.target === backdrop) close(); });\n}\n\n// \u2500\u2500 View menu \u2500\u2500\n\nconst viewBtn = document.getElementById(\"btn-view\");\nconst viewDropdown = document.getElementById(\"view-dropdown\");\nconst optTwoLine = document.getElementById(\"opt-two-line\") as HTMLInputElement;\nconst optPreview = document.getElementById(\"opt-preview\") as HTMLInputElement;\nconst optSnippet = document.getElementById(\"opt-snippet\") as HTMLInputElement;\nconst optThreaded = document.getElementById(\"opt-threaded\") as HTMLInputElement;\nconst optFlagged = document.getElementById(\"opt-flagged\") as HTMLInputElement;\nconst optFolderCounts = document.getElementById(\"opt-folder-counts\") as HTMLInputElement;\nconst optCalendarSidebar = document.getElementById(\"opt-calendar-sidebar\") as HTMLInputElement;\nconst optThreadFilter = document.getElementById(\"opt-thread-filter\") as HTMLInputElement;\n\n// Toggle dropdown \u2014 also close any other open toolbar menu so they can't\n// overlap. Without this, opening View while Settings was already open left\n// both visible at once (user-reported screenshot).\nviewBtn?.addEventListener(\"click\", (e) => {\n e.stopPropagation();\n const settingsDd = document.getElementById(\"settings-dropdown\");\n if (settingsDd) settingsDd.hidden = true;\n const restartDd = document.getElementById(\"restart-dropdown\");\n if (restartDd) restartDd.hidden = true;\n // If the dropdown was previously opened via the rail (re-parented to\n // body with inline modal styles), restore it to its toolbar parent so\n // it renders in the expected position-absolute location.\n restoreToolbarDropdown(\"view-dropdown\", \"view-menu\");\n if (viewDropdown) viewDropdown.hidden = !viewDropdown.hidden;\n});\n// Capture-phase pointerdown so we run BEFORE any handler that calls\n// stopPropagation. The earlier bubble-phase click listener missed clicks\n// when an upstream handler swallowed propagation, and the menu stayed open.\n// pointerdown also feels snappier than waiting for click (which fires on\n// pointerup). The closest() checks let inside-clicks (radio buttons,\n// checkboxes, the input) keep the menu open.\ndocument.addEventListener(\"pointerdown\", (e) => {\n const target = e.target as HTMLElement | null;\n if (!target) return;\n // Don't close if the click is on the toolbar button that toggles us \u2014\n // its own handler will run after this and toggle, otherwise we'd\n // close-then-reopen-then-close.\n if (target.closest(\"#btn-view, #btn-settings, #rail-view, #rail-settings, #rail-menu-backdrop\")) return;\n if (viewDropdown && !viewDropdown.hidden && !target.closest(\"#view-menu\") && !target.closest(\"#view-dropdown\")) {\n viewDropdown.hidden = true;\n }\n if (settingsDropdown && !settingsDropdown.hidden && !target.closest(\"#settings-menu\") && !target.closest(\"#settings-dropdown\")) {\n settingsDropdown.hidden = true;\n }\n}, true);\n// Escape always closes any open dropdown/backdrop, regardless of how it was\n// opened or whether an outside-click handler missed firing. Last-resort\n// escape hatch for the \"stuck modal\" case.\ndocument.addEventListener(\"keydown\", (e) => {\n if (e.key !== \"Escape\") return;\n let closed = false;\n for (const id of [\"settings-dropdown\", \"view-dropdown\", \"restart-dropdown\"]) {\n const dd = document.getElementById(id);\n if (dd && !dd.hidden) { dd.hidden = true; closed = true; }\n }\n const bd = document.getElementById(\"rail-menu-backdrop\");\n if (bd) { bd.remove(); closed = true; }\n const themeSub = document.getElementById(\"theme-submenu\");\n if (themeSub && !themeSub.hidden) { themeSub.hidden = true; closed = true; }\n if (closed) e.preventDefault();\n});\n\n// Restore saved view settings\nconst savedTwoLine = localStorage.getItem(\"mailx-two-line\") === \"true\";\nconst savedPreview = localStorage.getItem(\"mailx-preview\") !== \"false\"; // default true\nconst savedSnippet = localStorage.getItem(\"mailx-snippet\") !== \"false\"; // default true\nconst savedThreaded = localStorage.getItem(\"mailx-threaded\") === \"true\";\nconst savedFlagged = localStorage.getItem(\"mailx-flagged\") === \"true\";\nconst savedFolderCounts = localStorage.getItem(\"mailx-folder-counts\") === \"true\";\nif (optTwoLine) optTwoLine.checked = savedTwoLine;\nif (optPreview) optPreview.checked = savedPreview;\nif (optSnippet) optSnippet.checked = savedSnippet;\nif (optThreaded) optThreaded.checked = savedThreaded;\nif (optFlagged) optFlagged.checked = savedFlagged;\nif (optFolderCounts) optFolderCounts.checked = savedFolderCounts;\nif (savedTwoLine) document.getElementById(\"message-list\")?.classList.add(\"two-line\");\nif (!savedPreview) document.querySelector(\".main-area\")?.classList.add(\"no-preview\");\nif (!savedSnippet) document.getElementById(\"message-list\")?.classList.add(\"no-snippets\");\nif (savedThreaded) document.getElementById(\"ml-body\")?.classList.add(\"threaded\");\nif (savedFlagged) document.getElementById(\"ml-body\")?.classList.add(\"flagged-only\");\nif (savedFolderCounts) document.getElementById(\"folder-tree\")?.classList.add(\"show-folder-counts\");\n\n// \"Only this conversation\" toggle \u2014 hides rows whose threadId differs from\n// the currently-selected message's threadId. Client-side only (no server\n// round-trip); toggling off restores the full list. Persisted per-session\n// but not across reloads (thread context is tied to current selection).\noptThreadFilter?.addEventListener(\"change\", () => {\n const body = document.getElementById(\"ml-body\");\n if (!body) return;\n body.classList.toggle(\"thread-filter-on\", optThreadFilter.checked);\n applyThreadFilter();\n});\n// Re-apply thread filter whenever the list contents change (load,\n// search, paged append, removal). The filter also depends on the\n// currently-focused row's threadId, so wire mailx-focus-changed too \u2014\n// a focus change without a list-content change still needs the filter\n// to recompute which sibling rows to hide/show.\nmessageState.subscribe(() => applyThreadFilter());\ndocument.addEventListener(\"mailx-focus-changed\", () => applyThreadFilter());\n\nfunction applyThreadFilter(): void {\n const body = document.getElementById(\"ml-body\");\n if (!body) return;\n if (!optThreadFilter?.checked) {\n body.querySelectorAll<HTMLElement>(\".ml-row.thread-filter-hidden\")\n .forEach(r => r.classList.remove(\"thread-filter-hidden\"));\n return;\n }\n const sel = getCurrentFocused() as any;\n const tid = sel?.threadId;\n if (!tid) return;\n body.querySelectorAll<HTMLElement>(\".ml-row\").forEach(r => {\n const rowTid = r.dataset.threadId;\n if (rowTid === tid || r.classList.contains(\"selected\")) {\n r.classList.remove(\"thread-filter-hidden\");\n } else {\n r.classList.add(\"thread-filter-hidden\");\n }\n });\n}\n\n// S51 \u2014 Calendar sidebar: View-menu toggle, restore from localStorage,\n// hide auto-magically on narrow screens (CSS handles that).\n(async () => {\n const { initCalendarSidebar, isCalendarSidebarOn, showCalendarSidebar, hideCalendarSidebar } =\n await import(\"./components/calendar-sidebar.js\");\n initCalendarSidebar();\n const on = isCalendarSidebarOn();\n if (optCalendarSidebar) optCalendarSidebar.checked = on;\n if (on) await showCalendarSidebar();\n optCalendarSidebar?.addEventListener(\"change\", () => {\n if (optCalendarSidebar.checked) showCalendarSidebar();\n else hideCalendarSidebar();\n });\n})();\n\n// P17 / Q104: alarm subsystem \u2014 Thunderbird/Outlook-style popup with\n// snooze + dismiss. Covers calendar events + tasks today; mail reminders\n// will slot in here when the mail-reminder feature lands.\n(async () => {\n try {\n const { startAlarmPoller } = await import(\"./components/alarms.js\");\n startAlarmPoller();\n } catch (e: any) {\n console.error(\"alarm poller init failed:\", e?.message || e);\n }\n})();\n\n// Pull-to-refresh: touch-drag down at the top of the message list fires a\n// sync, same as F5 / btn-sync. Standard Android / Thunderbird / mobile\n// pattern. The indicator slides down with the drag and rotates the chevron\n// once the user crosses the trigger threshold; releasing past the threshold\n// fires triggerSync(). Below threshold = snap back without action.\n//\n// Touch-only by design \u2014 desktop has the F5 / sync button. Pointer events\n// would also catch mouse-drag, which is more annoying than useful (people\n// drag the scrollbar). The `e.touches.length === 1` check skips two-finger\n// gestures (pinch-zoom, two-finger scroll).\n(() => {\n const PTR_THRESHOLD_PX = 80; // pull this far to arm the trigger\n const PTR_MAX_PX = 120; // cap the visual translate\n const mlBody = document.getElementById(\"ml-body\");\n if (!mlBody) return;\n const indicator = document.createElement(\"div\");\n indicator.className = \"ptr-indicator\";\n indicator.innerHTML = `<span class=\"ptr-chev\"></span><span class=\"ptr-label\">Pull to refresh</span>`;\n mlBody.parentElement?.insertBefore(indicator, mlBody);\n const labelEl = indicator.querySelector(\".ptr-label\") as HTMLElement;\n\n let startY = 0;\n let pullY = 0;\n let active = false;\n let refreshing = false;\n\n const setPull = (px: number, armed: boolean): void => {\n const clamped = Math.min(PTR_MAX_PX, Math.max(0, px));\n indicator.classList.toggle(\"armed\", armed);\n indicator.style.transform = `translateY(calc(-100% + ${clamped}px))`;\n };\n const reset = (): void => {\n indicator.classList.remove(\"dragging\", \"armed\");\n indicator.style.transform = \"\";\n };\n\n mlBody.addEventListener(\"touchstart\", (e: TouchEvent) => {\n if (refreshing) return;\n if (e.touches.length !== 1) return;\n if (mlBody.scrollTop > 0) return;\n startY = e.touches[0].clientY;\n pullY = 0;\n active = true;\n indicator.classList.add(\"dragging\");\n labelEl.textContent = \"Pull to refresh\";\n }, { passive: true });\n\n mlBody.addEventListener(\"touchmove\", (e: TouchEvent) => {\n if (!active || refreshing) return;\n const dy = e.touches[0].clientY - startY;\n if (dy <= 0) {\n // User scrolled up past the start \u2014 abandon, let normal scroll\n // take over without reverting (the touchend handler will reset).\n pullY = 0;\n setPull(0, false);\n return;\n }\n // Resist past 0 \u2014 pull \"stretches\" rather than scrolls. preventDefault\n // is necessary to keep the browser from interpreting the drag as\n // overscroll / browser-level pull-to-refresh.\n if (e.cancelable) e.preventDefault();\n pullY = dy * 0.5; // 0.5 = rubber-band damping\n const armed = pullY >= PTR_THRESHOLD_PX;\n labelEl.textContent = armed ? \"Release to refresh\" : \"Pull to refresh\";\n setPull(pullY, armed);\n }, { passive: false });\n\n const finish = async (): Promise<void> => {\n if (!active) return;\n active = false;\n if (pullY < PTR_THRESHOLD_PX) { reset(); return; }\n // Armed \u2014 show spinner, fire sync, clear when complete.\n refreshing = true;\n indicator.classList.remove(\"armed\", \"dragging\");\n indicator.classList.add(\"refreshing\");\n indicator.style.transform = `translateY(calc(-100% + 60px))`;\n labelEl.textContent = \"Refreshing...\";\n try {\n const { triggerSync } = await import(\"./lib/api-client.js\");\n await triggerSync();\n } catch (e: any) {\n console.error(\"pull-to-refresh failed:\", e?.message || e);\n }\n refreshing = false;\n indicator.classList.remove(\"refreshing\");\n reset();\n };\n mlBody.addEventListener(\"touchend\", () => { finish(); }, { passive: true });\n mlBody.addEventListener(\"touchcancel\", () => { active = false; reset(); }, { passive: true });\n})();\n\n// P115: pick up a pending mailto: drop file dropped by the CLI invocation\n// that spawned us (or by an earlier --mailto run that the user hasn't yet\n// acknowledged). The IPC call deletes the file as part of reading it, so a\n// race with the daemon's fs.watch is harmless \u2014 whichever fires first wins\n// and the other gets null. Subsequent live clicks during this session\n// arrive via the `openMailto` event handled in onEvent above.\n(async () => {\n try {\n const { consumePendingMailto } = await import(\"./lib/api-client.js\");\n const data = await consumePendingMailto();\n if (data && (data.to.length || data.subject || data.body)) {\n await openComposeFromMailto(data);\n }\n } catch (e: any) {\n console.error(\"pending mailto pickup failed:\", e?.message || e);\n }\n})();\n\n// Two-line toggle\noptTwoLine?.addEventListener(\"change\", () => {\n const list = document.getElementById(\"message-list\");\n if (optTwoLine.checked) {\n list?.classList.add(\"two-line\");\n } else {\n list?.classList.remove(\"two-line\");\n }\n localStorage.setItem(\"mailx-two-line\", String(optTwoLine.checked));\n});\n\n// Preview pane toggle\noptPreview?.addEventListener(\"change\", () => {\n const main = document.querySelector(\".main-area\");\n if (optPreview.checked) {\n main?.classList.remove(\"no-preview\");\n } else {\n main?.classList.add(\"no-preview\");\n }\n localStorage.setItem(\"mailx-preview\", String(optPreview.checked));\n});\n\n// Preview snippet toggle\noptSnippet?.addEventListener(\"change\", () => {\n const list = document.getElementById(\"message-list\");\n if (optSnippet.checked) {\n list?.classList.remove(\"no-snippets\");\n } else {\n list?.classList.add(\"no-snippets\");\n }\n localStorage.setItem(\"mailx-snippet\", String(optSnippet.checked));\n});\n\n// \u2500\u2500 Search help button (?) \u2500\u2500\n// Toggles a NON-modal panel with the search-syntax reference so it stays\n// visible while the user types into the search box \u2014 build a query\n// against it, then dismiss. The panel is anchored directly below the\n// search bar (no dark backdrop, no centered modal).\n//\n// The help content is HTML compiled into the app (client/help/search-help.ts),\n// NOT a docs/*.md file. Feature help is application content; the docs/*.md\n// files are only a stand-in for proper settings UI and stay out of this path.\n// The module is dynamically imported so its markup isn't in the cold-start\n// bundle.\ndocument.getElementById(\"search-help\")?.addEventListener(\"click\", async () => {\n const btn = document.getElementById(\"search-help\") as HTMLButtonElement | null;\n if (!btn) return;\n\n // Toggle: a second click on ? closes the open panel.\n const existing = document.getElementById(\"search-help-panel\");\n if (existing) { existing.remove(); btn.setAttribute(\"aria-expanded\", \"false\"); return; }\n\n const { SEARCH_HELP_HTML } = await import(\"./help/search-help.js\");\n\n const searchBar = document.querySelector(\"search.ml-search\") as HTMLElement | null;\n\n const panel = document.createElement(\"div\");\n panel.id = \"search-help-panel\";\n panel.className = \"search-help-panel\";\n panel.innerHTML = `\n <div class=\"search-help-head\">\n <span>Search syntax</span>\n <button type=\"button\" id=\"search-help-close\" title=\"Close (Esc)\" aria-label=\"Close\">×</button>\n </div>\n <div class=\"search-help-body\">${SEARCH_HELP_HTML}</div>\n `;\n document.body.appendChild(panel);\n btn.setAttribute(\"aria-expanded\", \"true\");\n\n // Anchor below the search bar, clamped to the viewport. Re-runs on\n // resize so the panel tracks the bar if the layout reflows.\n const position = (): void => {\n const anchor = searchBar || btn;\n const rect = anchor.getBoundingClientRect();\n const margin = 8;\n const top = rect.bottom + 2;\n const width = Math.min(640, Math.max(320, rect.width));\n let left = rect.left;\n if (left + width > window.innerWidth - margin) left = window.innerWidth - margin - width;\n if (left < margin) left = margin;\n panel.style.top = `${top}px`;\n panel.style.left = `${left}px`;\n panel.style.width = `${width}px`;\n panel.style.maxHeight = `${Math.max(160, window.innerHeight - top - margin)}px`;\n };\n position();\n\n const close = (): void => {\n panel.remove();\n btn.setAttribute(\"aria-expanded\", \"false\");\n window.removeEventListener(\"resize\", position);\n document.removeEventListener(\"keydown\", onKey, true);\n document.removeEventListener(\"mousedown\", onOutside);\n };\n // Esc always closes the help panel while it's open \u2014 including when\n // focus is in the search box. Capture phase + stopPropagation so the\n // search input doesn't also clear/blur on the same keystroke.\n const onKey = (e: KeyboardEvent): void => {\n if (e.key === \"Escape\") {\n e.preventDefault();\n e.stopPropagation();\n close();\n }\n };\n // Click outside dismisses \u2014 except clicks on the search bar itself\n // (typing a query must not close the reference) or the ? button\n // (its own handler toggles).\n const onOutside = (e: MouseEvent): void => {\n const t = e.target as Node;\n if (panel.contains(t) || btn.contains(t) || searchBar?.contains(t)) return;\n close();\n };\n panel.querySelector(\"#search-help-close\")?.addEventListener(\"click\", close);\n window.addEventListener(\"resize\", position);\n document.addEventListener(\"keydown\", onKey, true);\n document.addEventListener(\"mousedown\", onOutside);\n});\n\n// \u2500\u2500 JSONC config file editor \u2500\u2500\ndocument.getElementById(\"btn-edit-jsonc\")?.addEventListener(\"click\", async () => {\n const settingsDropdown = document.getElementById(\"settings-dropdown\");\n if (settingsDropdown) settingsDropdown.hidden = true;\n await openJsoncEditor(\"accounts.jsonc\");\n});\n// Allow other components (remote-content banner, etc.) to open the editor\n// pre-selected to a specific file.\ndocument.addEventListener(\"mailx-open-jsonc-editor\", async (ev: Event) => {\n const file = ((ev as CustomEvent).detail?.file as string) || \"accounts.jsonc\";\n await openJsoncEditor(file);\n});\n// Q61: open ~/.mailx in OS file explorer.\ndocument.getElementById(\"btn-open-mailx-dir\")?.addEventListener(\"click\", async () => {\n const settingsDropdown = document.getElementById(\"settings-dropdown\");\n if (settingsDropdown) settingsDropdown.hidden = true;\n try {\n const { openLocalPath } = await import(\"./lib/api-client.js\");\n await openLocalPath(\"config\");\n } catch (e: any) {\n alert(`Couldn't open folder: ${e?.message || e}`);\n }\n});\n// Q62: open today's log file.\ndocument.getElementById(\"btn-open-log\")?.addEventListener(\"click\", async () => {\n const settingsDropdown = document.getElementById(\"settings-dropdown\");\n if (settingsDropdown) settingsDropdown.hidden = true;\n try {\n const { openLocalPath } = await import(\"./lib/api-client.js\");\n await openLocalPath(\"log\");\n } catch (e: any) {\n alert(`Couldn't open log: ${e?.message || e}`);\n }\n});\n\nasync function openJsoncEditor(initialFile: string): Promise<void> {\n const { readJsoncFile, writeJsoncFile, readConfigHelp, formatJsonc, openLocalPath } = await import(\"./lib/api-client.js\");\n\n const backdrop = document.createElement(\"div\");\n backdrop.className = \"mailx-modal-backdrop\";\n const panel = document.createElement(\"div\");\n panel.className = \"mailx-modal mailx-modal-wide\";\n panel.innerHTML = `\n <div class=\"mailx-modal-title\">\n <span class=\"mailx-modal-title-text\">Edit config file</span>\n <button type=\"button\" class=\"mailx-modal-close\" id=\"jsonc-close\" title=\"Close (Esc)\" aria-label=\"Close\">×</button>\n </div>\n <label class=\"mailx-modal-label\">File\n <select class=\"mailx-modal-input\" id=\"jsonc-file\">\n <option value=\"accounts.jsonc\">accounts.jsonc \u2014 accounts (shared via Google Drive)</option>\n <option value=\"contacts.jsonc\">contacts.jsonc \u2014 preferred + denylist + discovered (shared)</option>\n <option value=\"allowlist.jsonc\">allowlist.jsonc \u2014 remote-content allowlist (shared)</option>\n <option value=\"clients.jsonc\">clients.jsonc \u2014 per-device registrations (shared)</option>\n <option value=\"config.jsonc\">config.jsonc \u2014 local per-machine overrides (not synced)</option>\n </select>\n </label>\n <div class=\"mailx-modal-split\">\n <label class=\"mailx-modal-label mailx-modal-split-left\">Contents (JSONC \u2014 comments and trailing commas allowed)\n <div class=\"jsonc-editor-wrap\">\n <div class=\"jsonc-gutter\" id=\"jsonc-gutter\" aria-hidden=\"true\"></div>\n <textarea class=\"mailx-modal-input mailx-modal-textarea jsonc-textarea\" id=\"jsonc-content\" spellcheck=\"false\"></textarea>\n </div>\n </label>\n <div class=\"mailx-modal-split-right mailx-help-panel\">\n <div class=\"mailx-help-title\">\n <button type=\"button\" class=\"mailx-help-toggle\" id=\"jsonc-help-toggle\" aria-expanded=\"true\" title=\"Hide/show help\">\u25BE Help</button>\n </div>\n <div class=\"mailx-help-body\" id=\"jsonc-help-body\"></div>\n </div>\n </div>\n <div class=\"mailx-modal-error\" id=\"jsonc-error\" hidden></div>\n <div class=\"mailx-modal-buttons\">\n <button type=\"button\" class=\"mailx-modal-btn\" data-action=\"format\" title=\"Reformat indentation while preserving comments and trailing commas\">Format</button>\n <button type=\"button\" class=\"mailx-modal-btn\" data-action=\"opensource\" id=\"jsonc-opensource\" title=\"Open the folder containing config.jsonc so you can edit it in a full editor\" hidden>Open source folder</button>\n <span class=\"mailx-modal-spacer\"></span>\n <button type=\"button\" class=\"mailx-modal-btn\" data-action=\"cancel\">Cancel</button>\n <button type=\"button\" class=\"mailx-modal-btn mailx-modal-btn-primary\" data-action=\"save\">Save</button>\n </div>`;\n backdrop.appendChild(panel);\n document.body.appendChild(backdrop);\n\n const fileSelect = panel.querySelector<HTMLSelectElement>(\"#jsonc-file\")!;\n const textarea = panel.querySelector<HTMLTextAreaElement>(\"#jsonc-content\")!;\n const gutter = panel.querySelector<HTMLElement>(\"#jsonc-gutter\")!;\n const errorEl = panel.querySelector<HTMLElement>(\"#jsonc-error\")!;\n const saveBtn = panel.querySelector<HTMLButtonElement>('[data-action=\"save\"]')!;\n const helpBody = panel.querySelector<HTMLElement>(\"#jsonc-help-body\")!;\n const helpToggle = panel.querySelector<HTMLButtonElement>(\"#jsonc-help-toggle\")!;\n const helpPanel = panel.querySelector<HTMLElement>(\".mailx-help-panel\")!;\n fileSelect.value = initialFile;\n\n // Line-number gutter \u2014 recomputed whenever the textarea content changes,\n // scroll-synced so numbers stay aligned. errorLine (1-based) is highlighted\n // red so the \"Line N, col M\" error message in the status bar points at a\n // visible marker in the gutter.\n let errorLine = 0;\n const renderGutter = () => {\n const lines = textarea.value.split(\"\\n\").length;\n let html = \"\";\n for (let i = 1; i <= lines; i++) {\n html += i === errorLine\n ? `<div class=\"jsonc-gutter-line jsonc-gutter-error\">${i}</div>`\n : `<div class=\"jsonc-gutter-line\">${i}</div>`;\n }\n gutter.innerHTML = html;\n };\n const syncScroll = () => { gutter.scrollTop = textarea.scrollTop; };\n textarea.addEventListener(\"scroll\", syncScroll);\n textarea.addEventListener(\"input\", renderGutter);\n\n helpToggle.addEventListener(\"click\", () => {\n const open = helpPanel.classList.toggle(\"mailx-help-collapsed\");\n helpToggle.textContent = open ? \"\u25B8 Help\" : \"\u25BE Help\";\n helpToggle.setAttribute(\"aria-expanded\", open ? \"false\" : \"true\");\n });\n\n const loadHelp = async () => {\n helpBody.textContent = \"Loading help\u2026\";\n try {\n const r = await readConfigHelp(fileSelect.value);\n const md = (r?.content || \"\").trim();\n helpBody.innerHTML = md ? renderMarkdown(md) : \"<em>No help available for this file.</em>\";\n } catch (e: any) {\n helpBody.textContent = `Help unavailable: ${e.message}`;\n }\n };\n\n const clearValidation = () => {\n errorEl.hidden = true;\n errorEl.textContent = \"\";\n textarea.classList.remove(\"mailx-modal-input-error\");\n saveBtn.disabled = false;\n errorLine = 0;\n renderGutter();\n };\n const showValidation = (err: { message: string; pos: number; line: number; col: number }) => {\n // CRITICAL: do NOT move the cursor here. Validation fires every 600ms\n // while the user types; auto-selecting the error position yanked the\n // cursor mid-edit and made fixing the error impossible (the user\n // reported this as a fatal bug \u2014 the very mechanism preventing a save\n // was preventing the fix). Location is shown via the gutter highlight\n // + the \"Line N, col M\" message, and the user can click \"Jump\" to\n // explicitly navigate.\n errorEl.innerHTML = \"\";\n const text = document.createElement(\"span\");\n text.textContent = `Line ${err.line}, col ${err.col}: ${err.message} `;\n const jumpBtn = document.createElement(\"button\");\n jumpBtn.type = \"button\";\n jumpBtn.className = \"mailx-modal-btn mailx-modal-btn-link\";\n jumpBtn.textContent = \"Jump to error\";\n jumpBtn.addEventListener(\"click\", () => {\n textarea.focus();\n try { textarea.setSelectionRange(err.pos, err.pos + 1); } catch { /* */ }\n });\n errorEl.appendChild(text);\n errorEl.appendChild(jumpBtn);\n errorEl.hidden = false;\n textarea.classList.add(\"mailx-modal-input-error\");\n saveBtn.disabled = true;\n errorLine = err.line;\n renderGutter();\n };\n\n let validateTimer: number | undefined;\n const scheduleValidate = () => {\n if (validateTimer) window.clearTimeout(validateTimer);\n validateTimer = window.setTimeout(() => {\n const err = validateJsonc(textarea.value);\n if (err) showValidation(err); else clearValidation();\n }, 600);\n };\n textarea.addEventListener(\"input\", scheduleValidate);\n\n const loadFile = async () => {\n textarea.value = \"Loading...\";\n clearValidation();\n renderGutter();\n try {\n const r = await readJsoncFile(fileSelect.value);\n textarea.value = r?.content || \"\";\n renderGutter();\n scheduleValidate();\n } catch (e: any) {\n textarea.value = \"\";\n renderGutter();\n errorEl.textContent = `Failed to load: ${e.message}`;\n errorEl.hidden = false;\n }\n };\n // \"Open source folder\" \u2014 only meaningful for config.jsonc, the one\n // file that lives on the local disk (~/.rmfmail/config.jsonc). The\n // others are Google Drive objects with no local path, so the button\n // is hidden for them. Lets the user edit config.jsonc in a real editor\n // instead of the modal textarea.\n const openSourceBtn = panel.querySelector<HTMLButtonElement>(\"#jsonc-opensource\")!;\n const updateOpenSource = () => { openSourceBtn.hidden = fileSelect.value !== \"config.jsonc\"; };\n updateOpenSource();\n\n await Promise.all([loadFile(), loadHelp()]);\n fileSelect.addEventListener(\"change\", () => { loadFile(); loadHelp(); updateOpenSource(); });\n\n const close = () => {\n if (validateTimer) window.clearTimeout(validateTimer);\n backdrop.remove();\n document.removeEventListener(\"keydown\", onKey, true);\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") { e.stopPropagation(); e.preventDefault(); close(); }\n };\n document.addEventListener(\"keydown\", onKey, true);\n panel.querySelector<HTMLButtonElement>(\"#jsonc-close\")!.addEventListener(\"click\", close);\n\n panel.querySelectorAll<HTMLButtonElement>(\".mailx-modal-btn\").forEach(btn => {\n btn.addEventListener(\"click\", async () => {\n const action = btn.dataset.action;\n if (action === \"cancel\") { close(); return; }\n if (action === \"opensource\") {\n // Reveal ~/.rmfmail/ in the OS file manager \u2014 config.jsonc\n // sits there; the user can then open it in a full editor.\n try { await openLocalPath(\"config\"); }\n catch (e: any) { errorEl.textContent = `Couldn't open folder: ${e?.message || e}`; errorEl.hidden = false; }\n return;\n }\n if (action === \"format\") {\n // Reformat via the service-side jsonc-parser format() \u2014 the\n // edits are whitespace-only, so `//` and `/* */` comments\n // survive intact (which JSON.stringify(parse(...)) does not).\n btn.disabled = true;\n const orig = btn.textContent;\n btn.textContent = \"Formatting\u2026\";\n try {\n const r = await formatJsonc(textarea.value);\n if (r?.content !== undefined) {\n textarea.value = r.content;\n renderGutter();\n scheduleValidate();\n }\n } catch (e: any) {\n errorEl.textContent = `Format failed: ${e.message}`;\n errorEl.hidden = false;\n } finally {\n btn.disabled = false;\n btn.textContent = orig || \"Format\";\n }\n return;\n }\n if (action === \"save\") {\n // Final sync-check; refuse to save if it doesn't parse\n const err = validateJsonc(textarea.value);\n if (err) { showValidation(err); return; }\n errorEl.hidden = true;\n btn.disabled = true;\n btn.textContent = \"Saving...\";\n // Auto-format before save: the service-side formatter does\n // whitespace-only edits via jsonc-parser, so // and /* */\n // comments survive. If the format call fails (network /\n // parse) we fall back to the raw text \u2014 better to save\n // something than lose the user's edit.\n let toWrite = textarea.value;\n try {\n const r = await formatJsonc(textarea.value);\n if (r?.content !== undefined) {\n toWrite = r.content;\n textarea.value = r.content;\n }\n } catch { /* keep raw text */ }\n try {\n await writeJsoncFile(fileSelect.value, toWrite);\n close();\n const statusSync = document.getElementById(\"status-sync\");\n if (statusSync) statusSync.textContent = `Saved ${fileSelect.value} \u2014 restart mailx to apply`;\n } catch (e: any) {\n errorEl.textContent = `${e.message}`;\n errorEl.hidden = false;\n btn.disabled = false;\n btn.textContent = \"Save\";\n }\n }\n });\n });\n backdrop.addEventListener(\"mousedown\", (e) => { if (e.target === backdrop) close(); });\n}\n\n// JSONC validator \u2014 strips comments + trailing commas (preserving source positions\n// by replacing stripped chars with spaces/newlines) and runs JSON.parse. Reports\n// only the *first* error; cascading errors are suppressed.\nfunction validateJsonc(src: string): { message: string; pos: number; line: number; col: number } | null {\n const stripped = stripJsoncPreservingPositions(src);\n if (stripped.error) {\n const { pos, line, col } = offsetToLineCol(src, stripped.error.pos);\n return { message: stripped.error.message, pos, line, col };\n }\n if (stripped.text.trim() === \"\") return null; // empty file: treat as valid (settings code handles)\n try {\n JSON.parse(stripped.text);\n return null;\n } catch (e: any) {\n const msg = String(e?.message || \"parse error\");\n const m = msg.match(/at position (\\d+)/i);\n const pos = m ? Math.min(parseInt(m[1], 10), src.length - 1) : 0;\n const lc = offsetToLineCol(src, pos);\n return { message: msg.replace(/\\s*at position \\d+/i, \"\"), pos: lc.pos, line: lc.line, col: lc.col };\n }\n}\n\nfunction stripJsoncPreservingPositions(src: string): { text: string; error?: { message: string; pos: number } } {\n const out: string[] = new Array(src.length);\n let i = 0;\n const n = src.length;\n while (i < n) {\n const c = src[i];\n const next = src[i + 1];\n if (c === '\"') {\n out[i] = c; i++;\n while (i < n) {\n const ch = src[i];\n out[i] = ch; i++;\n if (ch === \"\\\\\" && i < n) { out[i] = src[i]; i++; continue; }\n if (ch === '\"') break;\n if (ch === \"\\n\") return { text: out.join(\"\"), error: { message: \"unterminated string\", pos: i - 1 } };\n }\n } else if (c === \"/\" && next === \"/\") {\n while (i < n && src[i] !== \"\\n\") { out[i] = \" \"; i++; }\n } else if (c === \"/\" && next === \"*\") {\n const start = i;\n out[i] = \" \"; out[i + 1] = \" \"; i += 2;\n let closed = false;\n while (i < n) {\n if (src[i] === \"*\" && src[i + 1] === \"/\") { out[i] = \" \"; out[i + 1] = \" \"; i += 2; closed = true; break; }\n out[i] = src[i] === \"\\n\" ? \"\\n\" : \" \"; i++;\n }\n if (!closed) return { text: out.join(\"\"), error: { message: \"unterminated block comment\", pos: start } };\n } else if (c === \",\") {\n // trailing comma before } or ] \u2192 replace with space\n let j = i + 1;\n while (j < n && /\\s/.test(src[j])) j++;\n if (j < n && (src[j] === \"}\" || src[j] === \"]\")) { out[i] = \" \"; i++; }\n else { out[i] = c; i++; }\n } else {\n out[i] = c; i++;\n }\n }\n return { text: out.join(\"\") };\n}\n\nfunction offsetToLineCol(src: string, pos: number): { pos: number; line: number; col: number } {\n pos = Math.max(0, Math.min(pos, src.length));\n let line = 1, col = 1;\n for (let i = 0; i < pos; i++) {\n if (src[i] === \"\\n\") { line++; col = 1; } else col++;\n }\n return { pos, line, col };\n}\n\n// Minimal markdown renderer for the help panel. Supports: headings, fenced code blocks,\n// inline code, bold, italic, links, bullet lists, paragraphs. HTML is escaped first.\nfunction renderMarkdown(md: string): string {\n const esc = (s: string) => s.replace(/[&<>\"']/g, c =>\n ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c]!);\n\n // Pull fenced code blocks out first so their contents aren't processed as markdown.\n const blocks: string[] = [];\n let src = md.replace(/```(\\w*)\\n([\\s\\S]*?)```/g, (_m, _lang, code) => {\n const i = blocks.length;\n blocks.push(`<pre class=\"mailx-help-code\"><code>${esc(code)}</code></pre>`);\n return `\\u0000BLOCK${i}\\u0000`;\n });\n\n const lines = src.split(/\\r?\\n/);\n const out: string[] = [];\n let inList = false;\n let para: string[] = [];\n const flushPara = () => {\n if (para.length) { out.push(`<p>${inline(para.join(\" \"))}</p>`); para = []; }\n };\n const closeList = () => { if (inList) { out.push(\"</ul>\"); inList = false; } };\n\n function inline(s: string): string {\n s = esc(s);\n s = s.replace(/`([^`]+)`/g, (_m, c) => `<code>${c}</code>`);\n s = s.replace(/\\*\\*([^*]+)\\*\\*/g, \"<strong>$1</strong>\");\n s = s.replace(/(^|[^*])\\*([^*\\n]+)\\*/g, \"$1<em>$2</em>\");\n s = s.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href=\"$2\" target=\"_blank\" rel=\"noopener\">$1</a>');\n return s;\n }\n\n for (const raw of lines) {\n const blockMatch = /^\\u0000BLOCK(\\d+)\\u0000$/.exec(raw);\n if (blockMatch) { flushPara(); closeList(); out.push(blocks[parseInt(blockMatch[1], 10)]); continue; }\n const h = /^(#{1,6})\\s+(.+)$/.exec(raw);\n if (h) { flushPara(); closeList(); const lvl = h[1].length; out.push(`<h${lvl}>${inline(h[2])}</h${lvl}>`); continue; }\n const bullet = /^\\s*[-*]\\s+(.+)$/.exec(raw);\n if (bullet) {\n flushPara();\n if (!inList) { out.push(\"<ul>\"); inList = true; }\n out.push(`<li>${inline(bullet[1])}</li>`);\n continue;\n }\n if (raw.trim() === \"\") { flushPara(); closeList(); continue; }\n para.push(raw);\n }\n flushPara();\n closeList();\n return out.join(\"\\n\");\n}\n\n// \u2500\u2500 Keyboard shortcuts (Settings menu item) \u2500\u2500\ndocument.getElementById(\"btn-shortcuts\")?.addEventListener(\"click\", () => {\n const settingsDropdown = document.getElementById(\"settings-dropdown\");\n if (settingsDropdown) settingsDropdown.hidden = true;\n openShortcutsDialog();\n});\n\n// \u2500\u2500 Check for updates (Settings menu item) \u2500\u2500\n// On phone narrow tier the toolbar Update menu is collapsed away \u2014 give the\n// user an explicit, non-reloading path to trigger the update poll. The\n// AppUpdater banner appears at the bottom of the WebView if a newer version\n// is available; otherwise the status bar reports \"up to date\".\ndocument.getElementById(\"btn-settings-checkupdate\")?.addEventListener(\"click\", () => {\n const settingsDropdown = document.getElementById(\"settings-dropdown\");\n if (settingsDropdown) settingsDropdown.hidden = true;\n const statusSync = document.getElementById(\"status-sync\");\n if (statusSync) statusSync.textContent = \"Checking for updates\u2026\";\n const isAndroid = (window as any).mailxapi?.platform === \"android\";\n if (isAndroid) {\n // Bridge fires the C# AppUpdater.CheckForUpdate, which injects the\n // bottom banner if a newer version is on rmf39.aaz.lt/mailx.\n const f = document.createElement(\"iframe\");\n f.style.display = \"none\";\n f.src = \"mailxapi://checkUpdate\";\n document.body.appendChild(f);\n setTimeout(() => f.remove(), 100);\n // Status hint resets after a few seconds in case nothing happens\n // (already up to date, or fetch failed silently).\n setTimeout(() => {\n if (statusSync && statusSync.textContent === \"Checking for updates\u2026\") {\n statusSync.textContent = \"Up to date or check pending \u2014 see banner if available\";\n setTimeout(() => { if (statusSync.textContent?.startsWith(\"Up to date\")) statusSync.textContent = \"\"; }, 6000);\n }\n }, 4000);\n } else {\n // Desktop: same path as the toolbar btn-update.\n const ipc = (window as any).mailxapi || (window as any).opener?.mailxapi;\n if (ipc?.performUpdate) {\n if (statusSync) statusSync.textContent = \"Updating\u2026 mailx will restart when done\";\n ipc.performUpdate();\n } else if (statusSync) {\n statusSync.textContent = \"Update not available in this mode\";\n }\n }\n});\n\n// \u2500\u2500 About dialog \u2500\u2500\ndocument.getElementById(\"btn-about\")?.addEventListener(\"click\", () => {\n const settingsDropdown = document.getElementById(\"settings-dropdown\");\n if (settingsDropdown) settingsDropdown.hidden = true;\n openAboutDialog();\n});\n// Clicking the version string (toolbar in wide mode, status bar in narrow mode) also opens About\ndocument.querySelectorAll<HTMLElement>(\".app-version\").forEach(el => {\n el.style.cursor = \"pointer\";\n el.addEventListener(\"click\", openAboutDialog);\n});\n\nasync function openAboutDialog(): Promise<void> {\n const backdrop = document.createElement(\"div\");\n backdrop.className = \"mailx-modal-backdrop\";\n const panel = document.createElement(\"div\");\n panel.className = \"mailx-modal\";\n panel.innerHTML = `\n <div class=\"mailx-modal-title\">\n <span class=\"mailx-modal-title-text\">About ${APP_NAME}</span>\n <button type=\"button\" class=\"mailx-modal-close\" id=\"about-x\" title=\"Close (Esc)\" aria-label=\"Close\">×</button>\n </div>\n <div class=\"mailx-about\" id=\"about-body\">Loading...</div>\n <div class=\"mailx-modal-buttons\">\n <span class=\"mailx-modal-spacer\"></span>\n <button type=\"button\" class=\"mailx-modal-btn mailx-modal-btn-primary\" data-action=\"close\">Close</button>\n </div>`;\n backdrop.appendChild(panel);\n document.body.appendChild(panel.parentElement!);\n\n const body = panel.querySelector<HTMLElement>(\"#about-body\")!;\n const close = () => {\n backdrop.remove();\n document.removeEventListener(\"keydown\", onKey, true);\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") { e.stopPropagation(); e.preventDefault(); close(); }\n };\n document.addEventListener(\"keydown\", onKey, true);\n panel.querySelector<HTMLButtonElement>('[data-action=\"close\"]')!\n .addEventListener(\"click\", close);\n panel.querySelector<HTMLButtonElement>(\"#about-x\")!\n .addEventListener(\"click\", close);\n backdrop.addEventListener(\"mousedown\", (e) => { if (e.target === backdrop) close(); });\n\n try {\n const [v, accounts] = await Promise.all([\n getVersion().catch(() => ({} as any)),\n getAccounts().catch(() => [] as any[]),\n ]);\n const storage = v.storage || {};\n const isApp = typeof mailxapi !== \"undefined\" && mailxapi?.isApp;\n const platform = isApp ? (mailxapi?.platform || \"app\") : \"browser\";\n const versionText = v.version ? `v${v.version}` : \"unknown\";\n const versionHtml = v.version\n ? `<a href=\"https://github.com/BobFrankston/mailx/releases/tag/v${v.version}\" target=\"_blank\" rel=\"noopener\">${versionText}</a>`\n : versionText;\n const rows: [string, string][] = [\n [\"Version\", versionHtml],\n [\"Platform\", platform],\n [\"Storage\", storage.provider || \"local\"],\n ];\n if (storage.cloudPath) rows.push([\"Cloud path\", `My Drive/${storage.cloudPath}/`]);\n if (storage.mode) rows.push([\"Storage mode\", storage.mode]);\n if (storage.folderPath) rows.push([\"Drive path\", storage.folderPath]);\n else if (storage.folderName) rows.push([\"Drive folder\", storage.folderName]);\n if (storage.folderId) rows.push([\"Drive folderId\", storage.folderId]);\n if (storage.folderOwner) rows.push([\"Drive owner\", storage.folderOwner]);\n if (storage.configDir) rows.push([\"Config dir\", storage.configDir]);\n rows.push([\"Accounts\", String((accounts || []).length)]);\n rows.push([\"User agent\", navigator.userAgent]);\n rows.push([\"Screen\", `${screen.width}\u00D7${screen.height}`]);\n rows.push([\"Window\", `${window.innerWidth}\u00D7${window.innerHeight}`]);\n\n // Version row contains an anchor tag; all other rows are plain text\n // and must be escaped. Treat row[0]===\"Version\" as pre-formatted HTML.\n body.innerHTML = `\n <dl class=\"mailx-about-dl\">\n ${rows.map(([k, val]) => `<dt>${k}</dt><dd>${k === \"Version\" ? val : escapeHtml(val)}</dd>`).join(\"\")}\n </dl>\n ${(accounts || []).length ? `\n <div class=\"mailx-about-accounts\">\n <div class=\"mailx-about-section\">Accounts</div>\n <ul>\n ${(accounts as any[]).map(a => `<li>${escapeHtml(a.email || a.id)}${a.name ? ` \u2014 ${escapeHtml(a.name)}` : \"\"}</li>`).join(\"\")}\n </ul>\n </div>` : \"\"}\n <div class=\"mailx-about-foot\">${APP_NAME} \u2014 local-first mail client</div>`;\n } catch (e: any) {\n body.textContent = `Failed to load: ${e.message}`;\n }\n}\n\nfunction escapeHtml(s: string): string {\n return String(s).replace(/[&<>\"']/g, c =>\n ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c]!);\n}\n\n// Threaded view toggle\noptThreaded?.addEventListener(\"change\", () => {\n const body = document.getElementById(\"ml-body\");\n if (optThreaded.checked) {\n body?.classList.add(\"threaded\");\n } else {\n body?.classList.remove(\"threaded\");\n }\n localStorage.setItem(\"mailx-threaded\", String(optThreaded.checked));\n reloadCurrentFolder();\n});\n\n// Flagged-only filter \u2014 keeps the CSS-level hiding for instant feedback on\n// the current page AND re-queries the folder so flagged messages that live\n// outside the currently-loaded page show up.\noptFlagged?.addEventListener(\"change\", () => {\n const body = document.getElementById(\"ml-body\");\n if (optFlagged.checked) body?.classList.add(\"flagged-only\");\n else body?.classList.remove(\"flagged-only\");\n localStorage.setItem(\"mailx-flagged\", String(optFlagged.checked));\n reloadCurrentFolder();\n});\n\n// Priority-senders-only filter \u2014 same pattern as flagged-only. Adds\n// .priority-only to the list body; CSS hides any row without .priority.\nconst optPriorityOnly = document.getElementById(\"opt-priority-only\") as HTMLInputElement | null;\nif (optPriorityOnly) {\n optPriorityOnly.checked = localStorage.getItem(\"mailx-priority-only\") === \"true\";\n const body0 = document.getElementById(\"ml-body\");\n if (optPriorityOnly.checked) body0?.classList.add(\"priority-only\");\n optPriorityOnly.addEventListener(\"change\", () => {\n const body = document.getElementById(\"ml-body\");\n if (optPriorityOnly.checked) body?.classList.add(\"priority-only\");\n else body?.classList.remove(\"priority-only\");\n localStorage.setItem(\"mailx-priority-only\", String(optPriorityOnly.checked));\n });\n}\n\n// Folder counts toggle\noptFolderCounts?.addEventListener(\"change\", () => {\n const tree = document.getElementById(\"folder-tree\");\n if (optFolderCounts.checked) {\n tree?.classList.add(\"show-folder-counts\");\n } else {\n tree?.classList.remove(\"show-folder-counts\");\n }\n localStorage.setItem(\"mailx-folder-counts\", String(optFolderCounts.checked));\n});\n\n// Q52: Reset column widths \u2014 clears persisted list/viewer splitter and\n// restores the default CSS-var value. Currently only the list/viewer split\n// is user-resizable; if per-column drag-resize lands later, add its keys to\n// the cleanup list below.\ndocument.getElementById(\"btn-reset-widths\")?.addEventListener(\"click\", () => {\n localStorage.removeItem(\"mailx-split\");\n document.documentElement.style.removeProperty(\"--list-viewer-split\");\n if (viewDropdown) viewDropdown.hidden = true;\n});\n\n// \u2500\u2500 Settings menu \u2500\u2500\n\nconst settingsBtn = document.getElementById(\"btn-settings\");\nconst settingsDropdown = document.getElementById(\"settings-dropdown\");\nconst optEditorQuill = document.getElementById(\"opt-editor-quill\") as HTMLInputElement;\nconst optEditorTiptap = document.getElementById(\"opt-editor-tiptap\") as HTMLInputElement;\nconst optEditorTinymce = document.getElementById(\"opt-editor-tinymce\") as HTMLInputElement | null;\nconst optTinymceCdn = document.getElementById(\"opt-tinymce-cdn\") as HTMLInputElement | null;\n// Restore TinyMCE CDN URL (Android-only path) from localStorage; the\n// adapter reads it on demand. Persisted client-side because it's a\n// per-device setting (Android needs CDN; desktop has npm install).\ntry {\n if (optTinymceCdn) optTinymceCdn.value = localStorage.getItem(\"mailx-tinymce-cdn\") || \"\";\n} catch { /* */ }\noptTinymceCdn?.addEventListener(\"change\", () => {\n try { localStorage.setItem(\"mailx-tinymce-cdn\", optTinymceCdn.value.trim()); } catch { /* */ }\n});\n\nsettingsBtn?.addEventListener(\"click\", (e) => {\n e.stopPropagation();\n if (viewDropdown) viewDropdown.hidden = true;\n const restartDd = document.getElementById(\"restart-dropdown\");\n if (restartDd) restartDd.hidden = true;\n restoreToolbarDropdown(\"settings-dropdown\", \"settings-menu\");\n if (settingsDropdown) settingsDropdown.hidden = !settingsDropdown.hidden;\n});\n// Close handled by the shared document click handler above\n\n// Load current editor setting from server\ngetSettings().then((s: any) => {\n const ed = s.ui?.editor || \"quill\";\n if (optEditorQuill) optEditorQuill.checked = ed === \"quill\";\n if (optEditorTiptap) optEditorTiptap.checked = ed === \"tiptap\";\n if (optEditorTinymce) optEditorTinymce.checked = ed === \"tinymce\";\n}).catch(() => {});\n\n// Save editor choice to server settings\nfunction saveEditorSetting(editor: string): void {\n // Update the localStorage cache SYNCHRONOUSLY. compose.ts reads\n // `mailx-editor-type` from localStorage at module-load to pick the\n // editor \u2014 its async getSettings() refresh only runs when a compose\n // window opens, and reads localStorage FIRST. Without this write the\n // cache stays stale and the next compose keeps the old editor until a\n // full app restart (Bob 2026-05-21: \"changed to quill but got tinymce\n // until I restarted\"). With it, the very next compose-open is correct.\n try { localStorage.setItem(\"mailx-editor-type\", editor); } catch { /* private mode */ }\n getSettings().then((settings: any) => {\n settings.ui = { ...settings.ui, editor };\n saveSettings(settings);\n }).catch(() => {});\n}\n\noptEditorQuill?.addEventListener(\"change\", () => {\n if (optEditorQuill.checked) saveEditorSetting(\"quill\");\n});\noptEditorTiptap?.addEventListener(\"change\", () => {\n if (optEditorTiptap.checked) saveEditorSetting(\"tiptap\");\n});\noptEditorTinymce?.addEventListener(\"change\", () => {\n if (!optEditorTinymce.checked) return;\n saveEditorSetting(\"tinymce\");\n // Q133 + 2026-05-21 \u2014 warm the TinyMCE bundle into the HTTP cache so the\n // first compose-open doesn't stall downloading it. The old code used a\n // silent <link rel=prefetch> \u2014 no completion signal, so picking TinyMCE\n // looked like a frozen UI (\"hang while fetching\"). Now we do an explicit\n // fetch() (I/O, already off the UI thread \u2014 a Worker buys nothing for a\n // download) and surface a pending \u2192 ready indicator on the menu item.\n const cdnUrl = localStorage.getItem(\"mailx-tinymce-cdn\") || \"lib/tinymce/tinymce.min.js\";\n const label = optEditorTinymce.closest(\"label\");\n let status = document.getElementById(\"opt-editor-tinymce-status\");\n if (label && !status) {\n status = document.createElement(\"span\");\n status.id = \"opt-editor-tinymce-status\";\n status.className = \"tb-menu-status\";\n label.appendChild(status);\n }\n const setStatus = (text: string, state: string): void => {\n if (!status) return;\n status.textContent = text;\n status.dataset.state = state;\n };\n setStatus(\" loading\u2026\", \"pending\"); // CSS ::before draws the spinner\n fetch(cdnUrl, { cache: \"force-cache\" })\n .then(r => (r.ok ? r.arrayBuffer() : Promise.reject(new Error(`HTTP ${r.status}`))))\n .then(() => {\n setStatus(\" \u2713 ready\", \"ready\");\n // Clear the badge after a few seconds \u2014 but only if still \"ready\"\n // (don't wipe a later pending/error state from a re-pick).\n setTimeout(() => { if (status?.dataset.state === \"ready\") setStatus(\"\", \"idle\"); }, 4000);\n })\n .catch(e => {\n setStatus(\" \u26A0 load failed\", \"error\");\n console.error(\"[tinymce] pre-warm fetch failed:\", e?.message || e);\n });\n});\n\n// External editor preference (Edit-in-Word handoff target). Stored under\n// settings.externalEditor so the service can read it via loadSettings().\n// \"auto\" tries Word \u2192 LibreOffice \u2192 OS default; explicit values force\n// that editor (still falling back to OS default if it isn't installed).\nconst optExtEditAuto = document.getElementById(\"opt-extedit-auto\") as HTMLInputElement | null;\nconst optExtEditWord = document.getElementById(\"opt-extedit-word\") as HTMLInputElement | null;\nconst optExtEditLibre = document.getElementById(\"opt-extedit-libre\") as HTMLInputElement | null;\ngetSettings().then((s: any) => {\n const v = s.externalEditor || \"auto\";\n if (optExtEditAuto) optExtEditAuto.checked = v === \"auto\";\n if (optExtEditWord) optExtEditWord.checked = v === \"word\";\n if (optExtEditLibre) optExtEditLibre.checked = v === \"libreoffice\";\n}).catch(() => {});\nfunction saveExtEditor(v: \"auto\" | \"word\" | \"libreoffice\"): void {\n getSettings().then((settings: any) => {\n settings.externalEditor = v;\n saveSettings(settings);\n }).catch(() => {});\n}\noptExtEditAuto?.addEventListener(\"change\", () => { if (optExtEditAuto.checked) saveExtEditor(\"auto\"); });\noptExtEditWord?.addEventListener(\"change\", () => { if (optExtEditWord.checked) saveExtEditor(\"word\"); });\noptExtEditLibre?.addEventListener(\"change\", () => { if (optExtEditLibre.checked) saveExtEditor(\"libreoffice\"); });\n\n// \u2500\u2500 AI feature toggles \u2500\u2500\n// One umbrella settings record (AutocompleteSettings) holds the provider config\n// + per-feature on/off flags. All features default OFF \u2014 user must opt into\n// each AI behavior individually. Per user preference (2026-04-21).\nconst optAutocomplete = document.getElementById(\"opt-autocomplete\") as HTMLInputElement | null;\nconst optAiTranslate = document.getElementById(\"opt-ai-translate\") as HTMLInputElement | null;\nconst optAiProofread = document.getElementById(\"opt-ai-proofread\") as HTMLInputElement | null;\n\ngetAutocompleteSettings().then((ac: any) => {\n if (optAutocomplete) optAutocomplete.checked = !!ac.enabled;\n if (optAiTranslate) optAiTranslate.checked = !!ac.translateEnabled;\n if (optAiProofread) optAiProofread.checked = !!ac.proofreadEnabled;\n}).catch(() => {});\n\nfunction persistAi(mutator: (ac: any) => void): void {\n getAutocompleteSettings().then((ac: any) => {\n mutator(ac);\n saveAutocompleteSettings(ac);\n }).catch(() => {});\n}\noptAutocomplete?.addEventListener(\"change\", () => persistAi((ac) => { ac.enabled = optAutocomplete.checked; }));\noptAiTranslate?.addEventListener(\"change\", () => persistAi((ac) => { ac.translateEnabled = optAiTranslate.checked; }));\noptAiProofread?.addEventListener(\"change\", () => {\n persistAi((ac) => { ac.proofreadEnabled = optAiProofread.checked; });\n // Mirror to localStorage so the compose editor (separate page/iframe with\n // its own getSettings cycle) can read it synchronously.\n try { localStorage.setItem(\"mailx-ai-proofread-enabled\", String(optAiProofread.checked)); } catch { /* */ }\n});\n\n// Sender reputation check (Spamhaus DBL). Stored at top-level settings so\n// the service can read it cheaply without going through autocomplete config.\n// Off by default \u2014 enabling it leaks read-recipient domains to Spamhaus's\n// DNS infra, which the user should opt into knowingly.\nconst optCheckReputation = document.getElementById(\"opt-check-reputation\") as HTMLInputElement | null;\ngetSettings().then((s: any) => {\n if (optCheckReputation) optCheckReputation.checked = !!s.checkDomainReputation;\n}).catch(() => {});\noptCheckReputation?.addEventListener(\"change\", () => {\n getSettings().then((settings: any) => {\n settings.checkDomainReputation = !!optCheckReputation.checked;\n saveSettings(settings);\n }).catch(() => {});\n});\n\n// Auto mark-as-read settings (per-device localStorage; the viewer reads\n// these directly when showing a message). Default on with a 2s delay so\n// scrolling through a folder doesn't mark every glanced-at message as\n// read, but a deliberate read still gets recorded.\nconst optAutomarkRead = document.getElementById(\"opt-automark-read\") as HTMLInputElement | null;\nconst optAutomarkDelay = document.getElementById(\"opt-automark-delay\") as HTMLInputElement | null;\ntry {\n if (optAutomarkRead) optAutomarkRead.checked = localStorage.getItem(\"mailx-automark-read\") !== \"false\";\n if (optAutomarkDelay) optAutomarkDelay.value = localStorage.getItem(\"mailx-automark-delay\") || \"2\";\n} catch { /* private mode */ }\noptAutomarkRead?.addEventListener(\"change\", () => {\n try { localStorage.setItem(\"mailx-automark-read\", String(optAutomarkRead.checked)); } catch { /* */ }\n});\noptAutomarkDelay?.addEventListener(\"change\", () => {\n const v = parseFloat(optAutomarkDelay.value);\n if (Number.isFinite(v) && v >= 0) {\n try { localStorage.setItem(\"mailx-automark-delay\", String(v)); } catch { /* */ }\n }\n});\n\n// \u2500\u2500 Version display \u2500\u2500\ndeclare const mailxapi: { isApp: boolean; platform: string; ensureServer: () => Promise<boolean>; getVersion: () => Promise<any> } | undefined;\nconst isApp = typeof mailxapi !== \"undefined\" && mailxapi?.isApp;\n\n// Wait for server ready signal, then fetch version\nconst versionPromise = getVersion();\nversionPromise.then((d: any) => {\n const els = document.querySelectorAll<HTMLElement>(\".app-version\");\n const storage = d.storage || {};\n const storageLabel = storage.provider && storage.provider !== \"local\"\n ? ` [${storage.provider}]`\n : \"\";\n // Toolbar real estate is tight; the app icon already conveys identity.\n // Show just the version + storage tag (and \"[browser]\" tag when running\n // in a plain browser tab vs. msger / MAUI shell).\n const text = `v${d.version}${storageLabel}${isApp ? \"\" : \" [browser]\"}`;\n const tip = storage.provider && storage.provider !== \"local\"\n ? (storage.cloudPath ? `My Drive/${storage.cloudPath}/` : storage.provider)\n : \"\";\n for (const el of els) {\n el.textContent = text;\n if (tip) el.title = tip;\n }\n if (d.settingsError) {\n showAlert(d.settingsError, \"settings-error\");\n // Add repair button to the banner\n const banner = document.getElementById(\"alert-banner\");\n if (banner && !banner.querySelector(\".repair-btn\")) {\n const btn = document.createElement(\"button\");\n btn.className = \"repair-btn status-action\";\n btn.textContent = \"Repair: restore accounts from cache\";\n btn.style.cssText = \"margin-left:1rem;padding:0.25rem 0.75rem;background:#a6e3a1;color:#1e1e2e;border:none;border-radius:4px;cursor:pointer;font-weight:bold\";\n btn.onclick = async () => {\n btn.textContent = \"Restoring...\";\n btn.disabled = true;\n try {\n const data = await repairAccounts();\n if (data.ok) {\n hideAlert();\n setTimeout(() => location.reload(), 1000);\n } else {\n btn.textContent = `Failed: ${data.error}`;\n }\n } catch (e: any) {\n btn.textContent = `Error: ${e.message}`;\n }\n };\n banner.querySelector(\"#alert-text\")?.after(btn);\n }\n } else if (storage.cloudError) {\n showAlert(`Cloud storage error: ${storage.cloudError}`, \"cloud-error\");\n }\n}).catch((e: any) => {\n // Version fetch failed\n const els = document.querySelectorAll<HTMLElement>(\".app-version\");\n const text = isApp ? `[version error: ${e.message}]` : `[server offline]`;\n for (const el of els) el.textContent = text;\n});\n\n// \u2500\u2500 Sync pending indicator + server health check (HTTP mode only) \u2500\u2500\nlet serverDown = false;\nif (isApp) {\n // IPC mode: events come via push, no polling needed\n} else\nsetInterval(async () => {\n try {\n const data = await getSyncPending();\n const el = document.getElementById(\"status-pending\");\n if (el) {\n el.textContent = data.pending > 0 ? `\u21BB ${data.pending} pending` : \"\";\n el.style.color = data.pending > 0 ? \"oklch(0.75 0.15 60)\" : \"\";\n }\n // Server is back \u2014 reload if it was down\n if (serverDown) {\n serverDown = false;\n const statusEl = document.getElementById(\"status-sync\");\n if (statusEl) statusEl.textContent = \"Server reconnected\";\n location.reload();\n }\n } catch {\n if (!serverDown) {\n serverDown = true;\n const statusEl = document.getElementById(\"status-sync\");\n if (statusEl) {\n statusEl.textContent = \"SERVER OFFLINE\";\n statusEl.style.color = \"oklch(0.65 0.2 25)\";\n }\n }\n }\n}, 5000);\n\n// \u2500\u2500 Outbox queue indicator (status-queue span) \u2500\u2500\n// Event-driven in IPC mode (service pushes outboxStatus on every mutation).\n// Plus a 15s poll safety net for both modes so a missed event doesn't leave\n// the user staring at stale numbers. Idempotent \u2014 renderOutboxStatus just\n// overwrites the text.\nfunction renderOutboxStatus(s: any): void {\n // Feed the folder-tree synthesized \"Send-pending\" row. Idempotent \u2014\n // it no-ops when the presence state and count haven't changed.\n setOutboxTotal(s?.total || 0);\n const el = document.getElementById(\"status-queue\");\n if (!el) return;\n if (!s || !s.total || s.total === 0) {\n el.textContent = \"\";\n el.title = \"\";\n el.style.color = \"\";\n return;\n }\n const parts: string[] = [`\u2709 ${s.total} queued`];\n if (s.claimed > 0) parts.push(`${s.claimed} sending`);\n if (s.retrying > 0) parts.push(`${s.retrying} retrying (\u00D7${s.maxAttempts})`);\n if (s.oldestAgeSec >= 60) {\n const age = s.oldestAgeSec >= 3600\n ? `${Math.floor(s.oldestAgeSec / 3600)}h`\n : `${Math.floor(s.oldestAgeSec / 60)}m`;\n parts.push(`oldest ${age}`);\n }\n el.textContent = parts.join(\" \u00B7 \");\n const perAcct = s.perAccount || {};\n const detail = Object.keys(perAcct).sort().map(a =>\n `${a}: ${perAcct[a].total} total, ${perAcct[a].claimed} sending, ${perAcct[a].retrying} retrying`\n ).join(\"\\n\");\n el.title = detail || \"\";\n // Orange when retrying, red when stuck >5min, else muted.\n el.style.color = s.oldestAgeSec > 300 ? \"oklch(0.65 0.2 25)\"\n : s.retrying > 0 ? \"oklch(0.75 0.15 60)\"\n : \"\";\n}\n\nsetInterval(async () => {\n try {\n const { getOutboxStatus, getDiagnostics } = await import(\"./lib/api-client.js\");\n // Run in parallel \u2014 neither call depends on the other and each is a\n // separate IPC round-trip. Earlier code awaited them serially.\n const [outbox, diag] = await Promise.all([getOutboxStatus(), getDiagnostics()]);\n renderOutboxStatus(outbox);\n renderDiagnosticsBadge(diag);\n } catch { /* service unreachable */ }\n}, 15000);\n// First read on startup so the bar isn't blank.\n(async () => {\n try {\n const { getOutboxStatus, getDiagnostics } = await import(\"./lib/api-client.js\");\n const [outbox, diag] = await Promise.all([getOutboxStatus(), getDiagnostics()]);\n renderOutboxStatus(outbox);\n renderDiagnosticsBadge(diag);\n } catch { /* */ }\n})();\n\n/** Render the \u26A0 \"something's wrong\" badge next to status-sync. Shown when\n * any account has non-zero diagnostic counters (inactivity timeouts,\n * connection-cap hits, rate-limit waits). Tooltip breaks down per-account. */\nfunction renderDiagnosticsBadge(snapshot: any[]): void {\n const host = document.getElementById(\"status-diag\");\n if (!host) return;\n const issues = (snapshot || []).filter(d => d.inactivityTimeouts > 0 || d.connCapHits > 0 || d.rateLimitWaits > 0);\n if (issues.length === 0) {\n host.hidden = true;\n host.textContent = \"\";\n host.title = \"\";\n return;\n }\n host.hidden = false;\n host.textContent = \"\u26A0\";\n const totalTimeouts = issues.reduce((a, d) => a + d.inactivityTimeouts, 0);\n const totalCapHits = issues.reduce((a, d) => a + d.connCapHits, 0);\n const totalRateLimits = issues.reduce((a, d) => a + d.rateLimitWaits, 0);\n const summary = [\n totalTimeouts > 0 ? `${totalTimeouts} IMAP inactivity timeout${totalTimeouts === 1 ? \"\" : \"s\"}` : null,\n totalCapHits > 0 ? `${totalCapHits} conn-cap rejection${totalCapHits === 1 ? \"\" : \"s\"}` : null,\n totalRateLimits > 0 ? `${totalRateLimits} rate-limit wait${totalRateLimits === 1 ? \"\" : \"s\"}` : null,\n ].filter(Boolean).join(\"; \");\n const detail = issues.map(d => {\n const parts = [\n d.inactivityTimeouts > 0 ? `${d.inactivityTimeouts} timeout${d.inactivityTimeouts === 1 ? \"\" : \"s\"}` : null,\n d.connCapHits > 0 ? `${d.connCapHits} conn-cap` : null,\n d.rateLimitWaits > 0 ? `${d.rateLimitWaits} rate-limit` : null,\n ].filter(Boolean).join(\", \");\n const last = d.lastCommand ? `\\n last: ${d.lastCommand}` : \"\";\n return `${d.accountId}: ${parts}${last}`;\n }).join(\"\\n\");\n host.title = `Connection issues \u2014 ${summary}\\n\\n${detail}`;\n}\n// Q64: pop-out a message into a floating overlay (real-OS-window pending C44).\n// Action buttons on the message pop-out window \u2014 Reply / Reply All / Forward\n// act on the popped-out message specifically (not the main viewer's), Delete\n// removes it. The pop-out lives in message-viewer.ts and can't import\n// openCompose directly, so it fires this event.\ndocument.addEventListener(\"mailx-popout-action\", ((e: any) => {\n const { action, msg, accountId } = e.detail || {};\n if (!msg) return;\n if (action === \"reply\" || action === \"replyAll\" || action === \"forward\") {\n openCompose(action as ComposeMode, msg, accountId);\n } else if (action === \"delete\") {\n deleteMessage(accountId, msg.uid).catch((err: any) =>\n console.error(`[popout] delete failed: ${err?.message || err}`));\n }\n}) as EventListener);\n\ndocument.addEventListener(\"mailx-popout-message\", (async (e: any) => {\n const { accountId, uid, folderId, subject } = e.detail || {};\n if (!accountId || !uid) return;\n const { getMessage } = await import(\"./lib/api-client.js\");\n let msg: any;\n try {\n msg = await getMessage(accountId, uid, false, folderId);\n } catch (err: any) {\n alert(`Couldn't load message: ${err?.message || err}`);\n return;\n }\n // Drafts pop out into a COMPOSE window, not a read-only viewer popout.\n // Bob 2026-05-12: \"popping out a draft should default to that\" (edit\n // mode). Flag detection: server-side \\Draft flag, or the message is\n // currently sitting in the user's Drafts folder. The read-only popout\n // surface is missing every action button (Reply, Forward, Edit Draft,\n // \u2026) so dumping a draft into it is a worst-case dead-end.\n const isDraft = !!msg && draftOf(msg);\n if (isDraft) {\n const accts = await getAccounts();\n const init = {\n mode: \"draft\",\n accountId,\n to: msg.to || [],\n cc: msg.cc || [],\n subject: msg.subject || subject || \"\",\n bodyHtml: msg.bodyHtml || \"\",\n inReplyTo: msg.inReplyTo || \"\",\n references: msg.references || [],\n accounts: accts.map((a: any) => ({ id: a.id, name: a.name, email: a.email, signature: a.signature, sig: a.sig })),\n draftUid: msg.uid,\n draftFolderId: msg.folderId,\n };\n sessionStorage.setItem(\"composeInit\", JSON.stringify(init));\n showComposeOverlay(msg.subject ? `Edit: ${msg.subject}` : \"Edit draft\");\n return;\n }\n const wrapper = document.createElement(\"div\");\n wrapper.className = \"popout-overlay\";\n wrapper.style.cssText = \"position:fixed;top:5vh;right:5vw;width:min(900px,60vw);height:min(800px,80vh);z-index:1500;background:var(--color-bg);border:1px solid var(--color-border);border-radius:6px;box-shadow:0 8px 32px rgba(0,0,0,0.3);display:flex;flex-direction:column;resize:both;overflow:hidden;\";\n const header = document.createElement(\"div\");\n header.style.cssText = \"display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--color-bg-surface);border-bottom:1px solid var(--color-border);font-weight:600;cursor:move;\";\n const title = document.createElement(\"span\");\n title.textContent = subject || \"(no subject)\";\n title.style.cssText = \"flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;\";\n const closeBtn = document.createElement(\"button\");\n closeBtn.textContent = \"\u00D7\";\n closeBtn.style.cssText = \"background:none;border:none;font-size:1.4rem;cursor:pointer;padding:0 8px;\";\n closeBtn.addEventListener(\"click\", () => wrapper.remove());\n header.appendChild(title);\n header.appendChild(closeBtn);\n const meta = document.createElement(\"div\");\n meta.style.cssText = \"padding:8px 12px;border-bottom:1px solid var(--color-border);font-size:0.9rem;color:var(--color-text-muted);\";\n meta.innerHTML = `<div><b>From:</b> ${escapeHtmlBasic(msg.from?.name || \"\")} <${escapeHtmlBasic(msg.from?.address || \"\")}></div>\n <div><b>To:</b> ${(msg.to || []).map((a: any) => escapeHtmlBasic(`${a.name||\"\"} <${a.address}>`)).join(\", \")}</div>\n ${msg.cc?.length ? `<div><b>Cc:</b> ${msg.cc.map((a:any) => escapeHtmlBasic(`${a.name||\"\"} <${a.address}>`)).join(\", \")}</div>` : \"\"}\n <div><b>Date:</b> ${new Date(msg.date).toLocaleString()}</div>`;\n const body = document.createElement(\"iframe\");\n body.style.cssText = \"flex:1;border:none;width:100%;background:#fff;\";\n body.sandbox.add(\"allow-same-origin\");\n wrapper.appendChild(header);\n wrapper.appendChild(meta);\n wrapper.appendChild(body);\n document.body.appendChild(wrapper);\n // Use the same wrapHtmlBody styling as the inline preview pane so the\n // popout's typography matches the preview's typography. Earlier the\n // popout fed bodyHtml DIRECTLY into srcdoc with no CSS wrapping, so\n // the browser used its default font (Times serif) while the preview\n // pane used system-ui sans-serif (Bob 2026-05-12: \"double clicking on\n // the summary shows in a different font than in preview\").\n body.srcdoc = msg.bodyHtml\n ? wrapHtmlBody(msg.bodyHtml, !!msg.remoteAllowed)\n : `<!DOCTYPE html><html><head><style>body{font-family:system-ui,sans-serif;font-size:17.5px;line-height:1.5;color:#1a1a2e;padding:1rem;margin:0;}pre{white-space:pre-wrap;word-break:break-word;font-family:inherit;font-size:inherit;margin:0;}</style></head><body><pre>${escapeHtmlBasic(msg.bodyText || \"(no body)\")}</pre></body></html>`;\n // Drag-to-move.\n let dragX = 0, dragY = 0, dragging = false;\n header.addEventListener(\"mousedown\", (de: MouseEvent) => {\n if ((de.target as HTMLElement).tagName === \"BUTTON\") return;\n dragging = true;\n const rect = wrapper.getBoundingClientRect();\n dragX = de.clientX - rect.left;\n dragY = de.clientY - rect.top;\n de.preventDefault();\n });\n document.addEventListener(\"mousemove\", (de) => {\n if (!dragging) return;\n wrapper.style.left = `${de.clientX - dragX}px`;\n wrapper.style.top = `${de.clientY - dragY}px`;\n wrapper.style.right = \"auto\";\n });\n document.addEventListener(\"mouseup\", () => { dragging = false; });\n}) as EventListener);\nfunction escapeHtmlBasic(s: string): string {\n return (s || \"\").replace(/[&<>\"']/g, c => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", \"\\\"\": \""\", \"'\": \"'\" }[c]!));\n}\n\n// Click the status-queue pill to open the outbox view (pink-row list).\ndocument.getElementById(\"status-queue\")?.addEventListener(\"click\", async () => {\n try {\n const { openOutboxView } = await import(\"./components/outbox-view.js\");\n openOutboxView();\n } catch (e: any) {\n console.error(\"Outbox view failed:\", e);\n }\n});\n// Make it look clickable.\n(() => {\n const el = document.getElementById(\"status-queue\");\n if (el) { el.style.cursor = \"pointer\"; el.title = \"Click to view queued messages\"; }\n})();\n\nconsole.log(\"mailx client initialized, location:\", location.href);\nupdateNewMessageCount();\n\n// Offline indicator \u2014 show/hide based on navigator.onLine. Doesn't gate any\n// functionality (the store is local-first; edits queue and replay on\n// reconnect regardless) but tells the user their queued actions are stacking\n// up for a later push rather than hitting the server now.\nconst offlineEl = document.getElementById(\"status-offline\");\nfunction refreshOfflineIndicator(): void {\n if (!offlineEl) return;\n offlineEl.hidden = navigator.onLine;\n}\nwindow.addEventListener(\"online\", refreshOfflineIndicator);\nwindow.addEventListener(\"offline\", refreshOfflineIndicator);\nrefreshOfflineIndicator();\n\n// \u2500\u2500 Midnight refresh \u2014 update date display when day changes \u2500\u2500\nfunction scheduleMiddnightRefresh(): void {\n const now = new Date();\n const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);\n const ms = midnight.getTime() - now.getTime();\n setTimeout(() => {\n reloadCurrentFolder();\n scheduleMiddnightRefresh();\n }, ms + 1000); // 1s after midnight\n}\nscheduleMiddnightRefresh();\n\n// \u2500\u2500 Apply theme from settings \u2500\u2500\nversionPromise.then((d: any) => {\n if (d.theme === \"dark\") document.documentElement.classList.add(\"theme-dark\");\n else if (d.theme === \"light\") document.documentElement.classList.add(\"theme-light\");\n}).catch(() => {});\n\n// \u2500\u2500 Save window geometry on close (IPC mode only) \u2500\u2500\n// Sends window position and size so the next launch restores them.\nif (isApp) {\n const ipcApi = (window as unknown as Record<string, unknown>).mailxapi as\n { saveWindowGeometry?: (g: { x: number; y: number; width: number; height: number }) => Promise<unknown> } | undefined;\n\n function sendGeometry(): void {\n if (!ipcApi?.saveWindowGeometry) return;\n ipcApi.saveWindowGeometry({\n x: window.screenX,\n y: window.screenY,\n width: window.outerWidth,\n height: window.outerHeight,\n }).catch(() => { /* fire-and-forget */ });\n }\n\n // Save on unload (window close) and periodically as a safety net\n window.addEventListener(\"beforeunload\", sendGeometry);\n setInterval(sendGeometry, 60_000);\n}\n\n// Boot-snapshot writer \u2014 captures key DOM regions to localStorage so the\n// next cold start hydrates instantly (see index.html's hydrateFromBootSnapshot).\n// Saves every 30 s and on beforeunload; pages with no inbox open just save\n// empty strings, which the loader treats as \"skip this region.\"\nfunction saveBootSnapshot(): void {\n try {\n const folderTree = document.getElementById(\"folder-tree\");\n const messageList = document.getElementById(\"ml-body\");\n const folderTitle = document.getElementById(\"ml-folder-title\");\n // Only save when at least one region has content. An in-flight render\n // (e.g. user clicked a folder, list is empty for a tick) would\n // otherwise clobber the previous good snapshot.\n const ftHtml = folderTree?.innerHTML?.trim() || \"\";\n const mlHtml = messageList?.innerHTML?.trim() || \"\";\n if (!ftHtml && !mlHtml) return;\n const snap = {\n folderTree: ftHtml,\n messageList: mlHtml,\n folderTitle: folderTitle?.innerHTML?.trim() || \"\",\n savedAt: Date.now(),\n };\n localStorage.setItem(\"mailx-boot-snapshot\", JSON.stringify(snap));\n } catch { /* best-effort */ }\n}\nwindow.addEventListener(\"beforeunload\", saveBootSnapshot);\n// Also save when the user tabs away \u2014 a Windows kill of an inactive\n// app would otherwise lose the snapshot up to 30 s old.\ndocument.addEventListener(\"visibilitychange\", () => {\n if (document.visibilityState === \"hidden\") saveBootSnapshot();\n});\nsetInterval(saveBootSnapshot, 30_000);\n"],
|
|
5
5
|
"mappings": "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",
|
|
6
6
|
"names": ["getMessages", "aiTransform", "e", "setPrioritySender", "refreshPriorityIndex", "showThreadPopup", "srcBtn", "editBtn", "data", "render", "currentAccountId", "getMessages", "escapeHtml", "escapeHtml", "escapeHtml", "aiTransform", "isOpen", "searchInput", "render", "escapeHtml", "escapeHtml", "setMessages", "messages", "removeMessagesAndReconcile", "splitter", "overlay", "openOutboxView", "collectCounts", "ipc", "formatAge", "currentAccountId", "currentFolderId", "messages", "moveMessages", "moveMessage", "recordSpamReport", "openAddressBook", "showCalendarSidebar", "escapeHtml", "reauthGoogleScopes", "acctEl", "tabs", "initCalendarSidebar", "isCalendarSidebarOn", "hideCalendarSidebar", "startAlarmPoller", "triggerSync", "consumePendingMailto", "SEARCH_HELP_HTML", "settingsDropdown", "openLocalPath", "readJsoncFile", "writeJsoncFile", "readConfigHelp", "formatJsonc", "isApp", "getOutboxStatus", "getDiagnostics", "getMessage", "openOutboxView", "sendGeometry", "folderTree", "messageList"]
|
|
7
7
|
}
|