/** * emailLibrary.js — Email library popup modal. * Similar pattern to documentLibrary.js. Shows emails in a grid with search/filter. */ import spinnerModule from './spinner.js'; import { styledConfirm, showToast, emptyStateIcon } from './ui.js'; import { folderDisplayName, sortedFolders } from './emailInbox.js'; import settingsModule from './settings.js'; import * as Modals from './modalManager.js'; import { makeWindowDraggable } from './windowDrag.js'; import { _esc, _escLinkify, _extractName, _parseTurnMeta, _formatBubbleDate, _formatRecipients, _senderColor, _initials, _sanitizeHtml, _TALON_WROTE, _TALON_FROM, _TALON_SENT, _TALON_SUBJ, _TALON_TO, _TALON_ORIG_RE, _SIG_BLOAT_MIN_CHARS, } from './emailLibrary/utils.js'; import { _looksLikeSignature, _harvestAttribution, _extractTurnMetaFromBlockquote, _foldSummary, _extractQuoteMeta, _peelSigNameLine, _isBloatedSig, _tryFoldHintSig, _foldSignature, _SIG_ICON, _QUOTE_ICON, } from './emailLibrary/signatureFold.js'; import { state } from './emailLibrary/state.js'; const API_BASE = window.location.origin; let _emailUnreadChipClickWired = false; let _libLoadSeq = 0; let _libFolderSeq = 0; function _syncEmailReadState(uid, isRead = true) { if (uid == null) return; const uidStr = String(uid); const read = !!isRead; const match = (state._libEmails || []).find(x => String(x.uid) === uidStr); if (match) match.is_read = read; document.querySelectorAll('.doclib-card[data-uid="' + CSS.escape(uidStr) + '"]').forEach(card => { card.classList.toggle('email-card-unread', !read); const titleRow = card.querySelector('.email-card-titlerow'); if (read) { card.querySelectorAll('.email-card-unread-dot, [data-unread-dot]').forEach(n => n.remove()); if (titleRow) { titleRow.querySelectorAll('span').forEach(s => { const st = s.getAttribute('style') || ''; if (/width:\s*6px/.test(st) && /border-radius:\s*50%/.test(st)) s.remove(); }); } return; } if (!titleRow || titleRow.querySelector('.email-card-unread-dot, [data-unread-dot]')) return; const isSentFolder = /sent/i.test(state._libFolder || ''); if (isSentFolder) return; const senderName = match ? (match.from_name || match.from_address || '') : ''; const dot = document.createElement('span'); dot.className = 'email-card-unread-dot'; dot.style.cssText = `width:6px;height:6px;border-radius:50%;background:${_senderColor(senderName)};flex-shrink:0;margin-left:2px;`; const done = titleRow.querySelector('.email-card-done'); const rightCluster = titleRow.querySelector('.email-card-header-menu')?.parentElement; if (done) done.insertAdjacentElement('afterend', dot); else if (rightCluster) titleRow.insertBefore(dot, rightCluster); else titleRow.appendChild(dot); }); } // When a reply is sent (from the doc editor), the source email is marked // \Answered server-side and an `email-answered` event fires. Reflect that live // so the email shows as done without waiting for a manual refresh. window.addEventListener('email-answered', (e) => { const uid = e.detail && e.detail.uid; if (uid == null) return; const em = (state._libEmails || []).find(x => String(x.uid) === String(uid)); if (em) { em.is_answered = true; em.is_read = true; } _syncEmailReadState(uid, true); document.querySelectorAll('.doclib-card[data-uid="' + CSS.escape(String(uid)) + '"]').forEach(card => { card.classList.add('email-card-answered'); card.classList.remove('email-card-unread'); const check = card.querySelector('.email-card-done'); if (check) check.classList.add('active'); }); }); function _toggleUnreadEmails() { if (state._libFolder === '__scheduled__') state._libFolder = 'INBOX'; state._libFilter = state._libFilter === 'unread' ? 'all' : 'unread'; state._libOffset = 0; state._libEmails = []; _syncUnreadWindowGlow(); const folderEl = document.getElementById('email-lib-folder'); const filterEl = document.getElementById('email-lib-filter'); if (folderEl) folderEl.value = state._libFolder || 'INBOX'; if (filterEl) filterEl.value = state._libFilter; document.getElementById('email-undone-btn')?.classList.remove('active'); document.getElementById('email-reminder-btn')?.classList.remove('active'); _loadEmails(); } function _syncUnreadTabBadge(count) { const label = count > 999 ? '999+ unread' : `${count} unread`; document.querySelectorAll('.minimized-dock-chip[data-modal-id="email-lib-modal"]').forEach(chip => { if (count > 0) { chip.dataset.emailUnreadLabel = label; chip.title = `Open ${label}`; } else { delete chip.dataset.emailUnreadLabel; chip.title = 'Restore Email'; } }); } function _syncUnreadWindowGlow() { document.getElementById('email-lib-modal')?.classList.toggle('email-lib-unread-active', state._libFilter === 'unread'); } function _syncReminderClearButton() { document.getElementById('email-reminders-clear-btn')?.classList.toggle('hidden', state._libFilter !== 'reminders'); } function _syncEmailReminderBellVisibility(enabled) { const btn = document.getElementById('email-reminder-btn'); const wrap = document.querySelector('#email-lib-modal .email-search-wrap'); btn?.classList.toggle('hidden', !enabled); wrap?.classList.toggle('email-reminder-bell-hidden', !enabled); } async function _loadEmailReminderBellVisibility() { try { const res = await fetch('/api/auth/settings', { credentials: 'same-origin' }); const settings = await res.json(); _syncEmailReminderBellVisibility(settings.reminder_channel === 'email'); } catch (_) { _syncEmailReminderBellVisibility(false); } } function _readCssPx(name) { const v = getComputedStyle(document.documentElement).getPropertyValue(name); const n = parseFloat(v); return Number.isFinite(n) ? n : 0; } function _emailSplitLeftEdge() { return _readCssPx('--icon-rail-w') + _readCssPx('--sidebar-w'); } function _setEmailDocumentSplit(leftEdge, emailWidth) { if (window.innerWidth <= 768) return; // Zero gap so the doc-pane sits flush against the email's right edge. // modalSnap.js's left-dock path publishes the same vars with 0 gap — both // systems agree on flush so handoffs between them don't cause the doc to // "jump" sideways. The 1px modal border on each side is the visual seam. const splitGap = 0; const left = Math.max(0, Math.round(leftEdge || 0)); const width = Math.max(320, Math.round(emailWidth || 420)); const x = left + width + splitGap; document.body.classList.add('email-doc-split-active'); document.documentElement.style.setProperty('--email-doc-split-left-x', `${left}px`); document.documentElement.style.setProperty('--email-doc-split-email-w', `${width}px`); document.documentElement.style.setProperty('--email-doc-split-right-x', `${x}px`); } function _measureEmailDocumentSplit(modal) { if (window.innerWidth <= 768 || !document.body.classList.contains('email-doc-split-active')) return; const content = modal?.querySelector?.('.modal-content'); const rect = content?.getBoundingClientRect?.(); if (!rect || !rect.width) return; const splitGap = 0; document.documentElement.style.setProperty('--email-doc-split-right-x', `${Math.ceil(rect.right + splitGap)}px`); try { modal.style.setProperty('z-index', '150', 'important'); if (content) { content.style.setProperty('position', 'absolute', 'important'); content.style.setProperty('left', '0px', 'important'); content.style.setProperty('right', 'auto', 'important'); content.style.setProperty('width', `${Math.ceil(rect.width)}px`, 'important'); content.style.setProperty('max-width', `${Math.ceil(rect.width)}px`, 'important'); } const docPane = document.getElementById('doc-editor-pane'); if (docPane) { docPane.style.setProperty('position', 'fixed', 'important'); docPane.style.setProperty('left', `${Math.ceil(rect.right + splitGap)}px`, 'important'); docPane.style.setProperty('right', '0px', 'important'); docPane.style.setProperty('top', '0px', 'important'); docPane.style.setProperty('bottom', '0px', 'important'); docPane.style.setProperty('width', 'auto', 'important'); docPane.style.setProperty('max-width', 'none', 'important'); docPane.style.setProperty('height', '100vh', 'important'); docPane.style.setProperty('z-index', '260', 'important'); } } catch (_) {} } function _scheduleEmailDocumentSplitMeasure(modal) { requestAnimationFrame(() => { _measureEmailDocumentSplit(modal); requestAnimationFrame(() => _measureEmailDocumentSplit(modal)); }); setTimeout(() => _measureEmailDocumentSplit(modal), 260); setTimeout(() => _measureEmailDocumentSplit(modal), 700); } function _clearEmailDocumentSplit() { document.body.classList.remove('email-doc-split-active'); document.documentElement.style.removeProperty('--email-doc-split-left-x'); document.documentElement.style.removeProperty('--email-doc-split-email-w'); document.documentElement.style.removeProperty('--email-doc-split-right-x'); const docPane = document.getElementById('doc-editor-pane'); if (!docPane) return; [ 'position', 'left', 'right', 'top', 'bottom', 'width', 'max-width', 'height', 'z-index', 'transform', ].forEach(prop => docPane.style.removeProperty(prop)); } function _hasDesktopRoomForEmailAndDocument(modal) { if (window.innerWidth <= 768) return false; if (window.innerWidth >= 1100) return true; const content = modal?.querySelector?.('.modal-content'); const rect = content?.getBoundingClientRect?.(); const isFullscreen = modal?.classList?.contains('email-lib-fullscreen') || modal?.classList?.contains('email-window-fullscreen'); const emailWidth = isFullscreen ? Math.min(440, Math.max(360, Math.round(window.innerWidth * 0.30))) : Math.max(360, Math.round(rect?.width || 440)); const docMinWidth = 560; const breathingRoom = 72; const leftEdge = isFullscreen ? _emailSplitLeftEdge() : Math.max(0, Math.round(rect?.left || _emailSplitLeftEdge())); return (window.innerWidth - leftEdge - emailWidth) >= (docMinWidth + breathingRoom); } function _prepareEmailWindowForDocument(modal) { if (window.innerWidth <= 768) return true; if (!modal) return false; if (!_hasDesktopRoomForEmailAndDocument(modal)) { _clearEmailDocumentSplit(); return true; } if (modal.classList.contains('modal-left-docked')) { const content = modal.querySelector('.modal-content'); const rect = content?.getBoundingClientRect?.(); if (content?._leftDockNavObs) { try { content._leftDockNavObs.navObs.disconnect(); } catch (_) {} try { content._leftDockNavObs.bodyObs && content._leftDockNavObs.bodyObs.disconnect(); } catch (_) {} try { content._leftDockNavObs.disconnectDocObs && content._leftDockNavObs.disconnectDocObs(); } catch (_) {} try { window.removeEventListener('resize', content._leftDockNavObs.reanchor); } catch (_) {} delete content._leftDockNavObs; } modal.classList.remove('modal-left-docked'); modal.classList.add('email-snap-left'); document.body.classList.remove('left-dock-active'); document.documentElement.style.removeProperty('--left-dock-w'); if (content) { delete content._dockSide; content.style.position = 'fixed'; content.style.left = Math.round(rect?.left || _emailSplitLeftEdge()) + 'px'; content.style.top = '0'; content.style.right = 'auto'; content.style.bottom = '0'; content.style.width = Math.round(rect?.width || 440) + 'px'; content.style.maxWidth = Math.round(rect?.width || 440) + 'px'; content.style.height = '100vh'; content.style.maxHeight = '100vh'; content.style.borderRadius = '0'; content.style.transform = 'none'; content.style.margin = '0'; } } if (modal.classList.contains('email-snap-left') || modal.classList.contains('modal-left-docked')) { const rect = modal.querySelector('.modal-content')?.getBoundingClientRect?.(); _setEmailDocumentSplit(rect?.left || _emailSplitLeftEdge(), rect?.width || 420); _scheduleEmailDocumentSplitMeasure(modal); return false; } // If Email is fullscreen and there is room, park it left instead of // minimizing so the document/compose pane can open beside it. _snapEmailModalToLeftSidebar(modal); return false; } function _wireUnreadTabClick() { if (_emailUnreadChipClickWired) return; _emailUnreadChipClickWired = true; document.addEventListener('click', (e) => { const chip = e.target?.closest?.('.minimized-dock-chip[data-modal-id="email-lib-modal"][data-email-unread-label]'); if (!chip || e.target?.classList?.contains('minimized-dock-x')) return; setTimeout(_toggleUnreadEmails, 0); }); } async function _deleteEmailAndAdvance(em, card, opts = {}) { if (!em || em.uid == null) return; if (opts.confirm !== false) { const subject = em.subject || '(no subject)'; const ok = await styledConfirm(`Delete "${subject}"?`, { confirmText: 'Delete', cancelText: 'Cancel', danger: true }); if (!ok) return; } const wasExpanded = !!card?.classList?.contains('doclib-card-expanded'); const sibling = wasExpanded ? (_findSiblingEmailCard(card, +1) || _findSiblingEmailCard(card, -1)) : null; const nextUid = sibling ? sibling.dataset.uid : null; try { await fetch(`${API_BASE}/api/email/delete/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' }); } catch (err) { console.error('Failed to delete email:', err); showToast('Failed to delete email'); return; } await _animateEmailCardRemoval([em.uid]); state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid)); state._selectedUids.delete(em.uid); _updateBulkBar(); _renderGrid(); _libCacheWriteBack(); showToast('Moved to Trash'); if (!wasExpanded || !nextUid) return; const grid = document.getElementById('email-lib-grid'); const nextCard = grid?.querySelector(`.doclib-card[data-uid="${CSS.escape(String(nextUid))}"]`); const nextEm = state._libEmails.find(e => String(e.uid) === String(nextUid)); if (nextCard && nextEm) { await _toggleCardPreview(nextCard, nextEm); nextCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } else { document.getElementById('email-lib-modal')?.classList.remove('email-reading'); } } function _animateEmailCardRemoval(uids, opts = {}) { const uidSet = new Set((uids || []).map(uid => String(uid))); if (!uidSet.size) return Promise.resolve(); const grid = document.getElementById('email-lib-grid'); if (!grid) return Promise.resolve(); const cards = Array.from(grid.querySelectorAll('.doclib-card[data-uid]')) .filter(card => uidSet.has(String(card.dataset.uid))); if (!cards.length) return Promise.resolve(); const duration = Number(opts.duration || 230); for (const card of cards) { const rect = card.getBoundingClientRect(); card.style.setProperty('--email-remove-h', `${Math.max(rect.height, card.scrollHeight)}px`); card.style.maxHeight = 'var(--email-remove-h)'; card.style.overflow = 'hidden'; card.classList.add('email-card-removing'); } return new Promise(resolve => { window.setTimeout(resolve, duration + 35); }); } // URL-suffix helper — appends &account_id=... when an account is actively selected. // Every email route call in this file goes through here so switching accounts // is a single-variable flip. // Open the Settings modal and activate a specific tab. Used by empty-state // "Set up at: Settings › X" links across email/calendar/etc. function _openSettingsTab(tab) { if (tab === 'integrations' && window.adminModule && typeof window.adminModule.open === 'function') { window.adminModule.open('integrations'); return; } if (settingsModule && typeof settingsModule.open === 'function') { settingsModule.open(tab || 'services'); return; } const modal = document.getElementById('settings-modal'); if (!modal) return; modal.classList.remove('hidden'); const tabBtn = modal.querySelector(`[data-settings-tab="${tab || 'services'}"]`); if (tabBtn) tabBtn.click(); } function _emailSetupHintHtml() { return '
' + 'Setup: Settings › Integrations' + '
'; } function _wireEmailSetupHint(root) { root?.querySelectorAll?.('[data-open-settings]').forEach(link => { if (link.dataset.emailSetupBound === '1') return; link.dataset.emailSetupBound = '1'; link.addEventListener('click', (e) => { e.preventDefault(); _openSettingsTab(link.dataset.openSettings || 'integrations'); }); }); } function _acct() { return state._libAccountId ? `&account_id=${encodeURIComponent(state._libAccountId)}` : ''; } // Per-(account, folder, filter, attachments) cache of the most recent // first-page list response. Lets reopen-after-close paint the previous // list instantly while the network refresh runs behind it — the modal // used to wipe its DOM and spinner-from-empty on every open, even when // the same view was just visible a second ago. // // Session-only (lives in module scope, cleared on hard reload). Search // results and __scheduled__ are deliberately not cached. const _libListCache = new Map(); const _LIB_CACHE_MAX = 24; function _libCacheKey() { return [ state._libAccountId || '', state._libFolder || '', state._libFilter || '', state._libHasAttachments ? 1 : 0, ].join('|'); } function _libCacheGet(key) { return _libListCache.get(key) || null; } function _libCachePut(key, value) { // Re-insert to bump LRU recency. _libListCache.delete(key); _libListCache.set(key, value); if (_libListCache.size > _LIB_CACHE_MAX) { const oldest = _libListCache.keys().next().value; _libListCache.delete(oldest); } } function _libCacheWriteBack() { // After a local mutation that already updated state._libEmails // (delete / archive / bulk), sync the change into the cache so the // next reopen doesn't briefly show the pre-mutation state before the // refetch wins. Skipped during search (results aren't the real list) // and on the scheduled virtual folder. if (state._libSearch) return; if (state._libFolder === '__scheduled__') return; const ck = _libCacheKey(); if (_libListCache.has(ck)) { _libCachePut(ck, { emails: state._libEmails.slice(), total: state._libTotal }); } } // Expose the active account id to other modules (document.js uses this when sending). // Simple global rather than cross-module import to keep coupling minimal. function _publishActiveAccount() { try { window.__odysseusActiveEmailAccount = state._libAccountId || null; } catch (_) {} } export function initEmailLibrary(config) { state._docModule = config.documentModule; state._onEmailClick = config.onEmailClick; } export function isOpen() { return state._libOpen; } export function openEmailLibrary(opts = {}) { // Force-clean any stale state from previous attempts const existing = document.getElementById('email-lib-modal'); if (existing) existing.remove(); if (state._libEscHandler) { document.removeEventListener('keydown', state._libEscHandler); state._libEscHandler = null; } state._libOpen = true; // On mobile the sidebar overlays content — close it so the email view isn't // opened behind it (same pattern as session-switch/delete). if (window.innerWidth <= 768) { const _sb = document.getElementById('sidebar'); if (_sb) _sb.classList.add('hidden'); const _bd = document.getElementById('sidebar-backdrop'); if (_bd) _bd.classList.remove('visible'); // Email was opened last → bring the email windows IN FRONT of any open doc // (they alternate: whichever was opened last wins). The doc stays open // behind it; reopening the doc flips it back on top. document.body.classList.add('email-front'); } state._libEmails = []; state._libOffset = 0; state._libSearch = ''; state._libFilter = 'all'; state._libHasAttachments = false; // Animate the very first card render with a domino cascade (same as the // sidebar section-domino-in keyframe). Reset by _renderGrid after the // animation is queued so subsequent filter/sort re-renders are instant. state._libJustOpened = true; if (Object.prototype.hasOwnProperty.call(opts, 'account_id')) { state._libAccountId = opts.account_id || null; _publishActiveAccount(); } if (opts.folder) state._libFolder = opts.folder; state._libPendingExpandUid = opts.uid || null; const modal = document.createElement('div'); modal.className = 'modal'; modal.id = 'email-lib-modal'; modal.innerHTML = ` `; document.body.appendChild(modal); modal.style.display = 'block'; // Make modal background non-blocking so user can interact with rest of the app modal.style.cssText += 'pointer-events:none;background:transparent;'; // Register so the chip carries the right label/icon. restoreFn left // empty — just unminimizing the modal is enough; whatever email was // expanded inside stays expanded. try { Modals.register('email-lib-modal', { label: 'Email', icon: 'M2 4h20v16H2zM22 7l-9.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7', closeFn: () => { const m = document.getElementById('email-lib-modal'); if (m) m.classList.add('hidden'); }, restoreFn: () => { // Reopened last → bring the email windows in front of any open doc. document.body.classList.add('email-front'); // Mobile: tapping the library chip chips down any open email // reader so the library is the only visible window. Pairs with // the per-reader restoreFn that chips the library down when a // reader is brought up. if (window.innerWidth <= 768) { document.querySelectorAll('.modal[id^="email-reader-"]').forEach(other => { try { if (Modals.isRegistered(other.id) && !Modals.isMinimized(other.id)) { Modals.minimize(other.id); } } catch {} }); } }, }); } catch (_) {} _wireUnreadTabClick(); const unreadBadge = document.getElementById('email-lib-unread-badge'); unreadBadge?.addEventListener('click', (e) => { e.stopPropagation(); _toggleUnreadEmails(); }); unreadBadge?.addEventListener('keydown', (e) => { if (e.key !== 'Enter' && e.key !== ' ') return; e.preventDefault(); _toggleUnreadEmails(); }); const content = modal.querySelector('.modal-content'); if (content) { const isMobile = window.innerWidth <= 768; if (isMobile) { // Bottom-anchored sheet on mobile content.style.position = 'fixed'; content.style.pointerEvents = 'auto'; content.style.left = '0'; content.style.right = '0'; content.style.bottom = '0'; content.style.top = 'auto'; content.style.transform = 'none'; } else { // Center on screen using fixed positioning + computed offsets content.style.position = 'fixed'; content.style.pointerEvents = 'auto'; // Wait a frame for size to stabilize, then center. Center against the // modal's max-height (85vh) — NOT the live offsetHeight, which is tiny // while the email list is still loading and put the window ~1/3 down // (then it grew off the bottom as the list filled in). requestAnimationFrame(() => { const w = content.offsetWidth; const refH = window.innerHeight * 0.85; content.style.left = Math.max(20, (window.innerWidth - w) / 2) + 'px'; content.style.top = Math.max(20, (window.innerHeight - refH) / 2) + 'px'; content.style.transform = 'none'; }); } } // Wire events document.getElementById('email-lib-close').addEventListener('click', closeEmailLibrary); // Clicking the modal header (anywhere except buttons/inputs) collapses // any currently-expanded email card and returns to the inbox list view. // Acts as a "back to email menu" gesture. const libHeader = modal.querySelector('.modal-header'); if (libHeader) { libHeader.style.cursor = 'pointer'; libHeader.addEventListener('click', (ev) => { if (ev.target.closest('button, input, select, a')) return; const g = document.getElementById('email-lib-grid'); if (!g) return; g.querySelectorAll('.doclib-card.doclib-card-expanded').forEach(c => { const uid = c.dataset.uid; const liveEm = state._libEmails.find(e => String(e.uid) === String(uid)); if (liveEm) _toggleCardPreview(c, liveEm); }); }); } // Drag-to-top edge → snap to fullscreen (Aero Snap). Dragging away from // the top edge while fullscreen unsnaps back to a centered window. _makeDraggable(content, modal, 'email-lib-fullscreen'); document.getElementById('email-lib-folder').addEventListener('change', (e) => { state._libFolder = e.target.value; state._libOffset = 0; state._libEmails = []; _loadEmails(); }); document.getElementById('email-lib-filter').addEventListener('change', (e) => { state._libFilter = e.target.value; state._libOffset = 0; state._libEmails = []; _syncUnreadWindowGlow(); _syncReminderClearButton(); _loadEmails(); // Sync quick-toggle active states so they mirror the dropdown. document.getElementById('email-undone-btn')?.classList.toggle('active', state._libFilter === 'undone'); document.getElementById('email-reminder-btn')?.classList.toggle('active', state._libFilter === 'reminders'); }); document.getElementById('email-attach-btn')?.addEventListener('click', () => { const btn = document.getElementById('email-attach-btn'); state._libHasAttachments = !state._libHasAttachments; btn?.classList.toggle('active', state._libHasAttachments); state._libOffset = 0; state._libEmails = []; _syncReminderClearButton(); _loadEmails(); }); document.getElementById('email-reminders-clear-btn')?.addEventListener('click', async () => { const ok = await styledConfirm('Permanently delete all Odysseus reminder emails?', { confirmText: 'Delete', cancelText: 'Cancel', danger: true, }); if (!ok) return; try { const res = await fetch(`${API_BASE}/api/email/odysseus/reminders?permanent=1${_acct()}`, { method: 'DELETE', credentials: 'same-origin', }); const data = await res.json().catch(() => ({})); showToast(`Deleted ${data.deleted || 0} reminder email${(data.deleted || 0) === 1 ? '' : 's'}`); if ((data.deleted || 0) > 0) { const visibleUids = Array.from(document.querySelectorAll('#email-lib-grid .doclib-card[data-uid]')) .map(card => card.dataset.uid) .filter(Boolean); await _animateEmailCardRemoval(visibleUids); } state._libFilter = 'all'; const filterEl = document.getElementById('email-lib-filter'); if (filterEl) filterEl.value = 'all'; document.getElementById('email-reminder-btn')?.classList.remove('active'); state._libOffset = 0; state._libEmails = []; _syncReminderClearButton(); _loadEmails(); } catch (err) { console.error(err); showToast('Failed to clear reminder emails'); } }); document.getElementById('email-undone-btn')?.addEventListener('click', () => { const btn = document.getElementById('email-undone-btn'); const filterEl = document.getElementById('email-lib-filter'); if (state._libFilter === 'undone') { state._libFilter = 'all'; filterEl.value = 'all'; btn.classList.remove('active'); } else { state._libFilter = 'undone'; filterEl.value = 'undone'; btn.classList.add('active'); document.getElementById('email-reminder-btn')?.classList.remove('active'); } state._libOffset = 0; state._libEmails = []; _syncUnreadWindowGlow(); _syncReminderClearButton(); _loadEmails(); }); document.getElementById('email-reminder-btn')?.addEventListener('click', () => { const btn = document.getElementById('email-reminder-btn'); const filterEl = document.getElementById('email-lib-filter'); if (state._libFilter === 'reminders') { state._libFilter = 'all'; filterEl.value = 'all'; btn.classList.remove('active'); } else { state._libFilter = 'reminders'; filterEl.value = 'reminders'; btn.classList.add('active'); document.getElementById('email-undone-btn')?.classList.remove('active'); } state._libOffset = 0; state._libEmails = []; _syncUnreadWindowGlow(); _syncReminderClearButton(); _loadEmails(); }); // The old "sort" dropdown (Latest / Unread first / Favorites first) was merged // into the filter dropdown above — "Favorites" is now a filter (server-side // \Flagged search). _libSort stays at its 'recent' default so the grid keeps // the API's newest-first order. let searchTimer = null; document.getElementById('email-lib-search').addEventListener('input', (e) => { state._libSearch = e.target.value; if (searchTimer) clearTimeout(searchTimer); searchTimer = setTimeout(_doSearch, 350); }); document.getElementById('email-lib-refresh-btn').addEventListener('click', async () => { const btn = document.getElementById('email-lib-refresh-btn'); btn?.classList.add('email-lib-refreshing'); state._libOffset = 0; // Don't wipe state._libEmails — _loadEmails will paint the cached // list while the forced refetch runs, so the grid doesn't blank out // mid-refresh. `force: true` adds the cache-buster so the server's // 8s list cache is bypassed for an actually-fresh result. try { await _loadEmails({ force: true }); } finally { btn?.classList.remove('email-lib-refreshing'); // Flash a checkmark for ~900ms so the user gets a clear "done" cue. if (btn) { const orig = btn.innerHTML; btn.classList.add('email-lib-refresh-done'); btn.innerHTML = ''; setTimeout(() => { if (btn.classList.contains('email-lib-refresh-done')) { btn.classList.remove('email-lib-refresh-done'); btn.innerHTML = orig; } }, 900); } } }); const _composeNew = () => { // Desktop: keep Email open when there is enough room for it plus the // compose/document pane. Mobile still tabs down so the doc owns the screen. if (_prepareEmailWindowForDocument(document.getElementById('email-lib-modal'))) { if (!Modals.minimize('email-lib-modal')) closeEmailLibrary(); } if (state._onEmailClick) state._onEmailClick({ compose: true }); if (document.body.classList.contains('email-doc-split-active')) { _scheduleEmailDocumentSplitMeasure(document.getElementById('email-lib-modal')); } }; document.getElementById('email-lib-compose-btn').addEventListener('click', _composeNew); // Mobile FAB: same action as the (desktop) New button, plus collapse-to-icon // while the list scrolls and spring back out to "New" when scrolling stops. const _fab = document.getElementById('email-lib-fab'); if (_fab) { _fab.addEventListener('click', _composeNew); const _grid = document.getElementById('email-lib-grid'); if (_grid) { let _fabIdle = null; _grid.addEventListener('scroll', () => { _fab.classList.add('collapsed'); clearTimeout(_fabIdle); _fabIdle = setTimeout(() => _fab.classList.remove('collapsed'), 280); _positionFab(); // Firefox's toolbar shows/hides on scroll }, { passive: true }); } // Keep the FAB above the browser's bottom toolbar. env(safe-area-inset) // doesn't cover Firefox-for-Android's URL bar, and its 100dvh handling is // unreliable, so measure how far the panel extends below the *visible* // (visualViewport) area and lift the button by that much. function _positionFab() { if (!_fab.isConnected) { // modal was rebuilt/closed — stop listening window.visualViewport?.removeEventListener('resize', _positionFab); window.visualViewport?.removeEventListener('scroll', _positionFab); window.removeEventListener('resize', _positionFab); return; } const card = _fab.parentElement; // .admin-card (positioned) const vh = window.visualViewport ? window.visualViewport.height : window.innerHeight; const overflowBelow = card ? Math.max(0, Math.round(card.getBoundingClientRect().bottom - vh)) : 0; _fab.style.bottom = `calc(18px + env(safe-area-inset-bottom, 0px) + ${overflowBelow}px)`; } if (window.visualViewport) { window.visualViewport.addEventListener('resize', _positionFab); window.visualViewport.addEventListener('scroll', _positionFab); } window.addEventListener('resize', _positionFab); // Run after layout settles (modal opens with an animation). requestAnimationFrame(() => requestAnimationFrame(_positionFab)); setTimeout(_positionFab, 300); // Reveal the FAB with a scale-from-center pop only AFTER the email list has // rendered (the window is "fully loaded") — position it first while it's // still invisible so it never flashes at the top and slides down. let _revealed = false; const _revealFab = () => { if (_revealed || !_fab.isConnected) return; _revealed = true; _positionFab(); // The FAB is an absolute child of .modal-content, which slides up on open // (sheet-enter). Wait until that entrance finishes before popping the FAB // in, otherwise it rides the slide ("swipes down with the window"). const content = _fab.closest('.modal-content'); const pop = () => { _positionFab(); requestAnimationFrame(() => _fab.classList.add('fab-revealed')); }; if (!content || content.classList.contains('sheet-ready')) { pop(); } else { let done = false; const onEnd = () => { if (done) return; done = true; content.removeEventListener('animationend', onEnd); pop(); }; content.addEventListener('animationend', onEnd); setTimeout(onEnd, 450); // fallback if animationend doesn't fire } }; if (_grid) { if (_grid.children.length) { _revealFab(); } else { const _gobs = new MutationObserver(() => { if (_grid.children.length) { _gobs.disconnect(); _revealFab(); } }); _gobs.observe(_grid, { childList: true }); // Safety net — never leave the FAB hidden if the list stays empty. setTimeout(() => { _gobs.disconnect(); _revealFab(); }, 1600); } } else { setTimeout(_revealFab, 400); } } // Select mode toggle document.getElementById('email-lib-select-btn').addEventListener('click', () => { state._selectMode = !state._selectMode; state._selectedUids.clear(); _updateBulkBar(); _renderGrid(); }); document.getElementById('email-lib-select-all').addEventListener('change', (e) => { if (e.target.checked) { state._libEmails.forEach(em => state._selectedUids.add(em.uid)); } else { state._selectedUids.clear(); } _updateBulkBar(); _renderGrid(); }); // Bulk cancel — wired with the same teardown a fresh Cancel-via-toggle does. // Lets the global Esc handler (keyboard-shortcuts.js) close select mode by // clicking the visible [id$="-bulk-cancel"] button. document.getElementById('email-lib-bulk-cancel')?.addEventListener('click', () => { state._selectMode = false; state._selectedUids.clear(); _updateBulkBar(); _renderGrid(); }); // Bulk actions document.getElementById('email-lib-bulk-actions').addEventListener('click', (e) => { e.stopPropagation(); if (state._selectedUids.size === 0) { showToast('Select emails first'); return; } _showBulkActionsMenu(e.currentTarget); }); document.getElementById('email-lib-bulk-delete')?.addEventListener('click', (e) => { e.stopPropagation(); if (state._selectedUids.size === 0) { showToast('Select emails first'); return; } _bulkAction('delete'); }); // ESC to close + Arrow nav + Delete on the selected / currently-expanded email. state._libEscHandler = (e) => { if (e.key === 'Escape') { if (state._selectMode) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation?.(); state._selectMode = false; state._selectedUids.clear(); _updateBulkBar(); _renderGrid(); return; } closeEmailLibrary(); return; } // Don't hijack arrows / delete while the user is typing somewhere. const t = e.target; if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; const isDeleteKey = e.key === 'Delete' || e.key === 'Backspace'; if (isDeleteKey && state._selectMode && state._selectedUids.size > 0) { e.preventDefault(); _bulkAction('delete'); return; } const expanded = document.querySelector('#email-lib-modal .doclib-card.doclib-card-expanded'); if (!expanded) return; if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { const dir = e.key === 'ArrowLeft' ? '-1' : '1'; const btn = expanded.querySelector(`.email-card-nav-btn[data-nav-dir="${dir}"]`); if (btn) { e.preventDefault(); btn.click(); } } else if (isDeleteKey) { const em = state._libEmails.find(x => String(x.uid) === String(expanded.dataset.uid)); if (em) { e.preventDefault(); _deleteEmailAndAdvance(em, expanded); } } }; document.addEventListener('keydown', state._libEscHandler); _loadAccounts(); _loadFolders(); _loadEmailReminderBellVisibility(); _loadEmails(); } async function _loadAccounts() { try { const r = await fetch(`${API_BASE}/api/email/accounts`); if (!r.ok) return; const d = await r.json(); state._libAccounts = d.accounts || []; } catch (_) { state._libAccounts = []; } _renderAccountsStrip(); } function _renderAccountsStrip() { const strip = document.getElementById('email-lib-accounts'); if (!strip) return; strip.style.display = 'flex'; // No accounts loaded yet — leave the row empty (New button still shows alongside). if (!state._libAccounts.length) { strip.innerHTML = ''; return; } const esc = s => String(s || '').replace(/&/g, '&').replace(/All (default)`; for (const a of state._libAccounts) { const active = state._libAccountId === a.id ? ' active' : ''; const label = a.name || a.from_address || a.imap_user || 'account'; html += ``; } strip.innerHTML = html; strip.querySelectorAll('button[data-acc-id]').forEach(btn => { btn.addEventListener('click', async () => { state._libAccountId = btn.dataset.accId || null; _publishActiveAccount(); state._libOffset = 0; state._libEmails = []; _renderAccountsStrip(); await _loadFolders({ resetMissing: true }); _loadEmails({ force: true }); }); }); _publishActiveAccount(); } export function closeEmailLibrary() { const modal = document.getElementById('email-lib-modal'); if (modal) modal.remove(); _clearEmailDocumentSplit(); if (state._libEscHandler) { document.removeEventListener('keydown', state._libEscHandler); state._libEscHandler = null; } state._libOpen = false; // If the /email route collapsed the wide sidebar to make room for // the fullscreen modal, re-expand it now that the modal is gone. try { window._restoreSidebarIfRouteCollapsed?.(); } catch (_) {} } // Make a modal draggable by its header. If `modal` and `fsClass` are // provided, dragging to the top edge of the viewport snaps to fullscreen // (Aero Snap). Dragging away from the top while fullscreen unsnaps. function _makeDraggable(content, modal, fsClass) { if (!content) return; const header = content.querySelector('.modal-header'); if (!header) return; // Per-modal fullscreen behavior — caller supplies fsClass, we apply // the same inline-style fullscreen pattern email-lib + email-window // both use. exitFullscreen restores the default windowed size // (min(720px, 92vw) × 85vh) and centers around the cursor. const enterFullscreen = () => { if (!fsClass || modal.classList.contains(fsClass)) return; modal.classList.add(fsClass); content.style.position = 'fixed'; content.style.left = '0'; content.style.top = '0'; content.style.right = '0'; content.style.bottom = '0'; content.style.width = '100vw'; content.style.maxWidth = '100vw'; content.style.height = '100vh'; content.style.maxHeight = '100vh'; content.style.borderRadius = '0'; content.style.transform = 'none'; }; const exitFullscreen = (cx, cy) => { if (!fsClass || !modal.classList.contains(fsClass)) return; modal.classList.remove(fsClass); content.style.width = 'min(720px, 92vw)'; content.style.maxWidth = ''; content.style.height = ''; content.style.maxHeight = '85vh'; content.style.borderRadius = ''; content.style.right = ''; content.style.bottom = ''; const w = Math.min(720, window.innerWidth * 0.92); content.style.left = Math.max(8, cx - w / 2) + 'px'; content.style.top = Math.max(8, cy - 20) + 'px'; }; makeWindowDraggable(modal, { content, header, fsClass, skipSelector: '.close-btn, .modal-close', enableLeftDock: true, // park the email on the left while replying on the right onEnterFullscreen: fsClass ? enterFullscreen : null, onExitFullscreen: fsClass ? exitFullscreen : null, }); } // When the user clicks Reply on a fullscreened email view, dock the email // modal to the left as a narrow sidebar so the doc panel (which opens on // the right side of the chat area) is visible side-by-side. Only triggers // when the viewport is wide enough to make a true split worthwhile. Returns // true if the snap was applied, false otherwise. function _snapEmailModalToLeftSidebar(modal) { if (!modal) return false; if (window.innerWidth < 900) return false; const content = modal.querySelector('.modal-content'); if (!content) return false; // Only dock if currently fullscreen — for a manually-sized window the // user already chose its layout; don't surprise them by snapping it. const wasLibFs = modal.classList.contains('email-lib-fullscreen'); const wasWinFs = modal.classList.contains('email-window-fullscreen'); if (!wasLibFs && !wasWinFs) return false; modal.classList.remove('email-lib-fullscreen'); modal.classList.remove('email-window-fullscreen'); modal.classList.add('email-snap-left'); const W = Math.min(440, Math.max(360, Math.round(window.innerWidth * 0.30))); const left = _emailSplitLeftEdge(); content.style.position = 'fixed'; content.style.left = '0'; content.style.top = '0'; content.style.right = ''; content.style.bottom = '0'; content.style.width = W + 'px'; content.style.maxWidth = W + 'px'; content.style.height = '100vh'; content.style.maxHeight = '100vh'; content.style.borderRadius = '0'; content.style.transform = 'none'; content.style.margin = '0'; _setEmailDocumentSplit(left, W); _scheduleEmailDocumentSplitMeasure(modal); return true; } async function _loadFolders({ resetMissing = false } = {}) { const seq = ++_libFolderSeq; const accountAtStart = state._libAccountId || ''; try { const res = await fetch(`${API_BASE}/api/email/folders?_=${Date.now()}${_acct()}`); const data = await res.json(); if (seq !== _libFolderSeq || accountAtStart !== (state._libAccountId || '')) return; const sel = document.getElementById('email-lib-folder'); if (!sel || !data.folders) return; state._libFolders = data.folders; if (resetMissing && state._libFolder !== '__scheduled__' && !data.folders.includes(state._libFolder)) { state._libFolder = data.folders.includes('INBOX') ? 'INBOX' : (data.folders[0] || 'INBOX'); state._libFilter = 'all'; state._libSearch = ''; state._libHasAttachments = false; _libListCache.clear(); const searchEl = document.getElementById('email-lib-search'); const filterEl = document.getElementById('email-lib-filter'); const attachEl = document.getElementById('email-attachments-btn'); if (searchEl) searchEl.value = ''; if (filterEl) filterEl.value = 'all'; if (attachEl) attachEl.classList.remove('active'); _syncUnreadWindowGlow(); _syncReminderClearButton(); } sel.innerHTML = ''; const { priority, others } = sortedFolders(data.folders); for (const f of priority) { const opt = document.createElement('option'); opt.value = f; opt.textContent = folderDisplayName(f); if (f === state._libFolder) opt.selected = true; sel.appendChild(opt); } if (priority.length > 0 && others.length > 0) { const sep = document.createElement('option'); sep.disabled = true; sep.textContent = '─────────'; sel.appendChild(sep); } for (const f of others) { const opt = document.createElement('option'); opt.value = f; opt.textContent = folderDisplayName(f); if (f === state._libFolder) opt.selected = true; sel.appendChild(opt); } // Scheduled (special virtual folder) const sep2 = document.createElement('option'); sep2.disabled = true; sep2.textContent = '─────────'; sel.appendChild(sep2); const schedOpt = document.createElement('option'); schedOpt.value = '__scheduled__'; schedOpt.textContent = 'Scheduled'; if (state._libFolder === '__scheduled__') schedOpt.selected = true; sel.appendChild(schedOpt); sel.value = state._libFolder; } catch (e) {} } function _crossFolderCandidates() { const available = Array.isArray(state._libFolders) ? state._libFolders.filter(Boolean) : []; const lower = new Map(available.map(f => [String(f).toLowerCase(), f])); const pick = (patterns, fallback) => { for (const p of patterns) { const direct = lower.get(String(p).toLowerCase()); if (direct) return direct; } const match = available.find(f => patterns.some(p => String(f).toLowerCase().includes(String(p).toLowerCase()))); return match || fallback; }; const candidates = [ pick(['INBOX'], 'INBOX'), pick(['[Gmail]/Sent Mail', 'Sent Mail', 'Sent Items', 'INBOX.Sent', 'Sent'], '[Gmail]/Sent Mail'), pick(['Archive', '[Gmail]/All Mail', 'All Mail'], '[Gmail]/All Mail'), ]; return Array.from(new Set(candidates.filter(Boolean))); } async function _doSearch() { const q = state._libSearch.trim(); if (q.length < 2) { // Empty or too short — show regular loaded emails _renderGrid(); return; } const grid = document.getElementById('email-lib-grid'); if (!grid) return; grid.innerHTML = ''; const sp = spinnerModule.createWhirlpool(28); grid.appendChild(sp.element); try { const res = await fetch(`${API_BASE}/api/email/search?folder=${encodeURIComponent(state._libFolder)}${_acct()}&q=${encodeURIComponent(q)}&limit=100`); const data = await res.json(); sp.destroy(); const results = data.emails || []; state._libEmails = results; // temporarily replace with search results _renderGrid(); const stats = document.getElementById('email-lib-stats'); if (stats) stats.textContent = `${data.total || results.length} match${(data.total || results.length) === 1 ? '' : 'es'}`; } catch (e) { sp.destroy(); grid.innerHTML = '
Search failed
'; } } // Refreshes the small accent-pill in the modal title with the unread count // for the current folder. When the inbox is currently filtered to unread, the // pill flips to show the total-emails count + "all" label, because clicking // it would toggle the filter off — so the label needs to advertise the // action, not the now-current view. Two tiny side-fetches (limit=1, total // only); silent on failure — the badge just stays hidden if the request errors. async function _refreshUnreadBadge() { const badge = document.getElementById('email-lib-unread-badge'); if (!badge) return; try { const folder = state._libFolder || 'INBOX'; if (folder === '__scheduled__') { badge.style.display = 'none'; return; } const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folder)}${_acct()}&limit=1&filter=unread`); const data = await res.json(); const n = data.total || 0; _syncUnreadTabBadge(n); if (state._libFilter === 'unread') { // Currently viewing unread — show what the click will take you to. try { const allRes = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folder)}${_acct()}&limit=1&filter=all`); const allData = await allRes.json(); const t = allData.total || 0; badge.textContent = `${t} all`; badge.title = 'Show all emails'; badge.style.display = ''; } catch (_) { badge.textContent = 'Show all'; badge.title = 'Show all emails'; badge.style.display = ''; } } else if (n > 0) { badge.textContent = n > 999 ? '999+ unread' : `${n} unread`; badge.title = 'Show unread emails'; badge.style.display = ''; } else { badge.style.display = 'none'; } } catch (_) { _syncUnreadTabBadge(0); } } async function _loadEmails({ force = false } = {}) { const seq = ++_libLoadSeq; state._libLoading = true; const accountAtStart = state._libAccountId || ''; const folderAtStart = state._libFolder; const filterAtStart = state._libFilter; const offsetAtStart = state._libOffset; const searchAtStart = state._libSearch; const hasAttachmentsAtStart = state._libHasAttachments; const grid = document.getElementById('email-lib-grid'); if (!grid) { if (seq === _libLoadSeq) state._libLoading = false; return; } // SWR: when loading the first page of a real folder with no search, // paint the cached list immediately (no spinner, no blank grid) and // then quietly refetch behind it. Pagination, search, and the // scheduled virtual folder skip the cache and use the old spinner // path. `force` (Refresh button) still consults the cache for // perceptual continuity, but adds a cache-buster so the server's 8s // list cache is bypassed too. const cacheable = offsetAtStart === 0 && !searchAtStart && folderAtStart !== '__scheduled__'; const ck = cacheable ? _libCacheKey() : null; const cached = cacheable ? _libCacheGet(ck) : null; let sp = null; if (cached) { state._libEmails = cached.emails || []; state._libTotal = cached.total || 0; // Suppress the open-cascade animation when we're painting from // cache — the data was already on screen a moment ago, so sliding // each card in fresh feels janky. Also prevents the cascade from // re-firing when the bg refetch lands within the 900ms cleanup // window and appends new card nodes into the still-classed grid. state._libJustOpened = false; const grid2 = document.getElementById('email-lib-grid'); if (grid2) grid2.classList.remove('email-lib-just-opened'); _renderGrid(); const stats = document.getElementById('email-lib-stats'); if (stats) stats.textContent = `${state._libTotal} emails`; } else { grid.innerHTML = ''; sp = spinnerModule.createWhirlpool(28); grid.appendChild(sp.element); } try { _syncUnreadWindowGlow(); if (folderAtStart === '__scheduled__') { await _loadScheduled(grid, sp); } else { const accountQS = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : ''; const attQS = hasAttachmentsAtStart ? '&has_attachments=1' : ''; // `&_=Date.now()` bypasses the server's 8s list cache. Default // opens omit it so rapid close/reopen returns instantly; the // Refresh button passes `force: true` to add it back. const buster = force ? `&_=${Date.now()}` : ''; const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folderAtStart)}${accountQS}&limit=100&offset=${offsetAtStart}&filter=${filterAtStart}${attQS}${buster}`); const data = await res.json(); if (seq !== _libLoadSeq || accountAtStart !== (state._libAccountId || '')) return; if (data.error) throw new Error(data.error); state._libEmails = data.emails || []; state._libTotal = data.total || 0; if (sp) sp.destroy(); _renderGrid(); const stats = document.getElementById('email-lib-stats'); if (stats) stats.textContent = `${state._libTotal} emails`; _refreshUnreadBadge(); if (cacheable) _libCachePut(ck, { emails: state._libEmails.slice(), total: state._libTotal }); } } catch (e) { if (seq !== _libLoadSeq || accountAtStart !== (state._libAccountId || '')) return; if (sp) sp.destroy(); // If we already painted the cached list, leave it on screen — beats // wiping it for "Failed to load" when there's still readable content. if (!cached) { const msg = e && e.message ? `Failed to load: ${e.message}` : 'Failed to load'; grid.innerHTML = `
${_esc(msg)}${_emailSetupHintHtml()}
`; _wireEmailSetupHint(grid); } } finally { if (seq === _libLoadSeq) state._libLoading = false; } } async function _loadScheduled(grid, sp) { const res = await fetch(`${API_BASE}/api/email/scheduled`); const data = await res.json(); sp.destroy(); const items = data.scheduled || []; grid.innerHTML = ''; const stats = document.getElementById('email-lib-stats'); if (stats) stats.textContent = `${items.length} scheduled`; if (items.length === 0) { grid.innerHTML = '
No scheduled emails
'; return; } for (const it of items) { const card = document.createElement('div'); card.className = 'doclib-card memory-item'; const sendDate = new Date(it.send_at); const dateStr = sendDate.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); const content = document.createElement('div'); content.style.cssText = 'flex:1;min-width:0;'; const subject = it.subject || '(no subject)'; const toDisplay = it.to || '(no recipient)'; content.innerHTML = `
${_esc(subject)} ${it.status === 'failed' ? 'FAILED' : 'PENDING'}
To: ${_esc(toDisplay)} · Sends ${_esc(dateStr)}
${it.error ? `
${_esc(it.error)}
` : ''} `; card.appendChild(content); // Cancel button const cancelBtn = document.createElement('button'); cancelBtn.className = 'memory-item-btn'; cancelBtn.title = 'Cancel scheduled send'; cancelBtn.innerHTML = ''; cancelBtn.addEventListener('click', async (e) => { e.stopPropagation(); const { styledConfirm } = await import('./ui.js'); const ok = await styledConfirm(`Cancel scheduled email "${subject}"?`, { confirmText: 'Cancel Send', cancelText: 'Keep', danger: true }); if (!ok) return; try { await fetch(`${API_BASE}/api/email/scheduled/${it.id}`, { method: 'DELETE' }); _loadEmails(); } catch (err) { console.error(err); } }); const actionsWrap = document.createElement('div'); actionsWrap.className = 'memory-item-actions'; actionsWrap.appendChild(cancelBtn); card.appendChild(actionsWrap); grid.appendChild(card); } } function _renderGrid() { const grid = document.getElementById('email-lib-grid'); if (!grid) return; grid.innerHTML = ''; let filtered = state._libEmails; // Apply sort if (state._libSort === 'unread') { filtered = [...filtered].sort((a, b) => Number(a.is_read) - Number(b.is_read)); } else if (state._libSort === 'favorites') { filtered = [...filtered].sort((a, b) => Number(b.is_flagged) - Number(a.is_flagged)); } // 'recent' is the default order from the API if (filtered.length === 0) { // Inbox-zero is a win — pair the message with a small smiley so the // empty state reads as "all caught up", not "something's broken". const _smileyIco = '' + emptyStateIcon('smiley') + ''; // Only show the "Set up at Settings › Integrations" hint when the inbox // is TRULY empty — no filter, no search, no source emails. A sub-filter // (reminders, unread, etc.) that happens to be empty isn't a setup // problem; the link there reads as nonsense. const _isTrulyEmpty = ( state._libEmails.length === 0 && (!state._libFilter || state._libFilter === 'all') && !(state._libSearch || '').trim() ); if (_isTrulyEmpty) { grid.innerHTML = '
' + 'No emails' + _smileyIco + '' + '' + 'Set up at: Settings › Integrations' + '' + '
'; const _link = grid.querySelector('[data-open-settings]'); if (_link) _link.addEventListener('click', (e) => { e.preventDefault(); _openSettingsTab(_link.dataset.openSettings || 'integrations'); }); } else { grid.innerHTML = '
' + 'No emails' + _smileyIco + '' + '
'; } return; } // Cascade-on-open: fire the same domino-in animation the sidebar // section uses. Only on the FIRST grid render after the library is // opened — subsequent re-renders (filter/sort/search) need to be // instant. if (state._libJustOpened) { grid.classList.add('email-lib-just-opened'); state._libJustOpened = false; // Strip the class after the cascade so it doesn't restrict later // animations (e.g. the FLIP reflow when archiving). Worst-case // duration matches the longest delay in the keyframe set below. setTimeout(() => grid.classList.remove('email-lib-just-opened'), 900); } for (const em of filtered) { grid.appendChild(_createCard(em)); } // If a deep-link asked us to expand a specific email, do it now and clear. if (state._libPendingExpandUid) { const target = filtered.find(e => String(e.uid) === String(state._libPendingExpandUid)); const wantUid = state._libPendingExpandUid; state._libPendingExpandUid = null; if (target) { const cards = grid.querySelectorAll('.doclib-card'); const targetCard = Array.from(cards).find(c => c.dataset.uid === String(wantUid)); if (targetCard) { requestAnimationFrame(() => _toggleCardPreview(targetCard, target)); } } } } function _createCard(em) { const card = document.createElement('div'); let cls = 'doclib-card memory-item'; if (em.is_answered) cls += ' email-card-answered'; else if (!em.is_read) cls += ' email-card-unread'; card.className = cls; card.dataset.uid = String(em.uid); if (state._selectMode && state._selectedUids.has(em.uid)) card.classList.add('selected'); // Checkbox in select mode if (state._selectMode) { const cb = document.createElement('input'); cb.type = 'checkbox'; cb.className = 'memory-select-cb'; cb.checked = state._selectedUids.has(em.uid); cb.addEventListener('click', e => e.stopPropagation()); cb.addEventListener('change', () => { if (cb.checked) state._selectedUids.add(em.uid); else state._selectedUids.delete(em.uid); card.classList.toggle('selected', cb.checked); _updateBulkBar(); }); card.appendChild(cb); } // In Sent folder, show the recipient(s) — the sender is always you and // hides the actually useful info. Outside Sent, show the sender as before. const isSentFolderEarly = /sent/i.test(state._libFolder); let senderName; if (isSentFolderEarly) { senderName = _formatRecipients(em.to) || em.to || '(no recipient)'; } else { senderName = em.from_name || em.from_address; } const color = _senderColor(senderName); let dateStr = ''; if (em.date) { try { const d = new Date(em.date); const now = new Date(); const sameYear = d.getFullYear() === now.getFullYear(); const dateOpts = sameYear ? { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' } : { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }; dateStr = d.toLocaleString([], dateOpts); } catch (_) {} } const content = document.createElement('div'); content.style.cssText = 'flex:1;min-width:0;'; const titleRow = document.createElement('div'); titleRow.className = 'email-card-titlerow'; titleRow.style.cssText = 'display:flex;align-items:center;gap:6px;'; const titleEl = document.createElement('span'); titleEl.className = 'memory-item-title'; titleEl.textContent = em.subject || '(no subject)'; // Hover preview: surface the cached AI summary directly on the title via // a native browser tooltip — no need to open the email to skim it. if (em.cached_summary) { titleEl.title = em.cached_summary; titleEl.classList.add('email-card-has-summary'); } titleRow.appendChild(titleEl); if (em.has_attachments) { const att = document.createElement('span'); att.title = 'Has attachments'; att.style.cssText = 'opacity:0.6;flex-shrink:0;display:inline-flex;'; att.innerHTML = ''; titleRow.appendChild(att); } // Done check + unread dot stay next to the subject on the left. const isSentFolder = /sent/i.test(state._libFolder); if (!isSentFolder) { const doneCheck = document.createElement('span'); doneCheck.className = 'email-card-done' + (em.is_answered ? ' active' : ''); doneCheck.title = em.is_answered ? 'Mark not done' : 'Mark done'; doneCheck.innerHTML = ''; const _toggleDone = async (e) => { if (e) e.stopPropagation(); // Use the visible class as source of truth — em.is_answered could // be stale from a background sync, which would leave the user // clicking and seeing no UI change. const wasActive = doneCheck.classList.contains('active'); const newState = !wasActive; em.is_answered = newState; doneCheck.classList.toggle('active', newState); doneCheck.title = newState ? 'Mark not done' : 'Mark done'; // Animate in both directions so the user gets explicit feedback when // un-checking too — without this the hover state and the active state // look identical, so the click felt like a no-op. doneCheck.classList.remove('just-checked', 'just-unchecked'); void doneCheck.offsetWidth; // restart animation doneCheck.classList.add(newState ? 'just-checked' : 'just-unchecked'); setTimeout(() => doneCheck.classList.remove('just-checked', 'just-unchecked'), 500); if (newState) { _syncEmailReadState(em.uid, true); } try { if (newState) { await fetch(`${API_BASE}/api/email/mark-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); } else { await fetch(`${API_BASE}/api/email/clear-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); } } catch (err) { console.error(err); } }; doneCheck.addEventListener('click', _toggleDone); titleRow.appendChild(doneCheck); if (!em.is_read) { const dot = document.createElement('span'); dot.className = 'email-card-unread-dot'; dot.style.cssText = `width:6px;height:6px;border-radius:50%;background:${color};flex-shrink:0;margin-left:2px;`; titleRow.appendChild(dot); } } if (em.is_flagged) { const star = document.createElement('span'); star.title = 'Favorited'; star.style.cssText = 'color:var(--accent, var(--red));opacity:0.85;flex-shrink:0;display:inline-flex;'; star.innerHTML = ''; titleRow.appendChild(star); } // Prev/next arrows — visible only when this card is the expanded one // (CSS-gated so collapsed cards stay clean). Click navigates by collapsing // this card and expanding the neighbour. const navArrows = document.createElement('span'); navArrows.className = 'email-card-nav-arrows'; navArrows.innerHTML = ` `; navArrows.addEventListener('click', async (ev) => { const btn = ev.target.closest('.email-card-nav-btn'); if (!btn || btn.disabled) return; ev.stopPropagation(); const card = navArrows.closest('.doclib-card'); if (!card) return; const dir = parseInt(btn.dataset.navDir, 10); const sibling = _findSiblingEmailCard(card, dir); if (!sibling) return; const nextEm = state._libEmails.find(e => String(e.uid) === String(sibling.dataset.uid)); if (!nextEm) return; await _toggleCardPreview(card, em); await _toggleCardPreview(sibling, nextEm); sibling.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }); // Right cluster: expanded-only actions menu + nav arrows. The normal // `.memory-item-actions` menu is hidden while expanded, so this keeps // the same email actions available beside the previous/next controls. const rightCluster = document.createElement('span'); rightCluster.style.cssText = 'margin-left:auto;display:inline-flex;align-items:center;gap:6px;'; const headerMenuBtn = document.createElement('button'); headerMenuBtn.type = 'button'; headerMenuBtn.className = 'email-card-header-menu'; headerMenuBtn.title = 'Actions'; headerMenuBtn.innerHTML = ''; headerMenuBtn.addEventListener('click', (e) => { e.stopPropagation(); _showCardMenu(em, headerMenuBtn); }); // The CSS rule on .email-card-nav-arrows still sets margin-left:auto // (needed when the arrows live alone in the title row). Inside this // wrapper, we want the cluster's gap to apply, so cancel that auto. navArrows.style.marginLeft = '0'; rightCluster.appendChild(headerMenuBtn); rightCluster.appendChild(navArrows); titleRow.appendChild(rightCluster); content.appendChild(titleRow); const meta = document.createElement('div'); meta.className = 'memory-item-meta'; meta.style.cssText = 'font-size:10px;opacity:0.7;margin-top:2px;'; const senderPrefix = isSentFolderEarly ? 'to ' : ''; meta.innerHTML = `${senderPrefix}${_esc(senderName)} · ${_esc(dateStr)}`; content.appendChild(meta); card.appendChild(content); // Per-card menu button (... menu) if (!state._selectMode) { const actionsWrap = document.createElement('div'); actionsWrap.className = 'memory-item-actions'; const menuBtn = document.createElement('button'); menuBtn.className = 'memory-item-btn'; menuBtn.title = 'Actions'; menuBtn.style.position = 'relative'; menuBtn.style.top = '-1px'; menuBtn.innerHTML = ''; menuBtn.addEventListener('click', (e) => { e.stopPropagation(); _showCardMenu(em, menuBtn); }); actionsWrap.appendChild(menuBtn); card.appendChild(actionsWrap); // Long-press anywhere on the row opens the same actions menu — matches // the chats / archive / research / documents tabs' long-press UX. let _hold = null, _holdStart = null; const _cancelHold = () => { if (_hold) { clearTimeout(_hold); _hold = null; } _holdStart = null; }; card.addEventListener('pointerdown', (e) => { if (card.classList.contains('email-card-expanded') || card.classList.contains('doclib-card-expanded')) return; if (e.target.closest('button, .email-card-done, .recipient-chip, .memory-select-cb, .email-card-nav-btn')) return; _holdStart = { x: e.clientX, y: e.clientY }; _hold = setTimeout(() => { _hold = null; if (card.classList.contains('email-card-expanded') || card.classList.contains('doclib-card-expanded')) return; card._suppressNextClick = true; setTimeout(() => { card._suppressNextClick = false; }, 400); if (navigator.vibrate) try { navigator.vibrate(15); } catch {} _showCardMenu(em, menuBtn); }, 500); }); card.addEventListener('pointermove', (e) => { if (!_holdStart) return; if (Math.hypot(e.clientX - _holdStart.x, e.clientY - _holdStart.y) > 10) _cancelHold(); }); card.addEventListener('pointerup', _cancelHold); card.addEventListener('pointercancel', _cancelHold); } // Click handler — toggle preview expansion card.addEventListener('click', async (e) => { if (card._suppressNextClick) { card._suppressNextClick = false; return; } if (state._selectMode) { if (state._selectedUids.has(em.uid)) state._selectedUids.delete(em.uid); else state._selectedUids.add(em.uid); card.classList.toggle('selected', state._selectedUids.has(em.uid)); const cb = card.querySelector('.memory-select-cb'); if (cb) cb.checked = state._selectedUids.has(em.uid); _updateBulkBar(); return; } await _toggleCardPreview(card, em); }); return card; } function _findSiblingEmailCard(card, dir) { const grid = card.closest('.doclib-grid'); if (!grid) return null; const cards = [...grid.querySelectorAll('.doclib-card[data-uid]')]; const idx = cards.indexOf(card); if (idx === -1) return null; return cards[idx + dir] || null; } function _syncCardNavArrows(card) { const prev = card.querySelector('.email-card-nav-btn[data-nav-dir="-1"]'); const next = card.querySelector('.email-card-nav-btn[data-nav-dir="1"]'); if (prev) prev.disabled = !_findSiblingEmailCard(card, -1); if (next) next.disabled = !_findSiblingEmailCard(card, 1); } async function _toggleCardPreview(card, em) { const grid = card.closest('.doclib-grid'); // Already expanded — collapse if (card.classList.contains('email-card-expanded')) { card.classList.remove('email-card-expanded'); card.classList.remove('doclib-card-expanded'); document.getElementById('email-lib-modal')?.classList.remove('email-reading'); const reader = card.querySelector('.email-card-reader'); if (reader) reader.remove(); return; } // Collapse any other expanded card if (grid) { grid.querySelectorAll('.email-card-expanded').forEach(c => { c.classList.remove('email-card-expanded'); c.classList.remove('doclib-card-expanded'); const r = c.querySelector('.email-card-reader'); if (r) r.remove(); }); } card.classList.add('email-card-expanded'); card.classList.add('doclib-card-expanded'); if (!em.is_read) { _syncEmailReadState(em.uid, true); fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }) .catch(err => console.error('Failed to mark email read:', err)); } // Class hook on the modal so the header-hide / padding rules work on // browsers without :has() support (Firefox mobile) — the :has() versions // below stay as the desktop path. document.getElementById('email-lib-modal')?.classList.add('email-reading'); // Show loading reader with whirlpool spinner const reader = document.createElement('div'); reader.className = 'email-card-reader'; const loadingWrap = document.createElement('div'); loadingWrap.style.cssText = 'padding:20px;display:flex;justify-content:center;align-items:center;flex:1;'; const sp = spinnerModule.createWhirlpool(28); loadingWrap.appendChild(sp.element); reader.appendChild(loadingWrap); card.appendChild(reader); try { const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`); const data = await res.json(); if (data.error) { reader.innerHTML = `
Error: ${_esc(data.error)}
`; return; } // Mark as read locally _syncEmailReadState(em.uid, true); // Build the attachments wrap using the shared helper so the signature- // image filter (small inline PNGs/JPGs, Outlook image001 placeholders, // logo/banner files) is applied here too. Falls back to '' when every // attachment is filtered out. const attsHtml = _buildAttsHtmlFor(em.uid, data); // Format date nicely (compact): "Mar 21, 2026 14:32" let dateDisplay = data.date || ''; try { if (data.date) { const d = new Date(data.date); if (!isNaN(d.getTime())) { dateDisplay = d.toLocaleString([], { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit', }); } } } catch (_) {} // Build recipient chip group from a comma-separated address list const buildRecipients = (str) => { if (!str) return ''; const addrs = str.split(',').map(s => s.trim()).filter(Boolean); if (addrs.length === 0) return ''; return addrs.map(a => { const name = _extractName(a); return `${_esc(name)}`; }).join(''); }; // Build the From chip too — single chip with name, click reveals address const fromChip = `${_esc(data.from_name || data.from_address)}`; reader.innerHTML = `
${data.to ? `` : ''} ${data.cc ? `` : ''}
${attsHtml}
${_renderEmailBody(data)}
`; // Attachment header click toggles fold/unfold (same UX as the summary). const attsWrap = reader.querySelector('.email-reader-atts-wrap'); if (attsWrap) { const attsToggle = attsWrap.querySelector('.email-reader-atts-header'); if (attsToggle) { attsToggle.addEventListener('click', (ev) => { ev.stopPropagation(); attsWrap.classList.toggle('collapsed'); }); attsToggle.addEventListener('keydown', (ev) => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); attsWrap.classList.toggle('collapsed'); } }); } } // Attachment chip clicks: works on both mobile (iOS Safari ignores // programmatic outside an actual in the DOM) and desktop. // On mobile we open the URL in a new tab so the OS picks the action; on // desktop we fetch + blob-download so the filename is preserved and no // popup-blocker fires. const _isMobileUA = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent); _wireAttachmentHandlers(reader, state._libFolder); reader.querySelector('[data-act="reply"]')?.addEventListener('click', async (ev) => { ev.stopPropagation(); _snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal')); if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply' }); }); reader.querySelector('[data-act="reply-all"]')?.addEventListener('click', async (ev) => { ev.stopPropagation(); _snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal')); if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply-all' }); }); reader.querySelector('[data-act="ai-reply"]')?.addEventListener('click', async (ev) => { ev.stopPropagation(); _snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal')); const btn = ev.currentTarget; btn.disabled = true; const orig = btn.innerHTML; // Use the app-wide whirlpool spinner for consistency. let _wp = null; try { _wp = spinnerModule.createWhirlpool(14); _wp.element.style.cssText = 'width:14px;height:14px;display:inline-block;vertical-align:middle;position:relative;top:-2px;'; btn.innerHTML = ''; btn.appendChild(_wp.element); } catch (_) {} try { if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'ai-reply' }); } finally { try { _wp && _wp.stop(); } catch (_) {} btn.disabled = false; btn.innerHTML = orig; } }); reader.querySelector('[data-act="forward"]')?.addEventListener('click', async (ev) => { ev.stopPropagation(); if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'forward' }); }); reader.querySelector('[data-act="close"]')?.addEventListener('click', (ev) => { ev.stopPropagation(); _toggleCardPreview(card, em); }); reader.querySelector('[data-act="more"]')?.addEventListener('click', (ev) => { ev.stopPropagation(); _showReaderMoreMenu(em, card, reader, ev.currentTarget); }); reader.querySelector('[data-act="summarize"]')?.addEventListener('click', async (ev) => { ev.stopPropagation(); await _summarizeEmail(reader, data, ev.currentTarget); }); // from-sender / thread-search Search button is DISABLED for now — // the search + threaded sidebar UX is too buggy to ship. Physically // remove it from every reader render path. Re-enable by deleting // these .remove() lines + the CSS rule. reader.querySelector('[data-act="from-sender"]')?.remove(); reader.querySelector('[data-act="from-sender"]')?.addEventListener('click', async (ev) => { ev.stopPropagation(); await _toggleFromSenderPanel(reader, data, ev.currentTarget); }); // Refresh the title-row prev/next arrows for this newly-expanded card. _syncCardNavArrows(card); // Horizontal swipe on the reader switches to prev/next email — but // only when the underlying content can't scroll further in the swipe // direction. If the email body is wider than the viewport (HTML emails // with tables, embedded images), normal horizontal scroll wins; nav // only fires once the user has reached an edge. { let _sx = 0, _sy = 0, _swiping = false, _intent = null; let _scrollEl = null; let _startScrollLeft = 0; const SWIPE_THRESHOLD = 60; const VERT_ABORT = 14; const findHScroller = (el) => { while (el && el !== reader) { if (el.scrollWidth - el.clientWidth > 2) return el; el = el.parentElement; } return null; }; reader.addEventListener('touchstart', (ev) => { if (ev.touches.length !== 1) { _swiping = false; return; } if (ev.target.closest('button, a, .recipient-chip, .email-attachment-chip, .email-reader-more-wrap')) { _swiping = false; return; } _sx = ev.touches[0].clientX; _sy = ev.touches[0].clientY; _scrollEl = findHScroller(ev.target); _startScrollLeft = _scrollEl ? _scrollEl.scrollLeft : 0; _swiping = true; _intent = null; }, { passive: true }); reader.addEventListener('touchmove', (ev) => { if (!_swiping) return; const dx = ev.touches[0].clientX - _sx; const dy = ev.touches[0].clientY - _sy; if (!_intent) { if (Math.abs(dy) > VERT_ABORT && Math.abs(dy) > Math.abs(dx)) { _intent = 'scroll'; _swiping = false; return; } if (Math.abs(dx) > 12) _intent = 'swipe'; } }, { passive: true }); reader.addEventListener('touchend', (ev) => { if (!_swiping) return; _swiping = false; const t = (ev.changedTouches && ev.changedTouches[0]) || null; if (!t || _intent !== 'swipe') return; const dx = t.clientX - _sx; const dy = t.clientY - _sy; if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dy) > Math.abs(dx)) return; // If a horizontally-scrollable element captured the swipe, let it // scroll instead of changing email — UNLESS the user was already // at the edge (scrollLeft can't move further in that direction). if (_scrollEl) { const max = _scrollEl.scrollWidth - _scrollEl.clientWidth; const atLeftEdge = _scrollEl.scrollLeft <= 2; const atRightEdge = _scrollEl.scrollLeft >= max - 2; // Swiping LEFT (dx<0) reveals content to the right → if not at // right edge, that's a scroll, not a nav. if (dx < 0 && !atRightEdge) return; // Swiping RIGHT (dx>0) reveals content to the left → if not at // left edge, that's a scroll, not a nav. if (dx > 0 && !atLeftEdge) return; // If the browser already scrolled during this gesture, treat as // scroll regardless (the user clearly wanted to pan). if (_scrollEl.scrollLeft !== _startScrollLeft) return; } const dir = dx < 0 ? 1 : -1; const navBtn = card.querySelector(`.email-card-nav-btn[data-nav-dir="${dir}"]`); if (navBtn && !navBtn.disabled) navBtn.click(); }, { passive: true }); } // If the email has a pre-cached summary, show it immediately. Fold // state is persisted via _summaryCollapsedPref inside the renderer. if (data.cached_summary) { const sumBtn = reader.querySelector('[data-act="summarize"]'); _showCachedSummary(reader, data.cached_summary, sumBtn); } // Event delegation for recipient chip clicks (toggle expand) reader.addEventListener('click', (ev) => { const chip = ev.target.closest('.recipient-chip'); if (chip && reader.contains(chip)) { ev.stopPropagation(); ev.preventDefault(); const full = chip.getAttribute('data-full') || ''; if (chip.classList.contains('expanded')) { chip.classList.remove('expanded'); const name = chip.getAttribute('data-name'); if (name != null) chip.textContent = name; } else { if (!chip.hasAttribute('data-name')) { chip.setAttribute('data-name', chip.textContent.trim()); } chip.classList.add('expanded'); // Decode HTML entities from the data-full attribute const tmp = document.createElement('textarea'); tmp.innerHTML = full; chip.textContent = tmp.value; } return; } // Always stop bubbling so the card's click doesn't fire ev.stopPropagation(); }); } catch (e) { reader.innerHTML = `
Failed to load email
`; } } /** * Wrap a probable signature block in a collapsed
so it stops * eating the whole reader. We try, in priority order: * 1. Mail-client signature wrappers — Gmail's `gmail_signature` div is * explicit, no guessing required. Same for Apple Mail's data-smartmail. * 2. The standard "-- " RFC 3676 sig delimiter. * 3. A common closing phrase ("Best regards", "Cheers", etc.) on its own * line — fuzzier, but catches sigs without the dash marker. * 4. "Sent from my iPhone/Android" / "Get Outlook for ..." mobile-client * boilerplate. * Anything matched gets wrapped from the marker through end-of-body. */ /** * Render the email body with sig/quote folds. If the backend has cached * LLM-detected boundary offsets (data.boundaries), use those for an exact * fold based on plain-text positions. Otherwise fall back to the regex * detectors. The plain-body branch is always preferred when boundaries * exist because the offsets are computed against plain text. */ // Global escape hatch — when the server's thread parser misfires (it // occasionally splits a single reply into two bogus "turns" by treating a // signature/disclaimer as its own message), the user can flip this off to // fall back to plain rendering. Survives reloads. const _BUBBLES_DISABLED_KEY = 'odysseus.email.bubblesDisabled'; // Threaded chat-bubble email view is DISABLED for now — too buggy to // ship. Force plain-text rendering everywhere by always returning true. // Re-enable by restoring the localStorage-backed body + the toggle // menu item in the reader's More menu. function _bubblesDisabled() { return true; } function _setBubblesDisabled(v) { try { localStorage.setItem(_BUBBLES_DISABLED_KEY, v ? '1' : '0'); } catch {} } function _renderEmailBody(data) { // Prefer the server-cached thread parse — that's the richest structure // and the one the chat-bubble layout is built around. Skip when the user // has manually disabled bubble rendering. if (!_bubblesDisabled() && Array.isArray(data && data.thread_turns) && data.thread_turns.length) { return _foldSignature( _renderTurnsAsBubbles(data.thread_turns, data), data && data.sender_signature || null, ); } const b = data && data.boundaries; // Use cached boundaries when present AND we have plain-text body to slice const plain = (typeof data.body === 'string' && data.body.length) ? data.body : ''; if (b && plain && (b.sig_start >= 0 || b.quote_start >= 0)) { // Pick the EARLIER of the two as the cut for "everything below this is // foldable", but render sig and quote with their own labels. let sig = (typeof b.sig_start === 'number' && b.sig_start >= 0) ? b.sig_start : -1; let quote = (typeof b.quote_start === 'number' && b.quote_start >= 0) ? b.quote_start : -1; // Clamp if (sig >= plain.length) sig = -1; if (quote >= plain.length) quote = -1; let head = plain; let sigSection = ''; let quoteSection = ''; if (sig >= 0 && quote >= 0) { const earlier = Math.min(sig, quote); head = plain.slice(0, earlier); if (sig < quote) { sigSection = plain.slice(sig, quote); quoteSection = plain.slice(quote); } else { quoteSection = plain.slice(quote, sig); sigSection = plain.slice(sig); } } else if (sig >= 0) { head = plain.slice(0, sig); sigSection = plain.slice(sig); } else { head = plain.slice(0, quote); quoteSection = plain.slice(quote); } const fmt = (s) => _escLinkify(s).replace(/\n/g, '
'); let out = fmt(head); if (quoteSection) { out += ''; } if (sigSection) { const sigHtml = fmt(sigSection); if (_isBloatedSig(sigHtml)) { out += ''; } else { // Short closing — leave inline; folding would just add chrome. out += sigHtml; } } return out; } // Fallback: client-side parse (HTML or plaintext). const hintSig = (data && data.sender_signature) || null; const isHtml = !!data.body_html; let rendered; if (isHtml) { rendered = _sanitizeHtml(data.body_html); } else { const plainTurns = _renderPlaintextThread(data.body || ''); if (plainTurns) return _foldSignature(plainTurns, hintSig); rendered = _escLinkify(data.body || '').replace(/\n/g, '
'); } const threaded = _renderThreadStructure(rendered); if (threaded) return _foldSignature(threaded, hintSig); return _foldSignature(_foldQuotedReplies(rendered), hintSig); } // ── Chat-bubble rendering for email threads ── // Each parsed turn renders as a chat bubble. Bubbles for the active // account's outgoing replies align right; everyone else aligns left. // Order is reversed so the oldest message sits at the top of the // conversation and the newest (the message currently being read) sits // at the bottom — matches the mental model people have from chat. function _meEmailAddrs() { const set = new Set(); for (const a of (state._libAccounts || [])) { if (a && a.from_address) set.add(String(a.from_address).toLowerCase().trim()); if (a && a.imap_user) set.add(String(a.imap_user).toLowerCase().trim()); } return set; } // _parseTurnMeta / _formatBubbleDate / _formatRecipients / _senderColor / // _initials live in ./emailLibrary/utils.js function _renderTurnsAsBubbles(turns, data) { if (!Array.isArray(turns) || !turns.length) return ''; const mineSet = _meEmailAddrs(); const lvl0Email = String(data && data.from_address || '').toLowerCase().trim(); const lvl0Mine = !!lvl0Email && mineSet.has(lvl0Email); const lvl0Author = (data && (data.from_name || data.from_address)) || ''; const lvl0Date = _formatBubbleDate(data && data.date); // Newest reply on top, older history below. Turns come ordered shallow→deep // (level 0 = current reply, deeper levels = older quoted material) so we // render in source order without reversing. const ordered = turns.slice(); // Gather per-turn sender identity + frequency for the no-self case below. const turnIdentity = ordered.map((t) => { if (t.level === 0) { return { email: lvl0Email, author: lvl0Author }; } const p = _parseTurnMeta(t.meta || ''); return { email: p.email, author: p.author }; }); const anyMine = turnIdentity.some(x => x.email && mineSet.has(x.email)); // When the user isn't a participant in this thread (forwarded chains, // historical archives, etc.), assign the two most frequent senders to // opposite sides so the conversation still reads side-to-side. Third+ // parties fall back to hash mod 2. const sideForKey = (() => { if (anyMine) return null; const freq = new Map(); const firstSeen = new Map(); turnIdentity.forEach((x, i) => { const key = (x.email || x.author || '').toLowerCase(); if (!key) return; freq.set(key, (freq.get(key) || 0) + 1); if (!firstSeen.has(key)) firstSeen.set(key, i); }); const sorted = [...freq.entries()] .sort((a, b) => (b[1] - a[1]) || (firstSeen.get(a[0]) - firstSeen.get(b[0]))); const leftKey = sorted[0] && sorted[0][0]; const rightKey = sorted[1] && sorted[1][0]; return (key) => { if (!key) return 'theirs'; if (key === leftKey) return 'theirs'; if (key === rightKey) return 'mine'; // Stable hash for 3rd+ parties. let h = 0; for (let i = 0; i < key.length; i++) h = ((h << 5) - h + key.charCodeAt(i)) | 0; return (h & 1) ? 'mine' : 'theirs'; }; })(); const rows = ordered.map((t, i) => { let isMine, author, date; if (t.level === 0) { isMine = lvl0Mine; author = lvl0Author || 'Me'; date = lvl0Date; } else { const p = _parseTurnMeta(t.meta || ''); isMine = !!p.email && mineSet.has(p.email); author = p.author || (t.meta || 'Earlier reply'); date = p.date; } // No-self fallback: route by per-sender side mapping. if (sideForKey) { const id = turnIdentity[i]; const key = (id.email || id.author || '').toLowerCase(); isMine = sideForKey(key) === 'mine'; } const side = isMine ? 'mine' : 'theirs'; const initials = _initials(author); const color = _senderColor(author || (t.level === 0 ? lvl0Email : '')); const head = ``; const avatar = ``; return ( `` ); }); return ``; } /** * Render server-cached thread turns (list of {level, body_html, meta}) * into the same nested-card structure the client-side parser produces. */ function _renderTurnsFromServer(turns) { if (!Array.isArray(turns) || !turns.length) return ''; let out = ''; const stack = []; // [{ level, html }] const wrap = (t) => `'; for (const t of turns) { if (t.level === 0) { while (stack.length) { const top = stack.pop(); const w = wrap(top); if (stack.length) stack[stack.length - 1].html += w; else out += w; } out += t.body_html || ''; } else { while (stack.length && stack[stack.length - 1].level > t.level) { const top = stack.pop(); const w = wrap(top); if (stack.length) stack[stack.length - 1].html += w; else out += w; } if (!stack.length || stack[stack.length - 1].level < t.level) { stack.push({ level: t.level, meta: t.meta, html: t.body_html || '' }); } else { stack[stack.length - 1].html += t.body_html || ''; if (t.meta && !stack[stack.length - 1].meta) { stack[stack.length - 1].meta = t.meta; } } } } while (stack.length) { const top = stack.pop(); const w = wrap(top); if (stack.length) stack[stack.length - 1].html += w; else out += w; } // Mark the bottom-most fold for rounded corners. const lastIdx = out.lastIndexOf('