From ab5a08a6f98cb3f876473be4bb0d617fd448aa11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o?= Date: Wed, 30 Sep 2026 09:08:21 +0200 Subject: [PATCH] refactor(email): move emailLibrary.js into static/js/emailLibrary/ The email library was 11,375 lines in one file, the second-largest JS module in the repo. `static/js/emailLibrary/` already held four extracted helpers, so the package existed; the bulk of the code just was not in it. The implementation moves to `emailLibrary/index.js` and the old path becomes a re-export wrapper. Five call sites import that path, four of them dynamically with a `?v=` string, and `sw.js` caches URLs verbatim, so a wrapper is what makes the move need no coordinated edit to any of them. The test side is the part worth reviewing. 58 tests read `static/js/emailLibrary.js` as text. Pointing them at `emailLibrary/index.js` would buy one move and break again on the next one, which is exactly what happened to the stylesheet tests (they now go through `tests/helpers/stylesheets.py`). So the same shape: `tests/helpers/js_modules.py` reads the whole package, and assertions stop caring which module a function sits in. `tests/test_email_library_module_graph_js.py` is new. This frontend has no module-graph validation, and a package fails in ways a single file cannot: a wrapper that drops an export is `undefined` at call time rather than an error at load time, and a module that reads a `const` across an import cycle throws only when that module is entered first. It pins the wrapper's surface against the entry module's, evaluates every module on its own in a browser, and requires both import paths to hand out one instance. It also caught a live gap while being written: `emailLibrary/replyRecipients.js` is imported by `emailInbox.js`, an app shell module, and was never in the `sw.js` precache. --- static/js/emailLibrary.js | 11397 +--------------- static/js/emailLibrary/index.js | 11378 +++++++++++++++ static/sw.js | 5 +- tests/helpers/js_modules.py | 50 + tests/test_action_menu_order.py | 51 +- ...est_active_document_visibility_contract.py | 3 +- tests/test_calendar_tool_card_static.py | 3 +- tests/test_calendar_visual_static.py | 12 +- tests/test_card_menu_style_contract.py | 26 +- ...est_email_ai_reply_context_draft_static.py | 5 +- tests/test_email_attachment_search.py | 11 +- tests/test_email_folder_chip_static.py | 3 +- tests/test_email_library_bulk_actions.py | 14 +- tests/test_email_library_module_graph_js.py | 187 + tests/test_email_library_prewarm.py | 5 +- tests/test_email_open_dedup_js.py | 8 +- tests/test_email_picker_exclusivity_static.py | 3 +- tests/test_email_ui_async_identity.py | 3 +- tests/test_external_context_tool_gate.py | 4 +- ...st_notes_mobile_checklist_toggle_static.py | 3 +- tests/test_security_regressions.py | 4 +- 21 files changed, 11729 insertions(+), 11446 deletions(-) create mode 100644 static/js/emailLibrary/index.js create mode 100644 tests/helpers/js_modules.py create mode 100644 tests/test_email_library_module_graph_js.py diff --git a/static/js/emailLibrary.js b/static/js/emailLibrary.js index 385c39ce8..b87e25e6c 100644 --- a/static/js/emailLibrary.js +++ b/static/js/emailLibrary.js @@ -1,11375 +1,26 @@ -/** - * 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?v=20260916largetoolscroll1'; -import { folderDisplayName, sortedFolders } from './emailInbox.js?v=20260914aireply4'; -import settingsModule from './settings.js?v=20260912writingstyle3'; -import * as Modals from './modalManager.js'; -import { topPortalZ } from './toolWindowZOrder.js'; -import { makeWindowDraggable } from './windowDrag.js'; -import { orderActionMenuItems, actionMenuRank, SELECT_MENU_ICON } from './actionMenuOrder.js'; -import { - _esc, _escLinkify, _extractName, _parseTurnMeta, - _formatBubbleDate, _formatRecipients, _senderColor, _initials, - _sanitizeHtml, _renderEmailSummaryError, - _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'; -import { getSettings } from './appConfig.js'; -import { collapseSidebarToRail } from './modalSnap.js'; -import { emailApiUrl } from './emailShared.js'; -import { - bindMenuDismiss, - dismissOrRemove, - dismissTopMenu, - bindExpandedCardDismiss, - unbindExpandedCardDismiss, -} from './escMenuStack.js'; - -const API_BASE = window.location.origin; -let _emailUnreadChipClickWired = false; -let _libLoadSeq = 0; -let _emailMailboxGeneration = 0; -let _emailCardOpenSeq = 0; -let _emailReadMutationSeq = 0; -const _emailReadMutations = new Map(); -let _libFolderSeq = 0; -let _libSearchSeq = 0; -let _libSearchHadResults = false; -let _libSearchInFlight = false; -let _activeEmailReaderForSelectAll = null; -let _libAccountsLoadedAt = 0; -const _LIB_ACCOUNTS_TTL_MS = 5 * 60 * 1000; -let _accountUnreadSeq = 0; -let _accountUnreadState = new Map(); // account_id -> { unreadCount, maxUid } -let _autoReplyRefreshSeq = 0; - -function _hasActiveEmailSearchResults() { - if (!_libSearchHadResults) return false; - if (String(state._libSearch || '').trim()) return true; - return (state._libSearchPills || []).some(p => p?.type === 'text' || p?.type === 'contact'); -} - -function _emailInlineImagesStorageKey(accountId = state._libAccountId) { - return `odysseus.email.viewInlineImages.${String(accountId || 'default')}`; -} - -function _readEmailInlineImagesPreference(accountId = state._libAccountId) { - try { - const stored = localStorage.getItem(_emailInlineImagesStorageKey(accountId)); - return stored === null ? true : stored !== '0'; - } catch (_) { - return true; - } -} - -function _writeEmailInlineImagesPreference(enabled, accountId = state._libAccountId) { - try { - localStorage.setItem(_emailInlineImagesStorageKey(accountId), enabled ? '1' : '0'); - } catch (_) {} -} - -async function _disableInlineImages(accountId = state._libAccountId) { - state._libViewInlineImages = false; - _writeEmailInlineImagesPreference(false, accountId); - document.querySelectorAll('#email-settings-view-inline-images').forEach(toggle => { - toggle.checked = true; - toggle.dispatchEvent(new Event('change', { bubbles: true })); - }); - try { - const res = await fetch(emailApiUrl('/api/email/config', { - account_id: accountId || undefined, - }), { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ email_view_inline_images: false }), - }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - } catch (err) { - console.error('Failed to disable inline images:', err); - } -} - -const _EMAIL_SETTINGS_ICON = ``; -const _EMAIL_SAVE_ICON = ''; -const _EMAIL_SAVED_ICON = ''; - -function _setEmailSaveIcon(button, saved) { - if (!button) return; - const icon = button.querySelector('svg'); - if (icon) icon.outerHTML = saved ? _EMAIL_SAVED_ICON : _EMAIL_SAVE_ICON; - button.classList.toggle('is-saved', !!saved); -} -const _DEFAULT_AUTO_REPLY_SUBJECT = '(Away) {subject}'; -const _DEFAULT_AUTO_REPLY_MESSAGE = "Thanks for your email. I'm away and may be slower to reply."; - -function _normalizeEmailStateFlags(em) { - if (!em || typeof em !== 'object') return em; - const flags = String(em.flags || ''); - const answered = !!( - em.is_answered || - em.is_done || - em.done || - em.answered || - flags.includes('\\Answered') - ); - // Once the normalized field exists, it is the source of truth. Some - // cached/fixture rows also retain legacy aliases such as `favorite`; OR-ing - // those aliases on every render would resurrect a favorite immediately - // after the user turns it off. - const flagged = Object.prototype.hasOwnProperty.call(em, 'is_flagged') - ? !!em.is_flagged - : !!( - em.is_favorite || - em.favorite || - em.flagged || - em.starred || - flags.includes('\\Flagged') - ); - const read = !!( - em.is_read || - em.read || - flags.includes('\\Seen') - ); - em.is_answered = answered; - em.is_done = answered; - em.is_flagged = flagged; - em.is_read = read; - return em; -} - -function _normalizeAutoReplyConfig(cfg = {}) { - const normalized = Object.prototype.hasOwnProperty.call(cfg, 'enabled'); - return { - enabled: normalized ? !!cfg.enabled : !!cfg.email_auto_reply, - start: String(normalized ? (cfg.start || '') : (cfg.email_auto_reply_start || '')), - end: String(normalized ? (cfg.end || '') : (cfg.email_auto_reply_end || '')), - subject: String(normalized ? (cfg.subject || _DEFAULT_AUTO_REPLY_SUBJECT) : (cfg.email_auto_reply_subject || _DEFAULT_AUTO_REPLY_SUBJECT)), - message: String(normalized ? (cfg.message || '') : (cfg.email_auto_reply_message || '')), - cooldown: String(normalized ? (cfg.cooldown || 'period') : (cfg.email_auto_reply_cooldown || 'period')), - scope: String(normalized ? (cfg.scope || 'all') : (cfg.email_auto_reply_scope || 'all')), - accountId: String(normalized ? (cfg.accountId || '') : (cfg.email_auto_reply_account_id || '')), - excludeAutomated: normalized ? cfg.excludeAutomated !== false : cfg.email_auto_reply_exclude_automated !== false, - pauseNotifications: normalized ? !!cfg.pauseNotifications : !!cfg.email_auto_reply_pause_notifications, - viewInlineImages: normalized ? cfg.viewInlineImages !== false : cfg.email_view_inline_images !== false, - }; -} - -function _autoReplyDateValue(value) { - const s = String(value || '').trim(); - const m = s.match(/^(\d{4}-\d{2}-\d{2})/); - return m ? m[1] : ''; -} - -function _todayDateInputValue() { - const now = new Date(); - const pad = value => String(value).padStart(2, '0'); - return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; -} - -const _AUTO_REPLY_CALENDAR_KEY_PREFIX = 'odysseus.email.autoReplyCalendarEvent.'; -const _autoReplyCalendarSyncs = new Map(); - -function _autoReplyCalendarKey(accountId) { - return `${_AUTO_REPLY_CALENDAR_KEY_PREFIX}${String(accountId || '').trim()}`; -} - -function _readAutoReplyCalendarUid(accountId) { - if (!accountId) return ''; - try { return String(localStorage.getItem(_autoReplyCalendarKey(accountId)) || ''); } catch (_) { return ''; } -} - -function _writeAutoReplyCalendarUid(accountId, uid) { - if (!accountId) return; - try { - if (uid) localStorage.setItem(_autoReplyCalendarKey(accountId), uid); - else localStorage.removeItem(_autoReplyCalendarKey(accountId)); - } catch (_) {} -} - -function _addLocalCalendarDays(value, days) { - const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})$/); - if (!match) return ''; - const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]) + days); - const pad = n => String(n).padStart(2, '0'); - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; -} - -function _autoReplyCalendarPayload(cfg, accountId = state._libAccountId) { - const start = _autoReplyDateValue(cfg?.start) || _todayDateInputValue(); - let end = _autoReplyDateValue(cfg?.end) || start; - if (end < start) end = start; - return { - summary: 'Email Auto Reply (away)', - dtstart: start, - dtend: _addLocalCalendarDays(end, 1), - all_day: true, - description: `Odysseus email auto reply - account:${String(accountId || '')}`, - }; -} - -async function _findAutoReplyCalendarEventUids(cfg, accountId) { - const start = _autoReplyDateValue(cfg?.start) || _todayDateInputValue(); - const end = _autoReplyDateValue(cfg?.end) || start; - const startYear = Number(start.slice(0, 4)) || new Date().getFullYear(); - const endYear = Math.max(startYear, Number(end.slice(0, 4)) || startYear); - try { - const response = await fetch(`${API_BASE}/api/calendar/events?start=${startYear - 1}-01-01&end=${endYear + 2}-01-01`, { - credentials: 'same-origin', - }); - if (!response.ok) return []; - const data = await response.json().catch(() => ({})); - const marker = `Odysseus email auto reply - account:${String(accountId || '')}`; - return (data.events || []) - .filter(ev => String(ev?.description || '').startsWith(marker)) - .map(ev => String(ev?.uid || '').trim()) - .filter(Boolean); - } catch (_) { - return []; - } -} - -async function _syncAutoReplyCalendarEventNow(cfg, accountId) { - accountId = String(accountId || '').trim(); - if (!accountId) return false; - let uid = _readAutoReplyCalendarUid(accountId); - const headers = { 'Content-Type': 'application/json' }; - const request = (url, options = {}) => fetch(url, { - credentials: 'same-origin', - ...options, - headers: { ...headers, ...(options.headers || {}) }, - }); - - if (!cfg?.enabled) { - const knownUids = new Set(uid ? [uid] : []); - (await _findAutoReplyCalendarEventUids(cfg, accountId)).forEach(foundUid => knownUids.add(foundUid)); - for (const eventUid of knownUids) { - const response = await request(`${API_BASE}/api/calendar/events/${encodeURIComponent(eventUid)}`, { method: 'DELETE' }); - if (!response.ok && response.status !== 404) throw new Error(`Calendar HTTP ${response.status}`); - } - _writeAutoReplyCalendarUid(accountId, ''); - return true; - } - - const payload = _autoReplyCalendarPayload(cfg, accountId); - if (!uid) { - uid = (await _findAutoReplyCalendarEventUids(cfg, accountId))[0] || ''; - if (uid) _writeAutoReplyCalendarUid(accountId, uid); - } - if (uid) { - const response = await request(`${API_BASE}/api/calendar/events/${encodeURIComponent(uid)}`, { - method: 'PUT', body: JSON.stringify(payload), - }); - if (response.ok) return true; - if (response.status !== 404) throw new Error(`Calendar HTTP ${response.status}`); - _writeAutoReplyCalendarUid(accountId, ''); - } - - const response = await request(`${API_BASE}/api/calendar/events`, { - method: 'POST', body: JSON.stringify(payload), - }); - const data = await response.json().catch(() => ({})); - if (!response.ok || !data.uid) throw new Error(`Calendar HTTP ${response.status}`); - _writeAutoReplyCalendarUid(accountId, data.uid); - return true; -} - -function _syncAutoReplyCalendarEvent(cfg) { - const accountId = String(state._libAccountId || '').trim(); - if (!accountId) return Promise.resolve(false); - const previous = _autoReplyCalendarSyncs.get(accountId) || Promise.resolve(); - const next = previous.catch(() => {}).then(() => _syncAutoReplyCalendarEventNow(cfg, accountId)); - _autoReplyCalendarSyncs.set(accountId, next); - return next.finally(() => { - if (_autoReplyCalendarSyncs.get(accountId) === next) _autoReplyCalendarSyncs.delete(accountId); - }); -} - -function _isAutoReplyActiveForCurrentAccount(cfg) { - cfg = _normalizeAutoReplyConfig(cfg || {}); - if (!cfg.enabled) return false; - if (cfg.scope === 'account' && cfg.accountId && cfg.accountId !== String(state._libAccountId || '')) return false; - const today = new Date().toISOString().slice(0, 10); - const start = _autoReplyDateValue(cfg.start); - const end = _autoReplyDateValue(cfg.end); - if (start && today < start) return false; - if (end && today > end) return false; - return true; -} - -function _syncEmailAutoReplyTitle(active) { - const titleText = document.getElementById('email-lib-title-text'); - const statusDot = document.getElementById('email-lib-auto-reply-status-dot'); - const badge = document.getElementById('email-lib-auto-reply-badge'); - if (titleText) titleText.textContent = active ? 'Auto Reply - Active' : 'Email'; - if (statusDot) statusDot.style.display = active ? 'inline-block' : 'none'; - if (badge) { - badge.style.display = 'none'; - badge.textContent = 'Auto Reply'; - } -} - -async function _fetchEmailSettingsConfig() { - const res = await fetch(emailApiUrl('/api/email/config', { - account_id: state._libAccountId || undefined, - }), { credentials: 'include' }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const normalized = _normalizeAutoReplyConfig(await res.json()); - state._libViewInlineImages = normalized.viewInlineImages; - _writeEmailInlineImagesPreference(state._libViewInlineImages); - return normalized; -} - -async function _fetchEmailWritingStyle() { - const res = await fetch(emailApiUrl('/api/email/style', { - account_id: state._libAccountId || undefined, - }), { credentials: 'include' }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json().catch(() => ({})); - return String(data.style || ''); -} - -function _emailSettingsLoadingHtml(label = 'Loading') { - const wp = spinnerModule.createWhirlpool(16); - const host = document.createElement('span'); - host.className = 'email-settings-loading-spinner'; - host.appendChild(wp.element); - const wrap = document.createElement('div'); - wrap.className = 'email-settings-loading'; - wrap.appendChild(host); - const text = document.createElement('span'); - text.textContent = label; - wrap.appendChild(text); - return wrap.outerHTML; -} - -function _emailSettingsFormHtml(cfg) { - const activeAccount = state._libAccounts?.find(a => a && a.id === state._libAccountId); - const accountAddress = activeAccount - ? String(activeAccount.from_address || activeAccount.imap_user || activeAccount.email || '').trim() - : ''; - const accountScope = accountAddress - ? ` (${_esc(accountAddress)})` - : ''; - return ` -
- - -
- `; -} - -function _emailCleanupSettingsHtml() { - const activeAccount = state._libAccounts?.find(a => a && a.id === state._libAccountId); - const accountAddress = activeAccount - ? String(activeAccount.from_address || activeAccount.imap_user || activeAccount.email || '').trim() - : ''; - const accountScope = accountAddress - ? ` (${_esc(accountAddress)})` - : ''; - return ` -
- - -
- `; -} - -function _emailDisplaySettingsHtml(cfg = {}) { - return ` -
- - -
-
- - -
- `; -} - -function _emailSettingsAccountSelectHtml() { - const accounts = Array.isArray(state._libAccounts) - ? state._libAccounts - .filter(a => a && a.enabled !== false) - .slice() - .sort((a, b) => Number(!!b.is_default) - Number(!!a.is_default)) - : []; - if (!accounts.length) return ''; - const nameCounts = new Map(); - accounts.forEach(a => { - const name = String(a.name || '').trim(); - if (name) nameCounts.set(name, (nameCounts.get(name) || 0) + 1); - }); - const opts = accounts.map(a => { - const name = String(a.name || '').trim(); - const address = String(a.from_address || a.imap_user || '').trim(); - const label = name && nameCounts.get(name) > 1 && address - ? `${name} · ${address}` - : (name || address || 'account'); - const suffix = a.is_default ? ' · (default)' : ''; - return ``; - }).join(''); - const selected = accounts.find(a => a.id === state._libAccountId) || accounts[0]; - const active = !!selected?.is_default; - const icon = active - ? '' - : ''; - const defaultTitle = active ? 'Default account' : 'Make this the default account'; - return `
` - + `` - + `` - + `
`; -} - -function _syncEmailSettingsAccountPicker(page) { - const select = page?.querySelector('#email-settings-account-select'); - const selected = (state._libAccounts || []).find(a => a && a.id === state._libAccountId); - if (select && selected) { - [...select.options] - .sort((a, b) => { - const aAccount = (state._libAccounts || []).find(account => account && account.id === a.value); - const bAccount = (state._libAccounts || []).find(account => account && account.id === b.value); - return Number(!!bAccount?.is_default) - Number(!!aAccount?.is_default); - }) - .forEach(option => select.appendChild(option)); - select.value = selected.id; - [...select.options].forEach(option => { - const account = (state._libAccounts || []).find(a => a && a.id === option.value); - if (!account) return; - const label = account.name || account.from_address || account.imap_user || 'account'; - option.textContent = label + (account.is_default ? ' · (default)' : ''); - }); - } - const toggle = page?.querySelector('.email-settings-default'); - if (!toggle || !selected) return; - const active = !!selected.is_default; - toggle.classList.toggle('active', active); - toggle.setAttribute('aria-pressed', active ? 'true' : 'false'); - toggle.dataset.emailDefaultId = selected.id || ''; - toggle.title = active ? 'Default account' : 'Make this the default account'; - toggle.querySelector('.cookbook-srv-default-icon').innerHTML = active - ? '' - : ''; -} - -function _emailWritingStyleHtml(style) { - return ` -
- - -
- `; -} - -async function _showEmailSettingsPage() { - const modal = document.getElementById('email-lib-modal'); - const page = document.getElementById('email-lib-settings-page'); - const btn = document.getElementById('email-lib-settings-btn'); - if (!modal || !page) return; - modal.classList.add('email-settings-mode'); - page.hidden = false; - page.scrollTop = 0; - page.querySelector('.email-settings-body')?.scrollTo(0, 0); - const headerBackBtn = document.getElementById('email-settings-header-back'); - if (headerBackBtn) headerBackBtn.style.display = 'inline-flex'; - btn?.classList.add('active'); - btn?.setAttribute('aria-expanded', 'true'); - page.innerHTML = ` -
- -
-
- ${_emailSettingsLoadingHtml()} -
- `; - let cfg; - let writingStyle = ''; - try { - await _loadAccounts().catch(() => {}); - const head = page.querySelector('.email-settings-page-head'); - const accountSelectHtml = _emailSettingsAccountSelectHtml(); - const oldPicker = page.querySelector('.email-settings-account-picker'); - if (oldPicker) oldPicker.remove(); - const title = head?.querySelector('.email-settings-title'); - if (title && accountSelectHtml) title.insertAdjacentHTML('beforeend', accountSelectHtml); - [cfg, writingStyle] = await Promise.all([ - _fetchEmailSettingsConfig(), - _fetchEmailWritingStyle().catch(() => ''), - ]); - } catch (err) { - page.querySelector('.email-settings-body').innerHTML = `
Could not load settings.
`; - return; - } - page.querySelector('.email-settings-body').innerHTML = _emailWritingStyleHtml(writingStyle) + _emailDisplaySettingsHtml(cfg) + _emailCleanupSettingsHtml() + _emailSettingsFormHtml(cfg); - page.querySelector('.email-settings-body')?.scrollTo(0, 0); - state._libAutoReplyActive = _isAutoReplyActiveForCurrentAccount(cfg); - state._libAutoReplyDraftActive = null; - _syncEmailAutoReplyTitle(state._libAutoReplyActive); - _renderAccountsStrip(); - _syncAutoReplyCalendarEvent(cfg).catch((err) => console.warn('Failed to reconcile auto-reply calendar event:', err)); - page.querySelector('#email-settings-account-select')?.addEventListener('change', async (ev) => { - state._libAccountId = ev.currentTarget.value || null; - state._libAutoReplyDraftActive = null; - _publishActiveAccount(); - _syncEmailSettingsAccountPicker(page); - _renderAccountsStrip(); - _resetEmailListForFreshLoad(); - _loadEmails({ useCache: true }); - _loadFolders({ resetMissing: true }).catch(() => {}); - page.querySelector('.email-unsubscribe-inline-panel')?.remove(); - const body = page.querySelector('.email-settings-body'); - if (body) body.innerHTML = _emailSettingsLoadingHtml(); - try { - const [nextCfg, nextStyle] = await Promise.all([ - _fetchEmailSettingsConfig(), - _fetchEmailWritingStyle().catch(() => ''), - ]); - if (body) body.innerHTML = _emailWritingStyleHtml(nextStyle) + _emailDisplaySettingsHtml(nextCfg) + _emailCleanupSettingsHtml() + _emailSettingsFormHtml(nextCfg); - state._libAutoReplyActive = _isAutoReplyActiveForCurrentAccount(nextCfg); - state._libAutoReplyDraftActive = null; - _syncEmailAutoReplyTitle(state._libAutoReplyActive); - _renderAccountsStrip(); - _syncAutoReplyCalendarEvent(nextCfg).catch((err) => console.warn('Failed to reconcile auto-reply calendar event:', err)); - _bindEmailSettingsPageControls(page); - } catch (_) { - if (body) body.innerHTML = `
Could not load settings.
`; - } - }); - page.querySelector('.email-settings-default')?.addEventListener('click', async (ev) => { - ev.stopPropagation(); - const acctId = ev.currentTarget.dataset.emailDefaultId; - const account = (state._libAccounts || []).find(a => a && a.id === acctId); - if (!acctId || account?.is_default) return; - try { - const response = await fetch(`${API_BASE}/api/email/accounts/${encodeURIComponent(acctId)}/set-default`, { - method: 'POST', credentials: 'same-origin', - }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - for (const a of state._libAccounts) a.is_default = a.id === acctId; - _syncEmailSettingsAccountPicker(page); - _renderAccountsStrip(); - } catch (err) { - console.error('Set default account failed:', err); - } - }); - page.querySelector('.email-settings-default')?.addEventListener('keydown', (ev) => { - if (ev.key !== 'Enter' && ev.key !== ' ') return; - ev.preventDefault(); - ev.currentTarget.click(); - }); - _bindEmailSettingsPageControls(page); -} - -function _bindEmailSettingsPageControls(page) { - page.querySelector('.email-settings-clean-btn')?.addEventListener('click', (ev) => { - // Clean is an explicit fresh scan action; do not reopen stale cached - // candidates when the user asks to clean again. - _openUnsubscribeReviewModal(ev.currentTarget, { forceRescan: true }); - }); - const showTagsToggle = page.querySelector('#email-settings-show-tags'); - const syncDisplayTagsState = () => { - const section = page.querySelector('.email-settings-display-section'); - const stateLabel = page.querySelector('.email-settings-display-enabled-state'); - const enabled = !!showTagsToggle?.checked; - section?.classList.toggle('is-disabled', !enabled); - section?.classList.toggle('is-enabled', enabled); - if (stateLabel) stateLabel.textContent = enabled ? 'Show' : 'Hide'; - }; - showTagsToggle?.addEventListener('change', (ev) => { - state._libShowTags = !!ev.target.checked; - localStorage.setItem('odysseus.email.showTags', state._libShowTags ? '1' : '0'); - _renderGrid(); - document.dispatchEvent(new CustomEvent('odysseus:email-tags-toggle', { detail: { show: state._libShowTags } })); - if (state._libShowTags && !_loadedEmailsHaveVisibleTags()) _notifyNoLoadedEmailTags(); - syncDisplayTagsState(); - }); - syncDisplayTagsState(); - const inlineImagesToggle = page.querySelector('#email-settings-view-inline-images'); - const syncInlineImagesState = () => { - const section = page.querySelector('.email-settings-inline-images-section'); - const stateLabel = page.querySelector('.email-settings-inline-images-enabled-state'); - const hidden = !!inlineImagesToggle?.checked; - section?.classList.toggle('is-disabled', !hidden); - section?.classList.toggle('is-enabled', hidden); - if (stateLabel) stateLabel.textContent = hidden ? 'Hide' : 'Show'; - }; - inlineImagesToggle?.addEventListener('change', async (ev) => { - const hidden = !!ev.target.checked; - const enabled = !hidden; - const previous = state._libViewInlineImages; - state._libViewInlineImages = enabled; - _writeEmailInlineImagesPreference(enabled); - syncInlineImagesState(); - try { - const res = await fetch(emailApiUrl('/api/email/config', { - account_id: state._libAccountId || undefined, - }), { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ email_view_inline_images: enabled }), - }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - document.querySelectorAll('.email-card-reader').forEach(reader => _wireEmailInlineImages(reader)); - } catch (err) { - state._libViewInlineImages = previous; - _writeEmailInlineImagesPreference(previous); - ev.target.checked = previous === false; - syncInlineImagesState(); - showToast?.('Failed to save inline image setting'); - console.error('Failed to save inline image setting:', err); - } - }); - syncInlineImagesState(); - page.querySelector('[data-open-email-tasks]')?.addEventListener('click', _openTasksForEmailTags); - const autoReplyStart = page.querySelector('#email-auto-reply-start'); - const seedAutoReplyStartDate = () => { - if (autoReplyStart && !autoReplyStart.value) autoReplyStart.value = _todayDateInputValue(); - }; - // Empty native date inputs can open on an arbitrary browser baseline date. - // Seed only the draft field when the picker is opened, leaving saved settings - // unchanged until the user explicitly presses Save. - autoReplyStart?.addEventListener('pointerdown', seedAutoReplyStartDate); - autoReplyStart?.addEventListener('focus', seedAutoReplyStartDate); - const enabledToggle = page.querySelector('#email-auto-reply-enabled'); - const syncEnabledState = () => { - const section = page.querySelector('.email-settings-auto-reply-section'); - const stateLabel = section?.querySelector('.email-settings-enabled-state'); - const enabled = !!enabledToggle?.checked; - section?.classList.toggle('is-disabled', !enabled); - section?.classList.toggle('is-enabled', enabled); - if (stateLabel) stateLabel.textContent = enabled ? 'Enabled' : 'Disabled'; - }; - const saveAutoReplySettings = async ({ quiet = false } = {}) => { - const saveBtn = page.querySelector('.email-settings-save'); - const status = page.querySelector('.email-settings-status'); - if (saveBtn && !quiet) saveBtn.disabled = true; - _setEmailSaveIcon(saveBtn, false); - if (status) { - status.classList.remove('is-success', 'is-error'); - status.textContent = 'Saving…'; - } - const payload = { - email_auto_reply: !!page.querySelector('#email-auto-reply-enabled')?.checked, - email_auto_reply_start: page.querySelector('#email-auto-reply-start')?.value || '', - email_auto_reply_end: page.querySelector('#email-auto-reply-end')?.value || '', - email_auto_reply_subject: page.querySelector('#email-auto-reply-subject')?.value || '', - email_auto_reply_message: page.querySelector('#email-auto-reply-message')?.value || '', - email_auto_reply_cooldown: page.querySelector('#email-auto-reply-cooldown')?.value || 'period', - email_auto_reply_scope: 'account', - email_auto_reply_account_id: state._libAccountId || '', - email_auto_reply_pause_notifications: !!page.querySelector('#email-auto-reply-pause')?.checked, - }; - try { - const res = await fetch(emailApiUrl('/api/email/config', { - account_id: state._libAccountId || undefined, - }), { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify(payload), - }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const savedCfg = { - enabled: !!payload.email_auto_reply, - start: payload.email_auto_reply_start, - end: payload.email_auto_reply_end, - }; - let calendarSynced = true; - try { - await _syncAutoReplyCalendarEvent(savedCfg); - } catch (calendarErr) { - calendarSynced = false; - console.warn('Failed to sync auto-reply calendar event:', calendarErr); - } - if (status) { - status.classList.add('is-success'); - status.textContent = 'Saved'; - } - _setEmailSaveIcon(saveBtn, true); - if (!quiet) showToast?.('Email settings saved'); - if (!calendarSynced) showToast?.('Saved, but the calendar event could not be updated'); - state._libAutoReplyActive = _isAutoReplyActiveForCurrentAccount(savedCfg); - state._libAutoReplyDraftActive = null; - _syncEmailAutoReplyTitle(state._libAutoReplyActive); - _renderAccountsStrip(); - _refreshUnreadBadge({ preserveAutoReplyTitle: true }).catch(() => {}); - setTimeout(() => { _setEmailSaveIcon(saveBtn, false); if (status) status.textContent = ''; }, quiet ? 900 : 1400); - return true; - } catch (err) { - if (status) { - status.classList.add('is-error'); - status.textContent = 'Save failed'; - } - showToast?.('Failed to save email settings'); - return false; - } finally { - if (saveBtn) saveBtn.disabled = false; - } - }; - enabledToggle?.addEventListener('change', () => { - syncEnabledState(); - // Invalidate any config read that started before this toggle. Its stale - // result must not overwrite the title we are about to render. - _autoReplyRefreshSeq += 1; - const active = _isAutoReplyActiveForCurrentAccount({ - email_auto_reply: !!enabledToggle.checked, - email_auto_reply_start: autoReplyStart?.value || '', - email_auto_reply_end: page.querySelector('#email-auto-reply-end')?.value || '', - email_auto_reply_scope: 'account', - email_auto_reply_account_id: state._libAccountId || '', - }); - state._libAutoReplyActive = active; - state._libAutoReplyDraftActive = active; - _syncEmailAutoReplyTitle(active); - _renderAccountsStrip(); - saveAutoReplySettings({ quiet: true }); - }); - syncEnabledState(); - page.querySelector('.email-settings-save')?.addEventListener('click', async () => { - await saveAutoReplySettings(); - }); - page.querySelector('.email-style-settings-save')?.addEventListener('click', async () => { - const saveBtn = page.querySelector('.email-style-settings-save'); - const status = page.querySelector('.email-style-settings-status'); - saveBtn.disabled = true; - _setEmailSaveIcon(saveBtn, false); - if (status) { - status.classList.remove('is-success', 'is-error'); - status.textContent = 'Saving…'; - } - try { - const res = await fetch(emailApiUrl('/api/email/style', { - account_id: state._libAccountId || undefined, - }), { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ style: page.querySelector('#email-writing-style-text')?.value || '' }), - }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - if (status) { - status.classList.add('is-success'); - status.textContent = 'Saved'; - } - _setEmailSaveIcon(saveBtn, true); - showToast?.('Email writing style saved'); - setTimeout(() => { _setEmailSaveIcon(saveBtn, false); if (status) status.textContent = ''; }, 1400); - } catch (err) { - if (status) { - status.classList.add('is-error'); - status.textContent = 'Save failed'; - } - showToast?.('Failed to save email writing style'); - } finally { - saveBtn.disabled = false; - } - }); - page.querySelector('.email-style-settings-extract')?.addEventListener('click', async () => { - const extractBtn = page.querySelector('.email-style-settings-extract'); - const saveBtn = page.querySelector('.email-style-settings-save'); - const status = page.querySelector('.email-style-settings-status'); - const styleEl = page.querySelector('#email-writing-style-text'); - extractBtn.disabled = true; - if (saveBtn) saveBtn.disabled = true; - let wp = null; - if (status) { - status.innerHTML = ''; - try { - wp = spinnerModule.createWhirlpool(14); - wp.element.style.cssText = 'display:inline-block;vertical-align:-3px;margin-right:6px;'; - status.appendChild(wp.element); - status.appendChild(document.createTextNode('Analyzing sent emails…')); - } catch (_) { - status.textContent = 'Analyzing sent emails…'; - } - } - try { - const res = await fetch(emailApiUrl('/api/email/extract-style', { - account_id: state._libAccountId || undefined, - }), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ sample_count: 15 }), - }); - const data = await res.json().catch(() => ({})); - if (!res.ok || !data.success || !data.style) throw new Error(data.error || `HTTP ${res.status}`); - if (styleEl) styleEl.value = data.style; - if (status) status.textContent = 'Extracted'; - showToast?.('Email writing style extracted'); - setTimeout(() => { if (status) status.textContent = ''; }, 1800); - } catch (err) { - if (status) status.textContent = err?.message || 'Extract failed'; - showToast?.('Failed to extract email writing style'); - } finally { - if (wp && wp.destroy) { try { wp.destroy(); } catch (_) {} } - extractBtn.disabled = false; - if (saveBtn) saveBtn.disabled = false; - } - }); -} - -function _hideEmailSettingsPage() { - const modal = document.getElementById('email-lib-modal'); - const page = document.getElementById('email-lib-settings-page'); - const btn = document.getElementById('email-lib-settings-btn'); - const headerBackBtn = document.getElementById('email-settings-header-back'); - modal?.classList.remove('email-settings-mode'); - if (page) page.hidden = true; - if (headerBackBtn) headerBackBtn.style.display = 'none'; - btn?.classList.remove('active'); - btn?.setAttribute('aria-expanded', 'false'); -} - -function _isEmailTypingTarget(t) { - return !!(t && ( - t.tagName === 'INPUT' || - t.tagName === 'TEXTAREA' || - t.tagName === 'SELECT' || - t.isContentEditable - )); -} - -function _selectEmailReaderContents(reader) { - if (!reader || !reader.isConnected) return false; - const hiddenModal = reader.closest('.modal.hidden'); - if (hiddenModal) return false; - const range = document.createRange(); - range.selectNodeContents(reader); - const sel = window.getSelection(); - sel?.removeAllRanges(); - sel?.addRange(range); - return true; -} - -function _markEmailReaderActive(reader) { - if (!reader) return; - _activeEmailReaderForSelectAll = reader; - if (reader.dataset.selectAllWired === '1') return; - reader.dataset.selectAllWired = '1'; - reader.addEventListener('pointerdown', () => { _activeEmailReaderForSelectAll = reader; }, true); - reader.addEventListener('focusin', () => { _activeEmailReaderForSelectAll = reader; }, true); -} - -function _emailReaderLoadErrorHtml(message) { - return ` -
- - - -
`; -} - -function _showEmailReaderLoadError(reader, message, onRetry) { - if (!reader) return; - reader.classList.remove('email-card-reader-loading'); - reader.classList.add('email-card-reader-error'); - reader.style.minHeight = ''; - reader.innerHTML = _emailReaderLoadErrorHtml(message); - const retry = reader.querySelector('.email-reader-retry-btn'); - retry?.addEventListener('click', (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - onRetry?.(); - }); - _markEmailReaderActive(reader); -} - -function _openCalendarEventFromEmail(uid) { - const target = String(uid || '').trim(); - if (!target) return; - import('./calendar.js?v=20260914emailsource11').then(mod => { - const open = mod.openCalendarTo || (mod.default && mod.default.openCalendarTo); - if (open) open(target); - }).catch(() => {}); -} - -function _applyTagFilterFromPill(tag) { - const normalized = String(tag || '').trim().toLowerCase().replace(/_/g, '-'); - if (!normalized || normalized === 'calendar') return; - const value = `filter:tag:${normalized}`; - const existingIdx = Array.isArray(state._libSearchPills) - ? state._libSearchPills.findIndex(p => p?.type === 'filter' && p.value === value) - : -1; - if (existingIdx >= 0) { - _removeSearchPillAt(existingIdx); - return; - } - _addSearchPill({ - type: 'filter', - value, - label: normalized.replace(/-/g, ' '), - }); -} - -document.addEventListener('odysseus:email-filter-tag', (e) => { - _applyTagFilterFromPill(e.detail?.tag); -}); - -function _emailTagPillHtml(tag, em) { - const normalized = String(tag || '').trim().toLowerCase().replace(/_/g, '-'); - if (!normalized) return ''; - const eventUid = normalized === 'calendar' && Array.isArray(em?.calendar_event_uids) - ? String(em.calendar_event_uids[0] || '').trim() - : ''; - if (normalized === 'calendar') { - if (!eventUid) return ''; - return ``; - } - return ``; -} - -function _emailTagGroupHtml(tags, em) { - const visible = (Array.isArray(tags) ? tags : []) - .map(t => _emailTagPillHtml(t, em)) - .filter(Boolean); - if (!visible.length) return ''; - // A lone tag is already the most useful compact representation. Never - // replace it with a misleading "+1" overflow control. - if (visible.length === 1) return visible[0]; - // Two tags still fit as useful labels. Overflow starts at three so the UI - // never renders a low-value "+1" control. - if (visible.length === 2) return visible.join(''); - const extra = visible.slice(1).map(html => `${html}`).join(''); - const additionalCount = visible.length - 1; - const mini = ``; - return `${visible[0]}${extra}${mini}`; -} - -function _fitEmailCardTags(titleRow) { - const tagWrap = titleRow?.querySelector('.email-card-tags'); - const title = titleRow?.querySelector('.memory-item-title'); - const status = titleRow?.querySelector('.email-card-status'); - if (!tagWrap || !title || !status) return; - tagWrap.classList.remove('email-tags-mini'); - tagWrap.style.setProperty('--email-status-width', `${status.offsetWidth}px`); - const minimumTitleWidth = Math.max(96, Math.min(160, titleRow.clientWidth * 0.45)); - const availableForTags = Math.max(0, titleRow.clientWidth - status.offsetWidth - minimumTitleWidth - 12); - const hasOverflowControl = !!tagWrap.querySelector('[data-email-tags-more]'); - tagWrap.classList.toggle('email-tags-mini', hasOverflowControl && tagWrap.scrollWidth > availableForTags); -} - -const _emailTagFitObserver = typeof ResizeObserver === 'function' - ? new ResizeObserver(entries => { - for (const entry of entries) _fitEmailCardTags(entry.target); - }) - : null; - -const _DONE_RESPONSE_TAGS = new Set(['urgent', 'reply-soon', 'action-needed']); - -function _visibleEmailTagsForRender(em) { - const tags = Array.isArray(em?.tags) ? em.tags : []; - if (!em?.is_answered) return tags; - return tags.filter(t => !_DONE_RESPONSE_TAGS.has(String(t || '').trim().toLowerCase().replace(/_/g, '-'))); -} - -function _clearDoneResponseTagsLocal(em) { - if (!em || !Array.isArray(em.tags)) return; - em.tags = em.tags.filter(t => !_DONE_RESPONSE_TAGS.has(String(t || '').trim().toLowerCase().replace(/_/g, '-'))); -} - -function _loadedEmailsHaveVisibleTags() { - return (state._libEmails || []).some(em => - _visibleEmailTagsForRender(em).length > 0 || !!em?.is_spam_verdict - ); -} - -async function _openTasksForEmailTags() { - try { - const mod = await import('./tasks.js?v=20260914taskmodel1'); - const openTasks = mod.openTasks || mod.default?.openTasks; - if (typeof openTasks === 'function') { - openTasks(null, { filter: 'Email', focusAction: 'check_email_urgency' }); - return; - } - } catch (_) {} - document.getElementById('tool-tasks-btn')?.click(); -} - -function _notifyNoLoadedEmailTags() { - const count = (state._libEmails || []).length; - const msg = count - ? `No tags found in the ${count} loaded emails.` - : 'No email tags loaded yet.'; - showToast(`${msg} Turn on email tagging in Tasks.`, { - duration: 6500, - action: 'Open Tasks', - onAction: _openTasksForEmailTags, - }); -} - -// Stash the email identity (uid + folder + account) on the reader element -// so chat submits and other code paths can ask "what email is the user -// currently looking at?" without re-deriving from the DOM hierarchy. -function _stampReaderContext(reader, em, folder, account) { - if (!reader || !em) return; - reader.dataset.emailUid = String(em.uid || ''); - reader.dataset.emailFolder = String(folder || state._libFolder || 'INBOX'); - reader.dataset.emailAccount = String(account || state._libAccountId || ''); - if (em.subject) reader.dataset.emailSubject = String(em.subject); - if (em.from_address || em.from_name) { - reader.dataset.emailFrom = String(em.from_address || em.from_name); - } -} - -// Returns { uid, folder, account, subject, from } for the email the user -// is most likely referring to — the last reader they interacted with, then -// any open reader-modal as a fallback. Returns null when no email reader -// is open. Exported below for chat.js to read on submit. -function _getActiveEmailContext() { - const candidates = []; - if (_activeEmailReaderForSelectAll && _activeEmailReaderForSelectAll.isConnected) { - candidates.push(_activeEmailReaderForSelectAll); - } - // Visible reader-tab modals (popped-out windows). - document.querySelectorAll('.modal[id^="email-reader-"]:not(.hidden):not(.modal-minimized) .email-card-reader').forEach(el => candidates.push(el)); - // Expanded inline reader in the library list. - document.querySelectorAll('#email-lib-modal:not(.hidden) .doclib-card.email-card-expanded .email-card-reader').forEach(el => candidates.push(el)); - for (const r of candidates) { - const uid = r?.dataset?.emailUid; - if (uid) { - return { - uid, - folder: r.dataset.emailFolder || 'INBOX', - account: r.dataset.emailAccount || '', - subject: r.dataset.emailSubject || '', - from: r.dataset.emailFrom || '', - }; - } - } - return null; -} - -// Frontend reads via the global so chat.js doesn't need a separate import -// path (emailLibrary loads lazily in some entry points). -try { window.__odysseusGetActiveEmailContext = _getActiveEmailContext; } catch (_) {} - -const _COPY_EMAIL_ICON = ''; - -function _decodeAttrValue(v) { - const tmp = document.createElement('textarea'); - tmp.innerHTML = v || ''; - return tmp.value; -} - -function _emailAddressFromRecipientText(text) { - const raw = String(text || '').trim(); - const angle = raw.match(/<\s*([^<>@\s]+@[^<>\s]+)\s*>/); - if (angle) return angle[1].trim(); - const any = raw.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i); - return any ? any[0].trim() : raw; -} - -function _splitRecipientList(raw) { - const out = []; - let cur = ''; - let quote = false; - let angle = false; - const s = String(raw || ''); - for (let i = 0; i < s.length; i += 1) { - const ch = s[i]; - if (ch === '"' && s[i - 1] !== '\\') quote = !quote; - else if (ch === '<' && !quote) angle = true; - else if (ch === '>' && !quote) angle = false; - - if (ch === ',' && !quote && !angle) { - const part = cur.trim(); - if (part) out.push(part); - cur = ''; - continue; - } - cur += ch; - } - const tail = cur.trim(); - if (tail) out.push(tail); - return out; -} - -async function _copyTextToClipboard(text) { - const value = String(text || ''); - if (!value) return false; - try { - if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(value); - return true; - } - } catch (_) {} - try { - const ta = document.createElement('textarea'); - ta.value = value; - ta.setAttribute('readonly', ''); - ta.style.position = 'fixed'; - ta.style.left = '-9999px'; - ta.style.top = '0'; - document.body.appendChild(ta); - ta.select(); - const ok = document.execCommand('copy'); - ta.remove(); - return !!ok; - } catch (_) { - return false; - } -} - -function _wireMetaToggle(root) { - const toggle = root && root.querySelector('.email-reader-meta-toggle'); - const details = root && root.querySelector('.email-reader-meta-details'); - if (!toggle || !details) return; - const meta = details.closest('.email-reader-meta'); - toggle.addEventListener('click', (ev) => { - ev.stopPropagation(); - const open = details.hasAttribute('hidden'); - if (open) details.removeAttribute('hidden'); - else details.setAttribute('hidden', ''); - toggle.setAttribute('aria-expanded', String(open)); - toggle.classList.toggle('open', open); - if (meta) meta.classList.toggle('email-reader-meta-expanded', open); - }); -} - -function _recipientMetaToggleHtml(data = {}) { - const ccCount = _splitRecipientList(data.cc || '').length; - if (ccCount) { - return ``; - } - if (!data.to) return ''; - return ``; -} - -function _recipientChipHtml(full, label, extraClass = '') { - const fullText = String(full || '').trim(); - const addr = _emailAddressFromRecipientText(fullText); - const labelText = String(label || addr || fullText || '').trim(); - const cls = `recipient-chip${extraClass ? ` ${extraClass}` : ''}`; - return `${_esc(labelText)}`; -} - -let _recipientChipPopoverCtl = null; -function _closeRecipientChipPopover() { - try { _recipientChipPopoverCtl?.abort(); } catch {} - _recipientChipPopoverCtl = null; - document.querySelector('.recipient-chip-popover')?.remove(); - document.querySelectorAll('.recipient-chip.popover-open').forEach(chip => { - chip.classList.remove('popover-open'); - }); -} - -function _showRecipientChipPopover(chip) { - if (!chip) return false; - _closeRecipientChipPopover(); - const full = _decodeAttrValue(chip.dataset.full || '').trim(); - const email = chip.dataset.email || _emailAddressFromRecipientText(full); - const name = chip.dataset.name || chip.querySelector('.recipient-chip-label')?.textContent?.trim() || ''; - const detail = full || email || name; - if (!detail) return true; - - chip.classList.add('popover-open'); - const pop = document.createElement('div'); - pop.className = 'recipient-chip-popover'; - pop.setAttribute('role', 'dialog'); - pop.setAttribute('aria-label', 'Sender details'); - pop.innerHTML = ` -
- ${name && detail !== name ? `
${_esc(name)}
` : ''} -
${_esc(detail)}
-
- ${email ? `` : ''} - `; - document.body.appendChild(pop); - - const rect = chip.getBoundingClientRect(); - const margin = 10; - const maxLeft = Math.max(margin, window.innerWidth - pop.offsetWidth - margin); - let left = Math.min(Math.max(margin, rect.left), maxLeft); - let top = rect.bottom + 6; - if (top + pop.offsetHeight + margin > window.innerHeight) { - top = Math.max(margin, rect.top - pop.offsetHeight - 6); - } - pop.style.left = `${Math.round(left)}px`; - pop.style.top = `${Math.round(top)}px`; - - const ctl = new AbortController(); - _recipientChipPopoverCtl = ctl; - pop.querySelector('.recipient-chip-popover-copy')?.addEventListener('click', async (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - try { - const copied = await _copyTextToClipboard(email); - if (!copied) throw new Error('copy failed'); - ev.currentTarget.classList.add('copied'); - showToast?.('Email copied'); - setTimeout(_closeRecipientChipPopover, 650); - } catch (_) { - showToast?.('Copy failed'); - } - }, { signal: ctl.signal }); - setTimeout(() => { - document.addEventListener('pointerdown', (ev) => { - if (pop.contains(ev.target) || chip.contains(ev.target)) return; - _closeRecipientChipPopover(); - }, { signal: ctl.signal, capture: true }); - }, 0); - document.addEventListener('keydown', (ev) => { - if (ev.key === 'Escape') _closeRecipientChipPopover(); - }, { signal: ctl.signal }); - window.addEventListener('resize', _closeRecipientChipPopover, { signal: ctl.signal }); - window.addEventListener('scroll', _closeRecipientChipPopover, { signal: ctl.signal, capture: true }); - return true; -} - -function _wireRecipientChips(root) { - if (!root || root.dataset.recipientChipsWired === '1') return; - root.dataset.recipientChipsWired = '1'; - root.addEventListener('click', async (ev) => { - const copyBtn = ev.target.closest?.('.recipient-chip-copy'); - if (copyBtn && root.contains(copyBtn)) { - ev.stopPropagation(); - ev.preventDefault(); - const chip = copyBtn.closest('.recipient-chip'); - const email = chip?.dataset.email || _emailAddressFromRecipientText(_decodeAttrValue(chip?.dataset.full || '')); - if (!email) return; - try { - const copied = await _copyTextToClipboard(email); - if (!copied) throw new Error('copy failed'); - copyBtn.classList.add('copied'); - copyBtn.title = 'Copied'; - showToast?.('Email copied'); - setTimeout(() => { - copyBtn.classList.remove('copied'); - copyBtn.title = 'Copy email'; - }, 900); - } catch (_) { - showToast?.('Copy failed'); - } - return; - } - - const chip = ev.target.closest?.('.recipient-chip'); - if (!chip || !root.contains(chip)) return; - ev.stopPropagation(); - ev.preventDefault(); - if (_showRecipientChipPopover(chip)) return; - const label = chip.querySelector('.recipient-chip-label'); - const copy = chip.querySelector('.recipient-chip-copy'); - if (chip.classList.contains('expanded')) { - chip.classList.remove('expanded'); - if (label) label.textContent = chip.dataset.name || label.textContent; - if (copy) copy.hidden = true; - } else { - if (!chip.dataset.name && label) chip.dataset.name = label.textContent.trim(); - chip.classList.add('expanded'); - const expandedText = _decodeAttrValue(chip.dataset.full || '').trim() - || chip.dataset.name - || chip.dataset.email - || label?.textContent?.trim() - || ''; - if (label && expandedText) label.textContent = expandedText; - if (copy) copy.hidden = false; - } - }); -} - -function _emailReaderForSelectAllTarget(target) { - if (_isEmailTypingTarget(target)) return null; - const direct = target?.closest?.('.email-card-reader, #email-lib-modal .doclib-card.doclib-card-expanded'); - if (direct) return direct.querySelector?.('.email-card-reader') || direct; - const expanded = document.querySelector('#email-lib-modal:not(.hidden) .doclib-card.doclib-card-expanded .email-card-reader'); - if (expanded) return expanded; - return _activeEmailReaderForSelectAll; -} - -document.addEventListener('keydown', (e) => { - if (!(e.ctrlKey || e.metaKey) || String(e.key || '').toLowerCase() !== 'a') return; - const reader = _emailReaderForSelectAllTarget(e.target); - if (!_selectEmailReaderContents(reader)) return; - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation?.(); -}, true); - -function _emailReadContextKey(context) { - return [context.accountId, context.folder, context.uid].map(value => String(value || '')).join('\u0000'); -} - -function _emailReadContextIsCurrent(context) { - if (!context) return true; - return ( - String(state._libAccountId || '') === context.accountId && - String(state._libFolder || 'INBOX') === context.libraryFolder && - _emailMailboxGeneration === context.mailboxGeneration - ); -} - -function _emailMatchesReadContext(email, context) { - if (String(email?.uid || '') !== context.uid) return false; - const accountId = String(email?.account_id || context.accountId); - const folder = String(email?.folder || context.folder); - return accountId === context.accountId && folder === context.folder; -} - -function _syncEmailReadState(uid, isRead = true, context = null) { - if (uid == null) return; - const uidStr = String(uid); - const read = !!isRead; - if (context && (!_emailReadContextIsCurrent(context) || uidStr !== context.uid)) return; - const match = (state._libEmails || []).find(x => ( - context ? _emailMatchesReadContext(x, context) : String(x.uid) === uidStr - )); - if (match) match.is_read = read; - - document.querySelectorAll('.doclib-card[data-uid="' + CSS.escape(uidStr) + '"]').forEach(card => { - if (context && ( - String(card.dataset.emailAccount || '') !== context.accountId || - String(card.dataset.emailFolder || '') !== context.folder - )) return; - 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 navArrows = titleRow.querySelector('.email-card-nav-arrows'); - if (done) done.insertAdjacentElement('afterend', dot); - else if (navArrows) titleRow.insertBefore(dot, navArrows); - else titleRow.appendChild(dot); - }); -} - -function _syncEmailDoneState(uid, isDone = true, context = null) { - if (uid == null) return; - const uidStr = String(uid); - const done = !!isDone; - const match = (state._libEmails || []).find(x => ( - context ? _emailMatchesReadContext(x, context) : String(x.uid) === uidStr - )); - if (match) { - match.is_answered = done; - match.is_done = done; - } - document.querySelectorAll('.doclib-card[data-uid="' + CSS.escape(uidStr) + '"]').forEach(card => { - if (context && ( - String(card.dataset.emailAccount || '') !== context.accountId || - String(card.dataset.emailFolder || '') !== context.folder - )) return; - card.classList.toggle('email-card-answered', done); - const check = card.querySelector('.email-card-done'); - if (check) { - check.classList.toggle('active', done); - check.title = done ? 'Mark not done' : 'Mark done'; - } - if (done) { - card.querySelectorAll('.email-tag-urgent, .email-tag-reply-soon, .email-tag-action-needed').forEach(n => n.remove()); - } - }); -} - -// 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_done = true; - em.is_read = true; - _clearDoneResponseTagsLocal(em); - } - _syncEmailDoneState(uid, 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'); - card.querySelectorAll('.email-tag-urgent, .email-tag-reply-soon, .email-tag-action-needed').forEach(n => n.remove()); - 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'; - _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; - _syncSearchOptionsMenu(); - _renderSearchPills(); - _loadEmailsFresh(); -} - -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 _syncCurrentAccountUnreadCount(count) { - const accountId = String(state._libAccountId || ''); - if (!accountId) return; - const nextCount = Math.max(0, Number(count) || 0); - const prev = _accountUnreadState.get(accountId) || {}; - _accountUnreadState.set(accountId, { - ...prev, - unreadCount: nextCount, - }); - _renderAccountsStrip(); -} - -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 _syncSearchOptionsMenu() { - const btn = document.getElementById('email-search-options-btn'); - const menu = document.getElementById('email-search-options-menu'); - const hasDate = !!(state._libDateFrom || state._libDateTo); - const hasActive = state._libFilter === 'undone' || state._libFilter === 'reminders' || !!state._libHasAttachments || hasDate; - btn?.classList.toggle('active', hasActive); - menu?.querySelector('[data-email-search-option="undone"]')?.classList.toggle('active', state._libFilter === 'undone'); - menu?.querySelector('[data-email-search-option="reminders"]')?.classList.toggle('active', state._libFilter === 'reminders'); - menu?.querySelector('[data-email-search-option="attachments"]')?.classList.toggle('active', !!state._libHasAttachments); - menu?.querySelector('[data-email-search-option="date"]')?.classList.toggle('active', hasDate); -} - -function _setEmailListFilter(value) { - const filterEl = document.getElementById('email-lib-filter'); - state._libFilter = state._libFilter === value ? 'all' : value; - if (filterEl) filterEl.value = state._libFilter; - _syncUnreadWindowGlow(); - _syncReminderClearButton(); - _renderFilterPickerCurrent(); - _syncSearchOptionsMenu(); - _renderSearchPills(); - _loadEmailsFresh(); -} - -function _toggleEmailAttachmentFilter() { - state._libHasAttachments = !state._libHasAttachments; - _syncReminderClearButton(); - _syncSearchOptionsMenu(); - _renderSearchPills(); - _loadEmailsFresh(); -} - -function _renderAccountsLoading() { - const strip = document.getElementById('email-lib-accounts'); - if (!strip) return; - strip.style.display = 'flex'; - strip.innerHTML = ''; - try { - const wp = spinnerModule.createWhirlpool(14); - wp.element.classList.add('email-accounts-loading-whirlpool'); - strip.appendChild(wp.element); - } catch (_) {} -} - -function _syncEmailReminderBellVisibility(enabled) { - const item = document.querySelector('#email-search-options-menu [data-email-search-option="reminders"]'); - item?.classList.toggle('hidden', !enabled); - _syncSearchOptionsMenu(); -} - -async function _loadEmailReminderBellVisibility() { - try { - const settings = await getSettings(); - _syncEmailReminderBellVisibility(settings.reminder_channel === 'email'); - } catch (_) { - _syncEmailReminderBellVisibility(false); - } -} -// Live-update the bell when the reminder channel changes in Settings, -// so the user doesn't have to reopen Email to see the change apply. -window.addEventListener('odysseus-reminder-channel-changed', (e) => { - const ch = e?.detail?.channel; - _syncEmailReminderBellVisibility(ch === 'email'); -}); - -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)); -} - -// Compute the left-edge x assuming the wide sidebar has collapsed to the -// rail. Used by the "try collapsing the sidebar first" path so we can decide -// whether collapsing recovers enough room before minimizing email. -function _emailSplitLeftEdgeIfSidebarCollapsed() { - return _readCssPx('--icon-rail-w'); -} - -function _hasDesktopRoomForEmailAndDocument(modal, opts = {}) { - 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)); - // Relaxed thresholds — the old 560 + 72 forced an unnecessary tab-down - // on ~1200–1300px viewports where there was visually plenty of room. - const docMinWidth = 460; - const breathingRoom = 40; - const leftEdgeNow = isFullscreen ? _emailSplitLeftEdge() : Math.max(0, Math.round(rect?.left || _emailSplitLeftEdge())); - const leftEdge = opts.assumeSidebarCollapsed ? _emailSplitLeftEdgeIfSidebarCollapsed() : leftEdgeNow; - return (window.innerWidth - leftEdge - emailWidth) >= (docMinWidth + breathingRoom); -} - -function _prepareEmailWindowForDocument(modal) { - if (window.innerWidth <= 768) return true; - if (!modal) return false; - // Try to make breathing room by collapsing the wide sidebar to the rail - // when there isn't enough horizontal space for both panes. The - // route-collapse marker that collapseSidebarToRail() sets means the - // sidebar will auto-restore when the doc closes. Crucially, we no - // longer fall back to clearing the split when even that isn't enough — - // the user opted out of auto-tab-down, so we proceed with the dock - // even if it's cramped. - if (!_hasDesktopRoomForEmailAndDocument(modal)) { - const sidebar = document.getElementById('sidebar'); - const sidebarWasOpen = sidebar && !sidebar.classList.contains('hidden'); - if (sidebarWasOpen && _hasDesktopRoomForEmailAndDocument(modal, { assumeSidebarCollapsed: true })) { - try { collapseSidebarToRail(); } catch (_) {} - } - } - 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 busy = _showEmailDeleteOverlay(card); - await busy?.ready; - 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 { - const response = await fetch(`${API_BASE}/api/email/delete/${encodeURIComponent(em.uid)}?${_emailMutationQuery(em)}`, { method: 'DELETE' }); - await _requireSuccessfulEmailMutation(response, 'Failed to delete email'); - } catch (err) { - console.error('Failed to delete email:', err); - busy?.remove?.(); - showToast('Failed to delete email'); - return; - } - busy?.remove?.(); - 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 _showEmailDeleteOverlay(target) { - if (!target) return null; - const wp = spinnerModule.createWhirlpool(18); - const overlay = document.createElement('div'); - overlay.className = 'email-delete-overlay'; - overlay.appendChild(wp.element); - const prevPos = target.style.position; - const prevPointerEvents = target.style.pointerEvents; - if (getComputedStyle(target).position === 'static') target.style.position = 'relative'; - target.style.pointerEvents = 'none'; - target.classList.add('email-delete-busy'); - target.appendChild(overlay); - const ready = new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); - return { - ready, - remove() { - try { wp.destroy?.(); } catch (_) {} - overlay.remove(); - target.classList.remove('email-delete-busy'); - target.style.pointerEvents = prevPointerEvents; - target.style.position = prevPos; - } - }; -} - -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); - }); -} - - -function _selectedEmailExportPayload() { - const selected = new Set(Array.from(state._selectedUids || []).map(uid => String(uid))); - return (state._libEmails || []) - .filter(em => selected.has(String(em.uid))) - .map(em => ({ - uid: String(em.uid || ''), - folder: String(em.folder || state._libFolder || 'INBOX'), - account_id: String(em.account_id || state._libAccountId || ''), - subject: String(em.subject || ''), - date: (() => { - const parsed = em.date_epoch ? new Date(Number(em.date_epoch) * 1000) : new Date(em.date || ''); - return Number.isFinite(parsed.getTime()) ? parsed.toISOString().slice(0, 10) : ''; - })(), - })) - .filter(em => em.uid); -} - -function _downloadBlob(blob, filename) { - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename || 'selected-email-attachments.zip'; - document.body.appendChild(a); - a.click(); - a.remove(); - setTimeout(() => URL.revokeObjectURL(url), 1500); -} - -function _filenameFromContentDisposition(header) { - const value = String(header || ''); - const utf = value.match(/filename\*=UTF-8''([^;]+)/i); - if (utf) { - try { return decodeURIComponent(utf[1]); } catch (_) {} - } - const quoted = value.match(/filename="([^"]+)"/i); - if (quoted) return quoted[1]; - const bare = value.match(/filename=([^;]+)/i); - return bare ? bare[1].trim() : ''; -} - -async function _exportSelectedAttachments() { - const messages = _selectedEmailExportPayload(); - if (!messages.length) { - showToast('Select emails first'); - return; - } - const actionsBtn = document.getElementById('email-lib-bulk-actions'); - const deleteBtn = document.getElementById('email-lib-bulk-delete'); - const cancelBtn = document.getElementById('email-lib-bulk-cancel'); - const selectAll = document.getElementById('email-lib-select-all'); - const countEl = document.getElementById('email-lib-selected-count'); - const originalActionsHtml = actionsBtn?.innerHTML || ''; - const originalCountText = countEl?.textContent || ''; - let busySpinner = null; - try { - if (actionsBtn) { - actionsBtn.disabled = true; - actionsBtn.classList.add('email-bulk-loading'); - actionsBtn.innerHTML = 'Exporting'; - busySpinner = spinnerModule.create('', 'clean', 'whirlpool'); - const spEl = busySpinner.createElement(); - spEl.classList.add('email-bulk-whirlpool'); - actionsBtn.appendChild(spEl); - busySpinner.start(); - } - if (deleteBtn) deleteBtn.disabled = true; - if (cancelBtn) cancelBtn.disabled = true; - if (selectAll) selectAll.disabled = true; - if (countEl) countEl.textContent = `Exporting attachments from ${messages.length}…`; - const res = await fetch(`${API_BASE}/api/email/attachments-download-bulk`, { - method: 'POST', - credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - messages, - category: String(state._libFilter || '').replace(/^tag:/, ''), - }), - }); - if (!res.ok) { - let detail = ''; - try { - const data = await res.json(); - detail = data?.detail || data?.error || ''; - } catch (_) {} - throw new Error(detail || `HTTP ${res.status}`); - } - const blob = await res.blob(); - const filename = _filenameFromContentDisposition(res.headers.get('Content-Disposition')) || 'selected-email-attachments.zip'; - _downloadBlob(blob, filename); - showToast(`Exported attachments from ${messages.length} selected email${messages.length === 1 ? '' : 's'}`); - } catch (err) { - console.error('Failed to export selected email attachments:', err); - showToast(err?.message || 'Failed to export attachments'); - } finally { - if (busySpinner) busySpinner.destroy(); - if (actionsBtn) { - actionsBtn.disabled = false; - actionsBtn.classList.remove('email-bulk-loading'); - actionsBtn.innerHTML = originalActionsHtml || actionsBtn.innerHTML; - } - if (deleteBtn) deleteBtn.disabled = false; - if (cancelBtn) cancelBtn.disabled = false; - if (selectAll) selectAll.disabled = false; - if (countEl) countEl.textContent = originalCountText; - _updateBulkBar(); - } -} - -// 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 _openGlobalEmailSettings(focus) { - const host = document.getElementById('settings-email-preferences'); - if (host && focus) host.dataset.emailSettingsFocus = focus; - _openSettingsTab('email'); - setTimeout(() => { - const section = focus === 'show-tags' - ? document.querySelector('#settings-email-preferences .email-settings-display-section') - : document.querySelector(`#settings-email-preferences .email-settings-${focus || 'style'}-section`); - if (section) { - section.scrollIntoView?.({ block: 'nearest' }); - } - }, 0); -} - -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)}` : ''; -} - -function _emailMutationQuery(em, fallbackFolder = state._libFolder) { - const folder = String(em?.folder || fallbackFolder || 'INBOX'); - const accountId = em?.account_id || state._libAccountId || ''; - const params = new URLSearchParams({ folder }); - if (accountId) params.set('account_id', String(accountId)); - if (em?.message_id) params.set('message_id', String(em.message_id)); - return params.toString(); -} - -async function _requireSuccessfulEmailMutation(response, fallback = 'Email operation failed') { - const data = await response.json().catch(() => null); - if (!response.ok || data?.success !== true) { - throw new Error(data?.error || `${fallback} (${response.status})`); - } - return data; -} - -function _unsubscribeMethodLabel(method) { - if (!method) return 'No unsubscribe method'; - if (method.kind === 'mailto') return 'Request Unsubscribe'; - if (method.kind === 'url') return 'Link Unsubscribe'; - return method.target || method.kind || 'Unsubscribe'; -} - -function _unsubscribeFolderLabel(folder) { - const value = String(folder || 'INBOX').trim(); - return value.toUpperCase() === 'INBOX' ? 'Inbox' : value; -} - -function _setUnsubButtonBusy(btn, label) { - if (!btn) return null; - const previous = btn.innerHTML; - const previousDisplay = btn.style.display; - const previousAlignItems = btn.style.alignItems; - const previousJustifyContent = btn.style.justifyContent; - const previousGap = btn.style.gap; - const previousWhiteSpace = btn.style.whiteSpace; - btn.disabled = true; - btn.style.display = 'inline-flex'; - btn.style.alignItems = 'center'; - btn.style.justifyContent = 'center'; - btn.style.gap = '5px'; - btn.style.whiteSpace = 'nowrap'; - btn.innerHTML = ''; - const sp = spinnerModule.createWhirlpool(14); - sp.element.style.position = 'relative'; - sp.element.style.top = '-2px'; - sp.element.style.flexShrink = '0'; - sp.element.style.margin = '0'; - sp.element.style.display = 'inline-flex'; - btn.appendChild(sp.element); - const text = document.createElement('span'); - text.textContent = label || 'Working'; - text.className = 'email-unsub-busy-label'; - text.style.whiteSpace = 'nowrap'; - text.style.display = 'inline-block'; - btn.appendChild(text); - const restore = () => { - btn.disabled = false; - btn.innerHTML = previous; - btn.style.display = previousDisplay; - btn.style.alignItems = previousAlignItems; - btn.style.justifyContent = previousJustifyContent; - btn.style.gap = previousGap; - btn.style.whiteSpace = previousWhiteSpace; - }; - restore.setLabel = (next) => { text.textContent = next || label || 'Working'; }; - return restore; -} - -const _UNSUB_EMAIL_ICON = ''; -const _UNSUB_CHECK_ICON = ''; -const _UNSUB_CLOSE_ICON = ''; -const _UNSUB_AGENT_ICON = ''; -const _UNSUB_TRASH_ICON = ''; -const _UNSUB_REFRESH_ICON = ''; -const _UNSUB_EXTERNAL_ICON = ''; - -function _setUnsubStatusBusy(statusEl, label) { - if (!statusEl) return null; - statusEl.classList.remove('is-error'); - statusEl.classList.add('is-busy'); - statusEl.innerHTML = ''; - statusEl.style.display = 'inline-flex'; - statusEl.style.alignItems = 'center'; - statusEl.style.gap = '6px'; - statusEl.style.justifyContent = 'flex-end'; - statusEl.style.width = '100%'; - const text = document.createElement('span'); - text.textContent = label || 'Scanning…'; - statusEl.appendChild(text); - const sp = spinnerModule.createWhirlpool(14); - sp.element.style.position = 'relative'; - sp.element.style.top = '0'; - sp.element.style.flexShrink = '0'; - sp.element.style.margin = '0'; - sp.element.style.display = 'inline-flex'; - statusEl.appendChild(sp.element); - return (next) => { - statusEl.style.display = ''; - statusEl.style.alignItems = ''; - statusEl.style.gap = ''; - statusEl.style.justifyContent = ''; - statusEl.style.width = ''; - statusEl.classList.remove('is-busy'); - statusEl.textContent = next || ''; - }; -} - -function _askAgentToUnsubscribe(candidate) { - const methods = Array.isArray(candidate?.methods) ? candidate.methods : []; - const recommended = candidate?.recommended_method; - const method = (recommended && typeof recommended === 'object') - ? recommended - : methods.find(m => m.kind === 'url') || methods[0] || null; - const url = method?.kind === 'url' ? method.target : ''; - const methodIndex = Math.max(0, methods.findIndex(m => ( - m === method || (m?.kind === method?.kind && m?.target === method?.target) - ))); - const uid = candidate?.uid || ''; - const reviewedUids = _unsubscribeCandidateUids(candidate); - const folder = candidate?.folder || state._libFolder || 'INBOX'; - const account = state._libAccountId || ''; - const prompt = url - ? `The user explicitly clicked Agent Unsubscribe for this reviewed email. Use the private_browser tool to open this exact unsubscribe URL and complete only the unsubscribe flow. Do not use web_search, web_fetch, bash, or scan_email_unsubscribes. After the page confirms a successful unsubscribe, use bulk_email action=delete on these exact reviewed UID(s) so they are not rediscovered. If the page is ambiguous or unsubscribe fails, stop and report that clearly so the user can choose Spam + delete; do not delete a different message.\n\nEmail UID(s): ${reviewedUids.join(', ') || uid}\nFolder: ${folder}\nAccount: ${account || '(default)'}\nExact unsubscribe URL: ${url}` - : `The user explicitly clicked Agent Unsubscribe for this reviewed email. Use unsubscribe_email directly for the already-reviewed candidate below. Do not call scan_email_unsubscribes again.\n\nEmail UID: ${uid}\nFolder: ${folder}\nAccount: ${account || '(default)'}\nReviewed method_index: ${methodIndex}`; - return _submitAgentUnsubscribePrompt(prompt); -} - -function _submitAgentUnsubscribePrompt(prompt) { - const input = document.getElementById('message') || document.getElementById('message-input'); - const form = document.getElementById('chat-form'); - if (!input || !form) { - showToast?.('Chat composer not found'); - return false; - } - input.value = prompt; - input.dispatchEvent(new Event('input', { bubbles: true })); - input.focus(); - // Match the working slash-command path. The app-level submit guard can - // still be settling when this is called from a modal button. - setTimeout(() => { - if (!document.body.contains(input) || !document.body.contains(form)) return; - try { - form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); - } catch (err) { - console.error('agent unsubscribe submit failed', err); - showToast?.('Could not start agent unsubscribe'); - } - }, 350); - return true; -} - -function _agentUnsubscribeGroups(candidates) { - const groups = new Map(); - (candidates || []).forEach(candidate => { - const urlMethod = (candidate?.methods || []).find(method => method?.kind === 'url'); - const sender = String(candidate?.from_address || '').trim(); - const key = sender ? `sender:${sender.toLowerCase()}` : `uid:${_unsubscribeCandidateUids(candidate).join(',')}`; - let group = groups.get(key); - if (!group) { - group = { - subject: String(candidate?.subject || '(no subject)'), - sender, - uids: [], - url: String(urlMethod?.target || ''), - subjects: [], - }; - groups.set(key, group); - } - _unsubscribeCandidateUids(candidate).forEach(uid => { - if (!group.uids.includes(uid)) group.uids.push(uid); - }); - const subject = String(candidate?.subject || '').trim(); - if (subject && !group.subjects.includes(subject)) group.subjects.push(subject); - if (!group.url && urlMethod?.target) group.url = String(urlMethod.target); - }); - return Array.from(groups.values()); -} - -function _askAgentToUnsubscribeAll(candidates) { - const reviewed = _agentUnsubscribeGroups(candidates) - .filter(item => item.uids.length || item.url); - if (!reviewed.length) { - showToast?.('No remaining unsubscribe candidates'); - return false; - } - const account = state._libAccountId || ''; - const lines = reviewed.map((item, index) => ( - `${index + 1}. ${item.sender || '(unknown sender)'} | ${item.subjects.slice(0, 3).join(' / ') || item.subject} | UID(s): ${item.uids.join(', ') || '(none)'} | URL: ${item.url || '(none)'}` - )).join('\n'); - const prompt = `The user explicitly chose Agent Unsubscribe All for these already-reviewed unsubscribe candidates. They are grouped by sender: open at most one unsubscribe flow per sender and never repeat an unsubscribe attempt for the same sender. Use the private_browser tool for each exact unsubscribe URL. Do not use web_search, web_fetch, bash, or scan_email_unsubscribes. If a page is ambiguous or unsubscribe fails, skip that sender and report it. After a successful unsubscribe, use bulk_email action=delete only for that sender group's exact reviewed UID(s), so those messages are not rediscovered. Do not delete any other messages. Report successful, skipped, and failed senders clearly.\n\nFolder: ${state._libFolder || 'INBOX'}\nAccount: ${account || '(default)'}\n\nReviewed sender groups (${reviewed.length}):\n${lines}`; - return _submitAgentUnsubscribePrompt(prompt); -} - -function _unsubscribeCandidateUids(candidate) { - const out = []; - const seen = new Set(); - const add = (uid) => { - const val = String(uid || '').trim(); - if (!val || seen.has(val)) return; - seen.add(val); - out.push(val); - }; - add(candidate?.uid); - (candidate?.duplicate_uids || []).forEach(add); - return out; -} - -function _unsubscribeHandledStorageKey() { - return `odysseus.email.unsubscribeHandled.${String(state._libAccountId || 'default')}`; -} - -function _unsubscribeScanStorageKey() { - const account = String(state._libAccountId || 'default'); - const folder = String(state._libFolder || 'INBOX'); - return `odysseus.email.unsubscribeScan.v2.${account}.${folder}`; -} - -function _readUnsubscribeScanCache() { - try { - const parsed = JSON.parse(localStorage.getItem(_unsubscribeScanStorageKey()) || 'null'); - return parsed && Array.isArray(parsed.candidates) ? parsed : null; - } catch (_) { - return null; - } -} - -function _writeUnsubscribeScanCache(data, scanLimit) { - if (!data || !Array.isArray(data.candidates)) return; - try { - localStorage.setItem(_unsubscribeScanStorageKey(), JSON.stringify({ - candidates: data.candidates, - scanned: Number(data.scanned || 0), - raw_total: Number(data.raw_total || data.candidates.length || 0), - scanLimit: scanLimit == null ? 180 : Number(scanLimit), - scanMode: Number(scanLimit || 0) > 0 ? 'limited' : 'whole-folder', - savedAt: Date.now(), - })); - } catch (_) {} -} - -function _readHandledUnsubscribeState() { - try { - const parsed = JSON.parse(localStorage.getItem(_unsubscribeHandledStorageKey()) || '{}'); - return { - uids: new Set(Array.isArray(parsed.uids) ? parsed.uids.map(String) : []), - senders: new Set(Array.isArray(parsed.senders) ? parsed.senders.map(v => String(v).toLowerCase()) : []), - ignoredUids: new Set(Array.isArray(parsed.ignoredUids) ? parsed.ignoredUids.map(String) : []), - ignoredSenders: new Set(Array.isArray(parsed.ignoredSenders) ? parsed.ignoredSenders.map(v => String(v).toLowerCase()) : []), - }; - } catch (_) { - return { uids: new Set(), senders: new Set(), ignoredUids: new Set(), ignoredSenders: new Set() }; - } -} - -function _rememberUnsubscribeHandled(candidates) { - const current = _readHandledUnsubscribeState(); - (candidates || []).forEach(candidate => { - _unsubscribeCandidateUids(candidate).forEach(uid => current.uids.add(String(uid))); - const sender = String(candidate?.from_address || '').trim().toLowerCase(); - if (sender) current.senders.add(sender); - }); - try { - localStorage.setItem(_unsubscribeHandledStorageKey(), JSON.stringify({ - uids: Array.from(current.uids).slice(-2000), - senders: Array.from(current.senders).slice(-500), - ignoredUids: Array.from(current.ignoredUids).slice(-2000), - ignoredSenders: Array.from(current.ignoredSenders).slice(-500), - })); - } catch (_) {} -} - -function _rememberUnsubscribeIgnored(candidate) { - const current = _readHandledUnsubscribeState(); - _unsubscribeCandidateUids(candidate).forEach(uid => current.ignoredUids.add(String(uid))); - const sender = String(candidate?.from_address || '').trim().toLowerCase(); - if (sender) current.ignoredSenders.add(sender); - try { - localStorage.setItem(_unsubscribeHandledStorageKey(), JSON.stringify({ - uids: Array.from(current.uids).slice(-2000), - senders: Array.from(current.senders).slice(-500), - ignoredUids: Array.from(current.ignoredUids).slice(-2000), - ignoredSenders: Array.from(current.ignoredSenders).slice(-500), - })); - } catch (_) {} -} - -function _filterHandledUnsubscribeCandidates(candidates) { - const handled = _readHandledUnsubscribeState(); - return (candidates || []).flatMap(candidate => { - const sender = String(candidate?.from_address || '').trim().toLowerCase(); - if (sender && (handled.senders.has(sender) || handled.ignoredSenders.has(sender))) return []; - const remainingUids = _unsubscribeCandidateUids(candidate) - .filter(uid => !handled.uids.has(String(uid)) && !handled.ignoredUids.has(String(uid))); - if (!remainingUids.length) return []; - if (remainingUids.length === _unsubscribeCandidateUids(candidate).length) return [candidate]; - return [{ - ...candidate, - uid: remainingUids[0], - duplicate_uids: remainingUids.slice(1), - duplicate_count: remainingUids.length, - }]; - }); -} - -function _dedupeUnsubscribeCandidatesForDisplay(candidates) { - const out = []; - const seen = new Map(); - (candidates || []).forEach(c => { - const method = c?.recommended_method || {}; - const urlMethod = (c?.methods || []).find(m => m?.kind === 'url'); - // One sender address should produce one review action, even when every - // message carries a different tokenized unsubscribe URL. - const sender = String(c?.from_address || '').trim().toLowerCase(); - const key = sender || String(c?.list_id || urlMethod?.target || method.target || c?.uid || '').trim().toLowerCase(); - if (!key || !seen.has(key)) { - if (key) seen.set(key, c); - out.push(c); - return; - } - const existing = seen.get(key); - existing.duplicate_count = Number(existing.duplicate_count || 1) + Number(c.duplicate_count || 1); - const uidSet = new Set(_unsubscribeCandidateUids(existing)); - _unsubscribeCandidateUids(c).forEach(uid => uidSet.add(uid)); - existing.duplicate_uids = Array.from(uidSet); - }); - return out; -} - -function _markUnsubscribeCardDone(modal, idx, label = 'Unsubscribed') { - const card = modal?.querySelector?.(`.email-unsub-card[data-idx="${idx}"]`); - if (!card) return; - card.classList.add('is-unsubscribed'); - if (!card.querySelector('.email-unsub-done-badge')) { - const badgeHost = card.querySelector('.email-unsub-subject-line') || card.querySelector('.email-unsub-card-top'); - badgeHost?.insertAdjacentHTML('beforeend', `${_UNSUB_CHECK_ICON}${_esc(label)}`); - } - card.querySelectorAll('.email-unsub-link-btn, .email-unsub-agent-btn, .email-unsub-send-btn, .email-unsub-done-btn, .email-unsub-ignore-btn').forEach(el => { - if (el.tagName === 'A') { - el.setAttribute('aria-disabled', 'true'); - el.style.pointerEvents = 'none'; - } else { - el.disabled = true; - } - el.style.opacity = '0.55'; - }); -} - -function _agentToolArgs(data) { - const candidates = [data?.args, data?.tool_args, data?.command]; - for (const value of candidates) { - if (value && typeof value === 'object' && !Array.isArray(value)) return value; - if (typeof value !== 'string' || !value.trim()) continue; - try { - const parsed = JSON.parse(value); - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed; - } catch (_) { - // Some tool cards prefix the JSON with a display label. Recover the - // object so email mutations still reconcile from the streamed event. - const start = value.indexOf('{'); - const end = value.lastIndexOf('}'); - if (start >= 0 && end > start) { - try { - const parsed = JSON.parse(value.slice(start, end + 1)); - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed; - } catch (_) {} - } - } - } - return {}; -} - -function _agentBrowserUnsubscribeSucceeded(data) { - const tool = String(data?.tool || '').toLowerCase(); - if (!tool.includes('private_browser')) return false; - const output = String(data?.output || ''); - if (/\b(?:not|never|failed|unable|could not|cannot|can't)\b[^.!?\n]{0,50}\bunsubscrib/i.test(output)) { - return false; - } - return /\b(?:already\s+unsubscribed|you(?:'re|\s+are)\s+(?:now\s+)?unsubscribed|successfully\s+unsubscribed|unsubscribed\s+(?:successfully|from)|unsubscription\s+(?:successful|complete)|removed\s+from\s+(?:the\s+)?(?:mailing|email)\s+list)\b/i.test(output); -} - -function _agentBrowserCandidate(run, args) { - const url = String(args?.url || '').trim(); - if (url && Array.isArray(run?.candidates)) { - const matched = run.candidates.find(candidate => ( - (candidate?.methods || []).some(method => method?.kind === 'url' && String(method.target || '') === url) - )); - if (matched) return matched; - } - return run?.activeCandidate || null; -} - -function _trackAgentBrowserUnsubscribe(data) { - const modal = document.getElementById('email-unsubscribe-review-modal'); - const run = modal?._agentUnsubscribeRun; - if (!run) return; - const args = _agentToolArgs(data); - const action = String(args.action || data?.action || '').toLowerCase(); - const candidate = _agentBrowserCandidate(run, args); - if (action === 'open' && candidate) run.activeCandidate = candidate; - if (!candidate || !_agentBrowserUnsubscribeSucceeded(data)) return; - run.cleanupInFlight ||= new Set(); - if (run.cleanupInFlight.has(candidate) || run.completed.has(candidate)) return; - run.cleanupInFlight.add(candidate); - _deleteAfterUnsubscribe([candidate]).then(result => { - if (Number(result?.failed || 0) > 0 || Number(result?.changed || 0) <= 0) { - run.failed ||= new Set(); - run.failed.add(candidate); - run.restoreBusy?.setLabel?.(`Unsubscribed, delete failed (${run.completed.size} / ${run.total})`); - modal.querySelector('.email-unsub-status')?.replaceChildren(document.createTextNode( - 'Unsubscribe succeeded, but the matching email could not be moved to Trash. Review it below.', - )); - return; - } - const idx = run.candidates.indexOf(candidate); - run.completed.add(candidate); - _markUnsubscribeCardDone(modal, idx, 'Unsubscribed'); - run.restoreBusy?.setLabel?.(`Unsubscribed ${run.completed.size} / ${run.total}`); - }).catch(error => { - run.failed ||= new Set(); - run.failed.add(candidate); - run.restoreBusy?.setLabel?.(`Unsubscribed, delete failed (${run.completed.size} / ${run.total})`); - modal.querySelector('.email-unsub-status')?.replaceChildren(document.createTextNode( - `Unsubscribe succeeded, but delete failed: ${error?.message || 'mail server error'}`, - )); - }); -} - -function _agentDeletedEmailUids(data) { - const tool = String(data?.tool || '').toLowerCase(); - const args = _agentToolArgs(data); - const action = String(args.action || data.action || '').toLowerCase(); - const output = String(data?.output || '').toLowerCase(); - const failed = /\b(?:failed|error|could not|unable|not found)\b/.test(output); - const isBulkDelete = tool.includes('bulk_email') - && ['delete', 'trash', 'move_to_trash'].includes(action); - const isSingleDelete = tool.endsWith('delete_email') - && !tool.includes('bulk_email'); - const isSuccessfulMailtoUnsubscribe = tool.includes('unsubscribe_email') - && !failed - && /(?:source email moved to trash|deleted|unsubscribe email sent)/.test(output); - if (!isBulkDelete && !isSingleDelete && !isSuccessfulMailtoUnsubscribe) return []; - if ((isBulkDelete || isSingleDelete) && failed) return []; - const values = [ - ...(Array.isArray(args.uids) ? args.uids : []), - ...(Array.isArray(data.deleted_uids) ? data.deleted_uids : []), - ...(Array.isArray(data.cleaned_uids) ? data.cleaned_uids : []), - ]; - if (args.uid != null) values.push(args.uid); - if (data.uid != null) values.push(data.uid); - return Array.from(new Set(values.map(uid => String(uid || '').trim()).filter(Boolean))); -} - -function _handleAgentEmailToolOutput(event) { - const data = event?.detail || {}; - _trackAgentBrowserUnsubscribe(data); - const ok = data.exit_code == null ? data.success !== false : data.exit_code === 0; - if (!ok) return; - const deletedUids = new Set(_agentDeletedEmailUids(data)); - if (!deletedUids.size) return; - - state._libEmails = (state._libEmails || []).filter(email => !deletedUids.has(String(email?.uid || ''))); - try { _libCacheWriteBack(); } catch (_) {} - try { _renderGrid(); } catch (_) {} - - const modal = document.getElementById('email-unsubscribe-review-modal'); - const completed = []; - (modal?._unsubscribeCandidates || []).forEach((candidate, idx) => { - const remaining = _unsubscribeCandidateUids(candidate) - .filter(uid => !deletedUids.has(String(uid))); - if (remaining.length === _unsubscribeCandidateUids(candidate).length) return; - if (!remaining.length) { - completed.push(candidate); - _markUnsubscribeCardDone(modal, idx, 'Agent done'); - return; - } - candidate.uid = remaining[0]; - candidate.duplicate_uids = remaining.slice(1); - candidate.duplicate_count = remaining.length; - }); - const run = modal?._agentUnsubscribeRun; - if (run && completed.length) { - completed.forEach(candidate => run.completed.add(candidate)); - run.restoreBusy?.setLabel?.(`Unsubscribed ${run.completed.size} / ${run.total}`); - } - if (completed.length) _rememberUnsubscribeHandled(completed); - const visible = (modal?._unsubscribeCandidates || []).filter(candidate => ( - _unsubscribeCandidateUids(candidate).some(uid => !deletedUids.has(String(uid))) - )); - if (modal && !visible.length) { - if (run) { - run.restoreBusy?.setLabel?.(`Unsubscribed ${run.total} / ${run.total}`); - run.restoreBusy = null; - modal._agentUnsubscribeRun = null; - } - modal.querySelector('.email-unsub-list')?.style.setProperty('display', 'none'); - modal.querySelector('.email-unsub-followup')?.remove(); - modal.querySelector('.email-unsub-status')?.replaceChildren(document.createTextNode('Agent unsubscribe cleanup completed.')); - } -} - -window.addEventListener('odysseus:agent-tool-output', _handleAgentEmailToolOutput); - -function _handleAgentUnsubscribeTerminal(event) { - const modal = document.getElementById('email-unsubscribe-review-modal'); - const run = modal?._agentUnsubscribeRun; - if (!run) return; - const detail = event?.detail?.data || {}; - (Array.isArray(detail.tool_events) ? detail.tool_events : []).forEach(_trackAgentBrowserUnsubscribe); - const completed = run.completed.size; - const remaining = Math.max(0, run.total - completed); - const failure = detail.failure || (detail.failed ? { message: 'The agent run failed before all unsubscribe flows completed.' } : null); - run.restoreBusy?.(); - modal._agentUnsubscribeRun = null; - modal.querySelector('.email-unsub-status')?.replaceChildren(document.createTextNode( - failure - ? `Agent unsubscribe failed: ${completed} / ${run.total} completed. ${remaining} still need review. ${failure.message || ''}`.trim() - : remaining - ? `Agent unsubscribe finished: ${completed} / ${run.total} completed. ${remaining} still need review.` - : `Agent unsubscribe finished: ${completed} / ${run.total} completed.`, - )); - // A tool result can arrive just after the final browser step. Re-fetch once - // the run is over so an old mailbox page or server-side list cache cannot - // leave deleted messages visible until the next manual refresh. - if (!failure && typeof refreshEmailLibrary === 'function') { - setTimeout(() => refreshEmailLibrary().catch(() => {}), 0); - } -} - -window.addEventListener('odysseus:agent-terminal', _handleAgentUnsubscribeTerminal); - -async function _runUnsubscribeCleanup(modal, candidates, action, btn) { - const uids = []; - const seen = new Set(); - (candidates || []).forEach(c => { - _unsubscribeCandidateUids(c).forEach(uid => { - if (seen.has(uid)) return; - seen.add(uid); - uids.push(uid); - }); - }); - if (!uids.length) { - showToast?.('No emails to update'); - return; - } - const busyLabel = action === 'junk' - ? 'Marking spam' - : action === 'junk_delete' ? 'Spam + delete' : 'Deleting'; - const restoreBusy = _setUnsubButtonBusy(btn, busyLabel); - try { - const r = await fetch(emailApiUrl('/api/email/unsubscribe/cleanup', { - account_id: state._libAccountId || undefined, - }), { - method: 'POST', - credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - action, - uids, - folder: state._libFolder || 'INBOX', - account_id: state._libAccountId || '', - }), - }); - const d = await r.json().catch(() => ({})); - if (!d.success) throw new Error(d.error || 'Cleanup failed'); - const removed = new Set(uids.map(uid => String(uid))); - state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid))); - try { _libCacheWriteBack(); } catch (_) {} - try { _renderGrid(); } catch (_) {} - _rememberUnsubscribeHandled(candidates); - modal.querySelector('.email-unsub-followup')?.remove(); - showToast?.(action === 'junk' - ? `Marked ${d.changed || 0} email${Number(d.changed || 0) === 1 ? '' : 's'} as spam` - : action === 'junk_delete' - ? `Marked ${d.changed || 0} email${Number(d.changed || 0) === 1 ? '' : 's'} as spam and deleted ${d.changed || 0}` - : `Deleted ${d.changed || 0} email${Number(d.changed || 0) === 1 ? '' : 's'}`); - } catch (err) { - console.error(err); - showToast?.(err?.message || 'Cleanup failed'); - } finally { - restoreBusy?.(); - } -} - -async function _deleteAfterUnsubscribe(candidates) { - const groups = new Map(); - (candidates || []).forEach(c => { - const sender = String(c?.from_address || '').trim(); - const key = sender.toLowerCase() || `uids:${_unsubscribeCandidateUids(c).join(',')}`; - if (!groups.has(key)) groups.set(key, { sender, uids: [] }); - const group = groups.get(key); - const seen = new Set(group.uids); - _unsubscribeCandidateUids(c).forEach(uid => { - if (!seen.has(uid)) { - seen.add(uid); - group.uids.push(uid); - } - }); - }); - if (!groups.size) return { success: true, changed: 0, failed: 0, cleaned_uids: [] }; - const result = { success: true, changed: 0, failed: 0, cleaned_uids: [] }; - for (const group of groups.values()) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 30000); - let r; - try { - r = await fetch(emailApiUrl('/api/email/unsubscribe/cleanup', { - account_id: state._libAccountId || undefined, - }), { - method: 'POST', - credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - signal: controller.signal, - body: JSON.stringify({ - action: 'delete', - scope: group.sender ? 'sender_unsubscribe' : '', - sender: group.sender, - uids: group.sender ? [] : group.uids, - folder: state._libFolder || 'INBOX', - account_id: state._libAccountId || '', - }), - }); - } catch (err) { - if (err?.name === 'AbortError') throw new Error('Deleting timed out; the mail server did not respond'); - throw err; - } finally { - clearTimeout(timeout); - } - const d = await r.json().catch(() => ({})); - if (!d.success) throw new Error(d.error || 'Could not move unsubscribed emails to Trash'); - result.changed += Number(d.changed || 0); - result.failed += Number(d.failed || 0); - result.cleaned_uids.push(...(d.cleaned_uids || [])); - } - const removed = new Set(result.cleaned_uids.map(uid => String(uid))); - state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid))); - try { _libCacheWriteBack(); } catch (_) {} - try { _renderGrid(); } catch (_) {} - _rememberUnsubscribeHandled(candidates); - return result; -} - -function _showUnsubscribeCleanupPrompt(modal, candidates, options = {}) { - if (!modal || !Array.isArray(candidates) || !candidates.length) return; - modal.querySelector('.email-unsub-followup')?.remove(); - const count = candidates.reduce((sum, c) => sum + _unsubscribeCandidateUids(c).length, 0); - const heading = options.heading || `Done. Mark all ${count} of these email${count === 1 ? '' : 's'} as spam?`; - const rows = candidates.map(c => { - const uids = _unsubscribeCandidateUids(c); - return ` -
-
-
${_esc(c.subject || '(no subject)')}
-
${_esc(c.from_name || c.from_address || '')} · ${_esc(c.from_address || '')}
-
- ${uids.length > 1 ? `` : ''} -
`; - }).join(''); - const box = document.createElement('div'); - box.className = 'email-unsub-followup'; - box.style.cssText = 'border:1px solid color-mix(in srgb,var(--accent,var(--red)) 35%,var(--border));border-radius:8px;padding:10px;background:color-mix(in srgb,var(--accent,var(--red)) 8%,transparent);display:flex;flex-direction:column;gap:8px;'; - box.innerHTML = ` -
-
${_esc(heading)}
-
- - - - - ${options.scanFurther ? '' : ''} -
-
-
${rows}
`; - const body = modal.querySelector('.modal-body'); - const list = modal.querySelector('.email-unsub-list'); - body?.insertBefore(box, list || null); - box.querySelector('.email-unsub-clean-spam')?.addEventListener('click', (e) => { - _runUnsubscribeCleanup(modal, candidates, 'junk', e.currentTarget); - }); - box.querySelector('.email-unsub-clean-junk-delete')?.addEventListener('click', async (e) => { - const ok = await styledConfirm(`Mark ${count} reviewed email${count === 1 ? '' : 's'} as spam and delete ${count === 1 ? 'it' : 'them'}?`, { - confirmText: 'Spam + delete', - cancelText: 'Cancel', - danger: true, - }); - if (ok) _runUnsubscribeCleanup(modal, candidates, 'junk_delete', e.currentTarget); - }); - box.querySelector('.email-unsub-clean-delete')?.addEventListener('click', async (e) => { - const ok = await styledConfirm(`Delete ${count} reviewed email${count === 1 ? '' : 's'}?`, { - confirmText: 'Delete', - cancelText: 'Cancel', - danger: true, - }); - if (ok) _runUnsubscribeCleanup(modal, candidates, 'delete', e.currentTarget); - }); - box.querySelector('.email-unsub-clean-keep')?.addEventListener('click', () => box.remove()); - box.querySelector('.email-unsub-scan-further')?.addEventListener('click', () => { - const nextAnchor = options.scanAnchor; - modal.remove(); - if (nextAnchor) setTimeout(() => _openUnsubscribeReviewModal(nextAnchor), 0); - }); -} - -function _showUnsubscribeScanFurtherPrompt(modal, anchor, heading) { - if (!modal || !anchor) return; - modal.querySelector('.email-unsub-followup')?.remove(); - const box = document.createElement('div'); - box.className = 'email-unsub-followup'; - box.style.cssText = 'border:1px solid color-mix(in srgb,var(--accent,var(--red)) 35%,var(--border));border-radius:8px;padding:10px;background:color-mix(in srgb,var(--accent,var(--red)) 8%,transparent);display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;'; - box.innerHTML = `${_esc(heading)}`; - const body = modal.querySelector('.modal-body'); - const list = modal.querySelector('.email-unsub-list'); - body?.insertBefore(box, list || null); - box.querySelector('.email-unsub-scan-further')?.addEventListener('click', () => { - modal.remove(); - setTimeout(() => _openUnsubscribeReviewModal(anchor), 0); - }); -} - -function _showUnsubscribeRemainingPrompt(modal, candidates) { - if (!modal || !Array.isArray(candidates) || !candidates.length) return; - modal.querySelector('.email-unsub-followup')?.remove(); - const remaining = new Set(candidates); - modal.querySelectorAll('.email-unsub-card').forEach(card => { - const idx = Number(card.dataset.idx || -1); - card.hidden = !remaining.has(modal._unsubscribeCandidates?.[idx]); - }); - const box = document.createElement('div'); - box.className = 'email-unsub-followup email-unsub-remaining-followup'; - box.style.cssText = 'border:1px solid color-mix(in srgb,var(--accent,var(--red)) 35%,var(--border));border-radius:8px;padding:10px;background:color-mix(in srgb,var(--accent,var(--red)) 8%,transparent);display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;'; - box.innerHTML = ` - ${candidates.length} unsubscribe${candidates.length === 1 ? '' : 's'} still need review - - - - `; - const body = modal.querySelector('.modal-body'); - const list = modal.querySelector('.email-unsub-list'); - body?.insertBefore(box, list || null); - box.querySelector('.email-unsub-clean-remaining-btn')?.addEventListener('click', async (event) => { - const count = candidates.reduce((sum, candidate) => sum + _unsubscribeCandidateUids(candidate).length, 0); - const ok = await styledConfirm( - `Mark ${count} remaining email${count === 1 ? '' : 's'} as spam and delete ${count === 1 ? 'it' : 'them'}?`, - { confirmText: 'Spam + delete', cancelText: 'Cancel', danger: true }, - ); - if (ok) _runUnsubscribeCleanup(modal, candidates, 'junk_delete', event.currentTarget); - }); - box.querySelector('.email-unsub-agent-all-btn')?.addEventListener('click', (event) => { - const button = event.currentTarget; - if (modal._agentUnsubscribeRun) return; - const total = candidates.length; - const restoreBusy = _setUnsubButtonBusy(button, `Completed 0 / ${total}`); - modal._agentUnsubscribeRun = { - total, - candidates: candidates.slice(), - completed: new Set(), - cleanupInFlight: new Set(), - restoreBusy, - }; - if (!_askAgentToUnsubscribeAll(candidates)) { - restoreBusy?.(); - modal._agentUnsubscribeRun = null; - } - }); -} - -async function _openUnsubscribeReviewModal(anchor, options = {}) { - const existing = document.getElementById('email-unsubscribe-review-modal'); - if (existing) { - existing.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' }); - existing.querySelector('.email-unsub-status')?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' }); - return; - } - const modal = document.createElement('div'); - const scanFolderLabel = _unsubscribeFolderLabel(state._libFolder); - modal.className = 'modal'; - modal.id = 'email-unsubscribe-review-modal'; - const settingsPage = anchor?.closest?.('#email-lib-settings-page, .email-settings-page-host'); - const inlineHost = settingsPage?.querySelector?.('.email-settings-cleanup-section'); - const inlineMode = !!inlineHost; - if (inlineMode) { - modal.className = 'email-unsubscribe-inline-panel'; - modal.style.cssText = 'display:none;margin-top:10px;'; - modal.innerHTML = ` - `; - inlineHost.appendChild(modal); - } else { - modal.style.display = 'block'; - modal.innerHTML = ` - `; - document.body.appendChild(modal); - } - const close = () => modal.remove(); - if (!inlineMode) modal.addEventListener('click', (e) => { if (e.target === modal) close(); }); - const statusEl = inlineMode - ? inlineHost.querySelector('.email-settings-clean-status') - : modal.querySelector('.email-unsub-status'); - const panelStatusEl = modal.querySelector('.email-unsub-panel-status'); - const listEl = modal.querySelector('.email-unsub-list'); - const cachedScan = options.forceRescan ? null : _readUnsubscribeScanCache(); - const usingCachedScan = !!cachedScan; - let finishScanStatus = _setUnsubStatusBusy( - statusEl, - usingCachedScan ? 'Loading last unsubscribe scan…' : `Scanning ${scanFolderLabel} headers…`, - ); - const showFinalStatus = (message, isError = false) => { - const actionsEl = modal.querySelector('.email-unsub-actions'); - if (actionsEl) actionsEl.style.display = 'flex'; - if (inlineMode && panelStatusEl) { - finishScanStatus?.(''); - panelStatusEl.classList.toggle('is-error', isError); - panelStatusEl.textContent = message || ''; - modal.style.display = 'block'; - return; - } - statusEl?.classList.toggle('is-error', isError); - finishScanStatus?.(message); - }; - const finishScanError = (message) => { - showFinalStatus(message || 'Failed to scan email headers', true); - }; - try { - const scanUnsubscribeHeaders = (maxScan) => fetch(emailApiUrl('/api/email/unsubscribe/scan', { - folder: state._libFolder || 'INBOX', - limit: 500, - max_scan: maxScan, - account_id: state._libAccountId || undefined, - }), { credentials: 'same-origin' }); - const readScanResponse = async (response) => { - const payload = await response.json().catch(() => ({})); - if (!response.ok) { - throw new Error( - payload?.error || payload?.detail || `Email scan failed (HTTP ${response.status})`, - ); - } - return payload; - }; - // Keep the review request below the app's 45s hard deadline. The old - // whole-folder request made Gmail scans time out before any cards loaded. - let scanLimit = 500; - let res; - let data = cachedScan; - if (!data) { - res = await scanUnsubscribeHeaders(scanLimit); - data = await readScanResponse(res); - } else { - scanLimit = Number(data.scanLimit || scanLimit); - } - if (!data || (!data.success && !usingCachedScan)) { - finishScanError(data?.error || 'Failed to scan email headers'); - return; - } - let candidates = _filterHandledUnsubscribeCandidates( - _dedupeUnsubscribeCandidatesForDisplay(data.candidates || []), - ); - if (!usingCachedScan && !candidates.length && scanLimit > 0 && Number(data.scanned || 0) >= scanLimit) { - const searchMore = await styledConfirm( - `No unsubscribe candidates found in ${scanLimit} recent emails. Search more?`, - { title: 'Search more?', confirmText: 'Search more', cancelText: 'No' }, - ); - if (searchMore) { - scanLimit = 0; - finishScanStatus = _setUnsubStatusBusy(statusEl, `Scanning ${scanLimit} recent ${scanFolderLabel} headers…`); - res = await scanUnsubscribeHeaders(scanLimit); - data = await readScanResponse(res); - if (!data.success) { - finishScanError(data.error || 'Failed to scan email headers'); - return; - } - candidates = _filterHandledUnsubscribeCandidates( - _dedupeUnsubscribeCandidatesForDisplay(data.candidates || []), - ); - } - } - if (!usingCachedScan) _writeUnsubscribeScanCache(data, scanLimit); - const rawTotal = Number(data.raw_total || candidates.length || 0); - const scanPrefix = usingCachedScan ? 'Last scan: ' : ''; - const scanSuffix = usingCachedScan ? ' Rescan for fresh results.' : ''; - const scanScope = scanLimit > 0 ? `${data.scanned || 0} recent emails` : `all ${data.scanned || 0} emails`; - const finalStatus = candidates.length - ? `${scanPrefix}Found ${candidates.length} unsubscribe target${candidates.length === 1 ? '' : 's'} from ${scanScope}${rawTotal > candidates.length ? `, collapsed from ${rawTotal} matching emails` : ''}.${scanSuffix} Review before sending unsubscribe.` - : `${scanPrefix}No unsubscribe candidates found in ${scanScope}.${scanSuffix}`; - showFinalStatus(finalStatus); - modal.querySelector('.email-unsub-actions').style.display = 'flex'; - modal.querySelector('.email-unsub-delete-all-btn').style.display = candidates.length ? 'inline-flex' : 'none'; - modal.querySelector('.email-unsub-auto-safe-btn').style.display = candidates.length ? 'inline-flex' : 'none'; - listEl.style.display = candidates.length ? 'flex' : 'none'; - listEl.innerHTML = candidates.map((c, idx) => { - const method = c.recommended_method || null; - const reasons = (c.reasons || []).map(r => `${_esc(r)}`).join(''); - const urlMethod = (c.methods || []).find(m => m.kind === 'url'); - const duplicateCount = Number(c.duplicate_count || 1); - const duplicateBadge = duplicateCount > 1 ? `x${duplicateCount}` : ''; - const deleteLabel = duplicateCount > 1 ? 'Delete all' : 'Delete'; - const deleteTitle = duplicateCount > 1 - ? 'Delete all unsubscribe emails from this sender' - : 'Delete matching unsubscribe emails from this sender'; - return ` -
- - -
`; - }).join(''); - modal._unsubscribeCandidates = candidates; - listEl.querySelectorAll('.email-unsub-card-toggle').forEach(btn => { - btn.addEventListener('click', () => { - const card = btn.closest('.email-unsub-card'); - const details = card?.querySelector('.email-unsub-card-details'); - if (!details) return; - const isOpen = !details.hidden; - details.hidden = isOpen; - card.classList.toggle('is-expanded', !isOpen); - btn.setAttribute('aria-expanded', isOpen ? 'false' : 'true'); - btn.setAttribute('aria-label', isOpen ? 'Show unsubscribe details' : 'Hide unsubscribe details'); - btn.title = isOpen ? 'Show unsubscribe details' : 'Hide unsubscribe details'; - }); - }); - modal.querySelector('.email-unsub-rescan-btn')?.addEventListener('click', () => { - modal.remove(); - setTimeout(() => _openUnsubscribeReviewModal(anchor, { forceRescan: true }), 0); - }); - modal.querySelector('.email-unsub-delete-all-btn')?.addEventListener('click', async (event) => { - const allCandidates = modal._unsubscribeCandidates || []; - const count = allCandidates.reduce((sum, candidate) => sum + _unsubscribeCandidateUids(candidate).length, 0); - if (!count) { - finishScanStatus?.('No unsubscribe emails to delete.'); - return; - } - const ok = await styledConfirm( - `Delete all ${count} reviewed unsubscribe email${count === 1 ? '' : 's'}?`, - { confirmText: 'Delete all', cancelText: 'Cancel', danger: true }, - ); - if (!ok) return; - const restoreBusy = _setUnsubButtonBusy(event.currentTarget, 'Deleting all'); - try { - const result = await _deleteAfterUnsubscribe(allCandidates); - if (Number(result?.failed || 0) > 0 || Number(result?.changed || 0) <= 0) { - throw new Error('Could not move the reviewed emails to Trash'); - } - allCandidates.forEach((candidate, idx) => _markUnsubscribeCardDone(modal, idx, 'Deleted')); - modal.querySelector('.email-unsub-list')?.style.setProperty('display', 'none'); - finishScanStatus?.(`Deleted ${result.changed} reviewed email${result.changed === 1 ? '' : 's'}`); - } catch (error) { - finishScanStatus?.(error?.message || 'Delete all failed'); - } finally { - restoreBusy?.(); - } - }); - modal.querySelector('.email-unsub-auto-safe-btn')?.addEventListener('click', async () => { - const safeCandidates = (modal._unsubscribeCandidates || []) - .map((c, idx) => ({ c, idx })) - .filter(item => item.c && item.c.can_execute); - if (!safeCandidates.length) { - finishScanStatus?.(`No automatic unsubscribe actions found. ${modal._unsubscribeCandidates?.length || 0} link${(modal._unsubscribeCandidates?.length || 0) === 1 ? '' : 's'} still need review.`); - _showUnsubscribeRemainingPrompt(modal, modal._unsubscribeCandidates || []); - return; - } - const ok = await styledConfirm(`Send unsubscribe emails for ${safeCandidates.length} target${safeCandidates.length === 1 ? '' : 's'}?`, { - confirmText: 'Auto Unsubscribe All', - cancelText: 'Cancel', - }); - if (!ok) return; - const btn = modal.querySelector('.email-unsub-auto-safe-btn'); - const restoreBusy = _setUnsubButtonBusy(btn, `Unsubscribing 0/${safeCandidates.length}`); - let sent = 0; - let failed = 0; - const sentCandidates = []; - const failedCandidates = []; - try { - for (const item of safeCandidates) { - const c = item.c; - try { - restoreBusy?.setLabel?.(`Unsubscribing ${sent + failed + 1}/${safeCandidates.length}`); - const r = await fetch(emailApiUrl('/api/email/unsubscribe/execute', { - account_id: state._libAccountId || undefined, - }), { - method: 'POST', - credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - uid: c.uid, - folder: c.folder || state._libFolder || 'INBOX', - account_id: state._libAccountId || '', - method_index: 0, - move_to_spam: false, - }), - }); - const d = await r.json().catch(() => ({})); - if (!d.success) throw new Error(d.error || 'Unsubscribe failed'); - sent += 1; - sentCandidates.push(c); - const rowBtn = modal.querySelector(`.email-unsub-send-btn[data-idx="${item.idx}"]`); - if (rowBtn) { - rowBtn.disabled = true; - rowBtn.innerHTML = `${_UNSUB_CHECK_ICON}Sent`; - rowBtn.style.display = 'inline-flex'; - rowBtn.style.alignItems = 'center'; - rowBtn.style.gap = '4px'; - } - _markUnsubscribeCardDone(modal, item.idx); - } catch (err) { - failed += 1; - failedCandidates.push(c); - console.error(err); - } - } - } finally { - restoreBusy?.(); - } - let deleteFailed = false; - let cleanupResult = { success: true, changed: 0, failed: 0, cleaned_uids: [] }; - if (sentCandidates.length) { - try { - cleanupResult = await _deleteAfterUnsubscribe(sentCandidates); - deleteFailed = Number(cleanupResult.failed || 0) > 0; - const cleaned = new Set((cleanupResult.cleaned_uids || []).map(uid => String(uid))); - (modal._unsubscribeCandidates || []).forEach((candidate, candidateIdx) => { - if (_unsubscribeCandidateUids(candidate).some(uid => cleaned.has(String(uid)))) { - _markUnsubscribeCardDone(modal, candidateIdx); - } - }); - } catch (err) { - deleteFailed = true; - console.error(err); - } - } - const handled = new Set(); - sentCandidates.forEach(c => _unsubscribeCandidateUids(c).forEach(uid => handled.add(String(uid)))); - (cleanupResult.cleaned_uids || []).forEach(uid => handled.add(String(uid))); - const remainingCandidates = (modal._unsubscribeCandidates || []).filter(c => ( - !_unsubscribeCandidateUids(c).some(uid => handled.has(String(uid))) - )); - finishScanStatus?.( - `Auto unsubscribe finished: ${sent} unsubscribed${failed ? `, ${failed} failed` : ''}${remainingCandidates.length ? `, ${remainingCandidates.length} still need review` : ''}.`, - ); - if (sent && !failed && !deleteFailed) { - showToast?.(`Unsubscribed and deleted ${sent} email${sent === 1 ? '' : 's'}`); - } else if (sent || failed) { - showToast?.(deleteFailed - ? `Unsubscribed ${sent}, but some emails could not be deleted` - : `Sent ${sent}, failed ${failed}`); - } - if (remainingCandidates.length) { - _showUnsubscribeRemainingPrompt(modal, remainingCandidates); - } else { - _showUnsubscribeScanFurtherPrompt( - modal, - anchor, - `Finished this batch: ${sent} unsubscribed. Search older emails?`, - ); - } - }); - listEl.querySelectorAll('.email-unsub-done-btn').forEach(btn => { - btn.addEventListener('click', async () => { - const idx = Number(btn.dataset.idx || 0); - const c = modal._unsubscribeCandidates?.[idx]; - if (!c) return; - const sender = c.from_name || c.from_address || 'this sender'; - const ok = await styledConfirm( - `Delete matching unsubscribe emails from ${sender}?`, - { confirmText: 'Delete', cancelText: 'Cancel', danger: true }, - ); - if (!ok) return; - const restoreBusy = _setUnsubButtonBusy(btn, 'Deleting'); - try { - await _deleteAfterUnsubscribe([c]); - restoreBusy?.(); - _markUnsubscribeCardDone(modal, idx, 'Deleted'); - showToast?.('Matching emails deleted'); - } catch (err) { - restoreBusy?.(); - showToast?.(err?.message || 'Could not delete matching emails'); - } - }); - }); - listEl.querySelectorAll('.email-unsub-ignore-btn').forEach(btn => { - btn.addEventListener('click', () => { - const idx = Number(btn.dataset.idx || 0); - const c = modal._unsubscribeCandidates?.[idx]; - if (!c) return; - _rememberUnsubscribeIgnored(c); - btn.closest('.email-unsub-card')?.remove(); - const remaining = listEl.querySelectorAll('.email-unsub-card:not([hidden])').length; - if (!remaining) { - listEl.style.display = 'none'; - modal.querySelector('.email-unsub-auto-safe-btn').style.display = 'none'; - finishScanStatus?.('No unsubscribe candidates left in this review.'); - } else { - finishScanStatus?.(`${remaining} unsubscribe candidate${remaining === 1 ? '' : 's'} still need review.`); - } - }); - }); - listEl.querySelectorAll('.email-unsub-agent-btn').forEach(btn => { - btn.addEventListener('click', () => { - const idx = Number(btn.dataset.idx || 0); - const c = modal._unsubscribeCandidates?.[idx]; - if (!c) return; - if (modal._agentUnsubscribeRun) return; - const restoreBusy = _setUnsubButtonBusy(btn, 'Starting'); - modal._agentUnsubscribeRun = { - total: 1, - candidates: [c], - completed: new Set(), - cleanupInFlight: new Set(), - restoreBusy, - }; - if (!_askAgentToUnsubscribe(c)) { - restoreBusy?.(); - modal._agentUnsubscribeRun = null; - } - }); - }); - listEl.querySelectorAll('.email-unsub-send-btn').forEach(btn => { - btn.addEventListener('click', async () => { - const idx = Number(btn.dataset.idx || 0); - const c = modal._unsubscribeCandidates?.[idx]; - if (!c) return; - const ok = await styledConfirm(`Send unsubscribe email to ${_unsubscribeMethodLabel(c.recommended_method)}?`, { - confirmText: 'Unsubscribe', - cancelText: 'Cancel', - }); - if (!ok) return; - btn.disabled = true; - btn.textContent = 'Sending…'; - try { - const r = await fetch(emailApiUrl('/api/email/unsubscribe/execute', { - account_id: state._libAccountId || undefined, - }), { - method: 'POST', - credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - uid: c.uid, - folder: c.folder || state._libFolder || 'INBOX', - account_id: state._libAccountId || '', - method_index: 0, - move_to_spam: false, - }), - }); - const d = await r.json().catch(() => ({})); - if (!d.success) throw new Error(d.error || 'Unsubscribe failed'); - btn.innerHTML = `${_UNSUB_CHECK_ICON}Sent`; - btn.style.display = 'inline-flex'; - btn.style.alignItems = 'center'; - btn.style.gap = '4px'; - _markUnsubscribeCardDone(modal, idx); - try { - await _deleteAfterUnsubscribe([c]); - showToast('Unsubscribed and deleted email'); - } catch (deleteErr) { - console.error(deleteErr); - showToast('Unsubscribed, but email could not be deleted'); - _showUnsubscribeCleanupPrompt(modal, [c], { - heading: 'The unsubscribe request was sent. Mark this email as spam and delete it?', - }); - } - } catch (err) { - console.error(err); - btn.disabled = false; - btn.innerHTML = `${_UNSUB_EMAIL_ICON}${_esc(_unsubscribeMethodLabel(c.recommended_method))}`; - btn.style.display = 'inline-flex'; - btn.style.alignItems = 'center'; - btn.style.gap = '4px'; - showToast(err?.message || 'Unsubscribe failed'); - } - }); - }); - } catch (err) { - console.error(err); - finishScanError('Failed to scan email headers'); - } -} - -function _rememberedEmailAccountId() { - try { - return String(localStorage.getItem(_LIB_LAST_ACCOUNT_KEY) || '').trim(); - } catch (_) { - return ''; - } -} - -// Per-(account, folder, filter, attachments) cache of the most recent -// first-page list response. Lets open-after-refresh paint the previous -// list instantly while the network refresh runs behind it. Search results -// and __scheduled__ are deliberately not cached. -const _libListCache = new Map(); -const _LIB_CACHE_MAX = 24; -const _LIB_INITIAL_PAGE_SIZE = 100; -const _LIB_SESSION_CACHE_PREFIX = 'odysseus.email.list.'; -const _LIB_SESSION_CACHE_TTL_MS = 10 * 60 * 1000; -const _LIB_PERSIST_CACHE_PREFIX = 'odysseus.email.list.v2.'; -const _LIB_PERSIST_CACHE_TTL_MS = 6 * 60 * 60 * 1000; -const _LIB_PERSIST_CACHE_MAX = 12; -const _LIB_LAST_ACCOUNT_KEY = 'odysseus.email.lastAccountId'; -const _LIB_PREWARM_COOLDOWN_MS = 5 * 60 * 1000; -let _libPrewarmDelayTimer = null; -let _libPrewarmIdleHandle = null; -let _libPrewarmPromise = null; -let _libPrewarmResolve = null; -let _libPrewarmAbortController = null; -let _libPrewarmDetachPriorityListeners = null; -let _libPrewarmGeneration = 0; -let _libLastPrewarmAt = 0; -let _libUnreadPrewarmKey = ''; -let _libUnreadPrewarmAt = 0; -let _libRenderedViewKey = ''; -let _libSyncStatus = { - updatedAt: '', - source: '', - warming: false, - loading: false, -}; -let _libSyncTicker = null; - -function _libSyncDateFrom(value) { - if (!value) return null; - const d = new Date(value); - return Number.isFinite(d.getTime()) ? d : null; -} - -function _libRelativeTime(value) { - const d = _libSyncDateFrom(value); - if (!d) return ''; - const seconds = Math.max(0, Math.floor((Date.now() - d.getTime()) / 1000)); - if (seconds < 45) return 'just now'; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - return `${days}d ago`; -} - -function _renderEmailSyncStatus() { - const el = document.getElementById('email-lib-sync-status'); - if (!el) return; - const parts = []; - const rel = _libRelativeTime(_libSyncStatus.updatedAt); - if (rel) parts.push(`Last: ${rel}`); - if (_libSyncStatus.loading) { - if (state._libEmails.length) parts.push('Updating...'); - el.textContent = parts.join(' · '); - el.style.visibility = parts.length ? 'visible' : 'hidden'; - return; - } - el.textContent = parts.join(' · '); - el.style.visibility = parts.length ? 'visible' : 'hidden'; -} - -function _setEmailSyncStatus(next = {}) { - if (Object.prototype.hasOwnProperty.call(next, 'updatedAt')) { - const updatedAt = next.updatedAt || ''; - if (!updatedAt) { - _libSyncStatus.updatedAt = _libSyncStatus.updatedAt || ''; - } else if (_libSyncDateFrom(updatedAt)) { - _libSyncStatus.updatedAt = updatedAt; - } - } - if (Object.prototype.hasOwnProperty.call(next, 'source')) { - _libSyncStatus.source = next.source || ''; - } - if (Object.prototype.hasOwnProperty.call(next, 'warming')) { - _libSyncStatus.warming = Boolean(next.warming); - } - if (Object.prototype.hasOwnProperty.call(next, 'loading')) { - _libSyncStatus.loading = Boolean(next.loading); - } - _renderEmailSyncStatus(); -} - -function _libCacheKeyFor(accountId, folder, filter, hasAttachments) { - return [ - accountId || '', - folder || '', - filter || '', - hasAttachments ? 1 : 0, - state._libDateFrom || '', - state._libDateTo || '', - ].join('|'); -} -function _libCacheKey() { - return _libCacheKeyFor( - state._libAccountId || '', - state._libFolder || '', - state._libFilter || '', - state._libHasAttachments - ); -} -function _libSessionCacheKey(key) { - return _LIB_SESSION_CACHE_PREFIX + encodeURIComponent(String(key || '')); -} -function _libPersistCacheKey(key) { - return _LIB_PERSIST_CACHE_PREFIX + encodeURIComponent(String(key || '')); -} -function _libSessionCacheGet(key) { - try { - const raw = sessionStorage.getItem(_libSessionCacheKey(key)); - if (!raw) return null; - const parsed = JSON.parse(raw); - if (!parsed || !parsed.savedAt || (Date.now() - parsed.savedAt) > _LIB_SESSION_CACHE_TTL_MS) { - sessionStorage.removeItem(_libSessionCacheKey(key)); - return null; - } - return parsed.value || null; - } catch (_) { - return null; - } -} -function _libPersistCacheGet(key) { - try { - const storageKey = _libPersistCacheKey(key); - const raw = localStorage.getItem(storageKey); - if (!raw) return null; - const parsed = JSON.parse(raw); - if (!parsed || !parsed.savedAt || (Date.now() - parsed.savedAt) > _LIB_PERSIST_CACHE_TTL_MS) { - localStorage.removeItem(storageKey); - return null; - } - return parsed.value || null; - } catch (_) { - return null; - } -} -function _libSessionCachePut(key, value) { - try { - sessionStorage.setItem(_libSessionCacheKey(key), JSON.stringify({ savedAt: Date.now(), value })); - } catch (_) {} -} -function _libPrunePersistCache() { - try { - const entries = []; - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (!key || !key.startsWith(_LIB_PERSIST_CACHE_PREFIX)) continue; - let savedAt = 0; - try { - savedAt = Number(JSON.parse(localStorage.getItem(key) || '{}')?.savedAt || 0); - } catch (_) {} - entries.push({ key, savedAt }); - } - entries.sort((a, b) => b.savedAt - a.savedAt); - for (const stale of entries.slice(_LIB_PERSIST_CACHE_MAX)) { - localStorage.removeItem(stale.key); - } - } catch (_) {} -} -function _libPersistCachePut(key, value) { - try { - localStorage.setItem(_libPersistCacheKey(key), JSON.stringify({ savedAt: Date.now(), value })); - _libPrunePersistCache(); - } catch (_) { - try { localStorage.removeItem(_libPersistCacheKey(key)); } catch {} - } -} -function _looksLikeFixtureEmailRow(row) { - if (!row || typeof row !== 'object') return false; - const account = String(row.account || row._account || row.account_id || row._account_id || '').toLowerCase(); - const from = `${row.from || ''} ${row.from_name || ''} ${row.from_address || ''}`.toLowerCase(); - const subject = String(row.subject || '').toLowerCase(); - const messageId = String(row.message_id || '').toLowerCase(); - return account.includes('fixture') - || from.includes('fixture@example') - || from.includes('older fixture') - || subject === 'older inbox message' - || messageId.includes('fixture-email-'); -} -function _libCacheHasFixtureRows(value) { - return Boolean(value && Array.isArray(value.emails) && value.emails.some(_looksLikeFixtureEmailRow)); -} -function _libDropCacheKey(key) { - _libListCache.delete(key); - try { sessionStorage.removeItem(_libSessionCacheKey(key)); } catch (_) {} - try { localStorage.removeItem(_libPersistCacheKey(key)); } catch (_) {} -} -function _libCacheGet(key) { - const memory = _libListCache.get(key); - if (memory) { - if (_libCacheHasFixtureRows(memory)) { - _libDropCacheKey(key); - return null; - } - return memory; - } - const stored = _libSessionCacheGet(key) || _libPersistCacheGet(key); - if (stored) { - if (_libCacheHasFixtureRows(stored)) { - _libDropCacheKey(key); - return null; - } - _libListCache.set(key, stored); - return stored; - } - return null; -} -function _libCachePut(key, value) { - if (_libCacheHasFixtureRows(value)) { - _libDropCacheKey(key); - return; - } - // Re-insert to bump LRU recency. - _libListCache.delete(key); - _libListCache.set(key, value); - _libSessionCachePut(key, value); - _libPersistCachePut(key, value); - if (_libListCache.size > _LIB_CACHE_MAX) { - const oldest = _libListCache.keys().next().value; - _libListCache.delete(oldest); - } -} - -function _resetBulkSelectionForContextChange({ rerender = false } = {}) { - const hadSelection = !!(state._selectedUids && state._selectedUids.size); - const wasSelectMode = !!state._selectMode; - if (state._selectedUids) state._selectedUids.clear(); - state._selectMode = false; - if (hadSelection || wasSelectMode) { - _updateBulkBar(); - if (rerender) _renderGrid(); - } -} - -function _resetEmailListForFreshLoad({ useCache = true, refreshAfterCache = false } = {}) { - _exitEmailReaderModeForList(); - _resetBulkSelectionForContextChange(); - state._libOffset = 0; - _emailMailboxGeneration += 1; - _libLoadSeq += 1; - const ck = _libCacheKey(); - const cached = useCache ? _libCacheGet(ck) : null; - if (cached && Array.isArray(cached.emails) && cached.emails.length) { - state._libEmails = cached.emails.slice(); - state._libTotal = cached.total || state._libEmails.length; - state._libJustOpened = false; - _renderGrid(); - const stats = document.getElementById('email-lib-stats'); - if (stats) stats.textContent = `${state._libTotal} emails`; - _setEmailSyncStatus({ updatedAt: cached.sync?.updated_at || '', source: cached.sync?.source || 'client_cache', loading: refreshAfterCache }); - _libRenderedViewKey = ck; - return; - } - if (state._libEmails.length && _libRenderedViewKey === ck) { - _renderGrid(); - _setEmailSyncStatus({ loading: false }); - return; - } - state._libEmails = []; - state._libTotal = 0; - _libRenderedViewKey = ck; - const grid = document.getElementById('email-lib-grid'); - if (grid) _renderEmailLoading(grid); - const stats = document.getElementById('email-lib-stats'); - if (stats) stats.textContent = 'Loading...'; - _setEmailSyncStatus({ loading: true }); -} - -function _exitEmailReaderModeForList() { - const modal = document.getElementById('email-lib-modal'); - modal?.classList.remove('email-reading'); - modal?.style.removeProperty('--email-reading-modal-min-h'); - const grid = document.getElementById('email-lib-grid'); - grid?.querySelectorAll('.email-card-expanded, .doclib-card-expanded').forEach(card => { - unbindExpandedCardDismiss(card); - card.classList.remove('email-card-expanded'); - card.classList.remove('doclib-card-expanded'); - card.style.minHeight = ''; - card.querySelector('.email-card-reader')?.remove(); - }); -} - -function _loadEmailsFresh({ force = true, useCache = true, refreshAfterCache = true, showRefreshSpinner = false } = {}) { - const refreshBtn = showRefreshSpinner ? document.getElementById('email-lib-refresh-btn') : null; - refreshBtn?.classList.add('email-lib-refreshing'); - const resolvedFolder = _resolveEmailFolderAlias(state._libFolder); - if (resolvedFolder && resolvedFolder !== state._libFolder) { - state._libFolder = resolvedFolder; - const folderSel = document.getElementById('email-lib-folder'); - if (folderSel) folderSel.value = resolvedFolder; - _renderFolderPicker(); - } - _resetEmailListForFreshLoad({ useCache, refreshAfterCache }); - return _loadEmails({ force, useCache }).finally(() => { - refreshBtn?.classList.remove('email-lib-refreshing'); - }); -} - -async function _refreshEmailLibraryFromUi(btn = null) { - btn?.classList.add('email-lib-refreshing'); - state._libOffset = 0; - // Don't wipe state._libEmails — _loadEmails will paint the current - // 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); - } - } -} - -// Refresh the currently visible mailbox after another surface sends a message. -// Keep the user's folder, filters, and scroll context intact. -export async function refreshEmailLibrary() { - if (!state._libOpen) return false; - await _loadEmailsFresh({ - force: true, - useCache: false, - refreshAfterCache: false, - showRefreshSpinner: true, - }); - return true; -} - -function _initMobileEmailPullRefresh() { - const grid = document.getElementById('email-lib-grid'); - const modal = document.getElementById('email-lib-modal'); - const host = modal?.querySelector('.admin-card'); - if (!grid || !host || grid.dataset.pullRefreshBound === '1') return; - if (!('ontouchstart' in window || navigator.maxTouchPoints > 0)) return; - grid.dataset.pullRefreshBound = '1'; - - const THRESHOLD = 72; - const MAX_PULL = 104; - let startY = 0; - let pullY = 0; - let tracking = false; - let refreshing = false; - - const indicator = document.createElement('div'); - indicator.className = 'chat-pull-refresh email-pull-refresh'; - indicator.setAttribute('aria-hidden', 'true'); - indicator.innerHTML = '
'; - host.prepend(indicator); - const spinnerMount = indicator.querySelector('.chat-pull-refresh-spinner'); - try { - const spinner = spinnerModule.createWhirlpool(18); - spinnerMount.replaceChildren(spinner.element); - } catch (_) {} - - function setPull(px, active = false) { - pullY = Math.max(0, Math.min(MAX_PULL, px)); - const pct = Math.min(1, pullY / THRESHOLD); - indicator.style.setProperty('--pull-refresh-y', `${pullY}px`); - indicator.style.setProperty('--pull-refresh-progress', `${pct}`); - indicator.classList.toggle('is-visible', active || refreshing || pullY > 2); - indicator.classList.toggle('is-ready', pct >= 1 && !refreshing); - indicator.classList.toggle('is-refreshing', refreshing); - } - - async function runRefresh() { - if (refreshing) return; - refreshing = true; - setPull(THRESHOLD, true); - try { - await _refreshEmailLibraryFromUi(document.getElementById('email-lib-refresh-btn')); - } catch (err) { - console.warn('email pull refresh failed:', err); - } finally { - refreshing = false; - setPull(0, false); - } - } - - grid.addEventListener('touchstart', (e) => { - if (refreshing || window.innerWidth > 768) return; - if (grid.scrollTop > 0) return; - if (e.target?.closest?.('button, input, textarea, select, a, .email-card-reader, .doclib-card-expanded')) return; - tracking = true; - startY = e.touches[0].clientY; - setPull(0, false); - }, { passive: true }); - - grid.addEventListener('touchmove', (e) => { - if (!tracking || refreshing) return; - const dy = e.touches[0].clientY - startY; - if (dy <= 0) { - setPull(0, false); - return; - } - if (grid.scrollTop <= 0) { - e.preventDefault(); - setPull(dy * 0.62, true); - } - }, { passive: false }); - - grid.addEventListener('touchend', () => { - if (!tracking) return; - tracking = false; - if (pullY >= THRESHOLD) runRefresh(); - else setPull(0, false); - }, { passive: true }); - - grid.addEventListener('touchcancel', () => { - tracking = false; - if (!refreshing) setPull(0, false); - }, { passive: true }); -} - -function _isChatInteractionBusy() { - try { - if (window.__odysseusChatBusy) return true; - const until = Number(window.__odysseusChatBusyUntil || 0); - return until > Date.now(); - } catch (_) { - return false; - } -} - -function _canRunEmailPrewarm() { - if (state._libOpen || state._libLoading || _libSearchInFlight) return false; - if (document.visibilityState && document.visibilityState !== 'visible') return false; - return !_isChatInteractionBusy(); -} - -function _isEmailPrewarmTemporarilyBlocked() { - if (state._libOpen || state._libLoading || _libSearchInFlight) return false; - if (document.visibilityState && document.visibilityState !== 'visible') return false; - return _isChatInteractionBusy(); -} - -function _isEmailPrewarmCurrent(generation, signal) { - return generation === _libPrewarmGeneration - && !signal?.aborted - && _canRunEmailPrewarm(); -} - -function _settleEmailPrewarm(generation, value = false) { - if (generation !== _libPrewarmGeneration) return; - const resolve = _libPrewarmResolve; - const detachPriorityListeners = _libPrewarmDetachPriorityListeners; - _libPrewarmDelayTimer = null; - _libPrewarmIdleHandle = null; - _libPrewarmPromise = null; - _libPrewarmResolve = null; - _libPrewarmAbortController = null; - _libPrewarmDetachPriorityListeners = null; - detachPriorityListeners?.(); - resolve?.(value); -} - -function _cancelEmailPrewarm() { - const resolve = _libPrewarmResolve; - const detachPriorityListeners = _libPrewarmDetachPriorityListeners; - _libPrewarmGeneration += 1; - if (_libPrewarmDelayTimer !== null) { - clearTimeout(_libPrewarmDelayTimer); - } - if (_libPrewarmIdleHandle !== null && typeof window.cancelIdleCallback === 'function') { - try { window.cancelIdleCallback(_libPrewarmIdleHandle); } catch (_) {} - } - try { _libPrewarmAbortController?.abort(); } catch (_) {} - _libPrewarmDelayTimer = null; - _libPrewarmIdleHandle = null; - _libPrewarmPromise = null; - _libPrewarmResolve = null; - _libPrewarmAbortController = null; - _libPrewarmDetachPriorityListeners = null; - detachPriorityListeners?.(); - resolve?.(false); -} - -function _scheduleEmailPrewarm(task, { delay = 0 } = {}) { - if (_libPrewarmPromise) return _libPrewarmPromise; - // Do not disguise a timer as idle work. Browsers without the genuine idle - // callback simply skip this optional optimization and load on demand. - if (typeof window.requestIdleCallback !== 'function') return Promise.resolve(false); - - const generation = ++_libPrewarmGeneration; - _libPrewarmPromise = new Promise(resolve => { _libPrewarmResolve = resolve; }); - const promise = _libPrewarmPromise; - let attemptPending = false; - let retryRequested = false; - - function clearScheduledAttempt() { - if (_libPrewarmDelayTimer !== null) clearTimeout(_libPrewarmDelayTimer); - if (_libPrewarmIdleHandle !== null && typeof window.cancelIdleCallback === 'function') { - try { window.cancelIdleCallback(_libPrewarmIdleHandle); } catch (_) {} - } - _libPrewarmDelayTimer = null; - _libPrewarmIdleHandle = null; - } - - function scheduleIdleRetry(delay = 500) { - if (generation !== _libPrewarmGeneration) return; - retryRequested = true; - if (attemptPending || _libPrewarmDelayTimer !== null || _libPrewarmIdleHandle !== null) return; - if (document.visibilityState && document.visibilityState !== 'visible') return; - _libPrewarmDelayTimer = setTimeout(requestIdle, Math.max(50, Number(delay) || 500)); - } - - function handlePriorityChange() { - if (generation !== _libPrewarmGeneration) return; - if (_canRunEmailPrewarm()) { - scheduleIdleRetry(50); - return; - } - - const priorityBlocked = _isChatInteractionBusy() - || (document.visibilityState && document.visibilityState !== 'visible'); - if (!priorityBlocked) return; - - retryRequested = true; - clearScheduledAttempt(); - const controller = _libPrewarmAbortController; - _libPrewarmAbortController = null; - try { controller?.abort(); } catch (_) {} - // A hidden page waits for visibilitychange. Chat priority also retains the - // timer fallback for busy-until windows whose final transition has no event. - if (!document.visibilityState || document.visibilityState === 'visible') { - scheduleIdleRetry(); - } - } - - window.addEventListener('odysseus:chat-busy-change', handlePriorityChange); - document.addEventListener('visibilitychange', handlePriorityChange); - _libPrewarmDetachPriorityListeners = () => { - window.removeEventListener('odysseus:chat-busy-change', handlePriorityChange); - document.removeEventListener('visibilitychange', handlePriorityChange); - }; - - function requestIdle() { - if (generation !== _libPrewarmGeneration) return; - _libPrewarmDelayTimer = null; - try { - _libPrewarmIdleHandle = window.requestIdleCallback((deadline) => { - if (generation !== _libPrewarmGeneration) return; - _libPrewarmIdleHandle = null; - const hasIdleBudget = Boolean( - deadline - && !deadline.didTimeout - && typeof deadline.timeRemaining === 'function' - && deadline.timeRemaining() > 0 - ); - if (!_canRunEmailPrewarm()) { - if (_isEmailPrewarmTemporarilyBlocked()) { - scheduleIdleRetry(); - } else { - _settleEmailPrewarm(generation, false); - } - return; - } - if (!hasIdleBudget) { - scheduleIdleRetry(); - return; - } - if (generation !== _libPrewarmGeneration) { - _settleEmailPrewarm(generation, false); - return; - } - const controller = new AbortController(); - _libPrewarmAbortController = controller; - attemptPending = true; - retryRequested = false; - Promise.resolve() - .then(() => task({ signal: controller.signal, generation })) - .then(value => { - if (controller !== _libPrewarmAbortController || controller.signal.aborted) return; - _settleEmailPrewarm(generation, Boolean(value)); - }) - .catch(() => { - if (controller !== _libPrewarmAbortController || controller.signal.aborted) return; - _settleEmailPrewarm(generation, false); - }) - .finally(() => { - attemptPending = false; - if (generation !== _libPrewarmGeneration) return; - if (retryRequested) scheduleIdleRetry(); - }); - }); - } catch (_) { - _settleEmailPrewarm(generation, false); - } - } - - const wait = Math.max(0, Number(delay) || 0); - if (wait > 0) _libPrewarmDelayTimer = setTimeout(requestIdle, wait); - else requestIdle(); - return promise; -} - -export function prewarmEmailLibrary({ delay = 2500 } = {}) { - if (_libPrewarmPromise) return _libPrewarmPromise; - const elapsed = Date.now() - _libLastPrewarmAt; - if (elapsed >= 0 && elapsed < _LIB_PREWARM_COOLDOWN_MS) return Promise.resolve(false); - return _scheduleEmailPrewarm(_prewarmEmailViews, { delay }); -} - -function _chooseEmailPrewarmAccountId(accounts) { - const enabled = Array.isArray(accounts) ? accounts.filter(a => a && a.enabled !== false) : []; - const remembered = _rememberedEmailAccountId(); - const current = String(state._libAccountId || '').trim(); - const chosen = enabled.find(a => String(a.id || '') === remembered) - || enabled.find(a => String(a.id || '') === current) - || enabled.find(a => a.is_default) - || enabled[0] - || null; - return String(chosen?.id || '').trim(); -} - -async function _ensureEmailAccountsForPrewarm({ signal, generation } = {}) { - if (!_isEmailPrewarmCurrent(generation, signal)) return null; - const accountsFresh = _libAccountsLoadedAt && (Date.now() - _libAccountsLoadedAt) < _LIB_ACCOUNTS_TTL_MS; - if (!(Array.isArray(state._libAccounts) && state._libAccounts.length && accountsFresh)) { - try { - const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, { - credentials: 'same-origin', - signal, - }); - if (!_isEmailPrewarmCurrent(generation, signal)) return null; - if (accountsRes.ok) { - const accountsData = await accountsRes.json().catch(() => ({})); - if (!_isEmailPrewarmCurrent(generation, signal)) return null; - if (Array.isArray(accountsData.accounts)) { - state._libAccounts = accountsData.accounts; - _libAccountsLoadedAt = Date.now(); - } - } - } catch (err) { - if (err?.name === 'AbortError') return null; - } - } - - const accountId = _chooseEmailPrewarmAccountId(state._libAccounts); - if (!_isEmailPrewarmCurrent(generation, signal)) return null; - if (!accountId) return null; - return accountId; -} - -export function prewarmUnreadEmails({ limit = 8, maxUid = 0 } = {}) { - return _scheduleEmailPrewarm( - context => _prewarmUnreadEmailsNow({ limit, maxUid }, context), - { delay: 0 } - ); -} - -async function _prewarmUnreadEmailsNow({ limit = 8, maxUid = 0 } = {}, { signal, generation } = {}) { - if (!_isEmailPrewarmCurrent(generation, signal)) return false; - const accountId = await _ensureEmailAccountsForPrewarm({ signal, generation }); - if (accountId === null || !_isEmailPrewarmCurrent(generation, signal)) return false; - const n = Math.max(1, Math.min(20, Number(limit) || 8)); - const key = `${accountId}|${maxUid || 0}|${n}`; - if (_libUnreadPrewarmKey === key && (Date.now() - _libUnreadPrewarmAt) < 60 * 1000) return true; - try { - const folder = 'INBOX'; - const res = await fetch(emailApiUrl('/api/email/list', { - folder, - limit: n, - offset: 0, - filter: 'unread', - account_id: accountId || undefined, - }), { - credentials: 'same-origin', - signal, - }); - if (!_isEmailPrewarmCurrent(generation, signal) || !res.ok) return false; - const data = await res.json().catch(() => null); - if (!_isEmailPrewarmCurrent(generation, signal)) return false; - if (!data || data.error || !Array.isArray(data.emails) || !data.emails.length) return false; - const sync = data.sync || {}; - _libCachePut(_libCacheKeyFor(accountId, folder, 'unread', false), { - emails: data.emails, - total: data.total || data.emails.length, - sync, - }); - _libUnreadPrewarmKey = key; - _libUnreadPrewarmAt = Date.now(); - return true; - } catch (_) { - return false; - } -} - -async function _prewarmEmailViews({ signal, generation } = {}) { - if (!_isEmailPrewarmCurrent(generation, signal)) return false; - _setEmailSyncStatus({ warming: true }); - const folder = 'INBOX'; - const filter = 'all'; - try { - const accountId = await _ensureEmailAccountsForPrewarm({ signal, generation }); - if (accountId === null || !_isEmailPrewarmCurrent(generation, signal)) return false; - const ck = _libCacheKeyFor(accountId, folder, filter, false); - if (_libCacheGet(ck)) { - _libLastPrewarmAt = Date.now(); - return true; - } - - // One optional first-page request only. Folder metadata, unread state, and - // other accounts remain demand-driven so startup cannot fan out into IMAP. - const res = await fetch(emailApiUrl('/api/email/list', { - folder, - limit: _LIB_INITIAL_PAGE_SIZE, - offset: 0, - filter, - account_id: accountId || undefined, - }), { - credentials: 'same-origin', - signal, - }); - if (!_isEmailPrewarmCurrent(generation, signal) || !res.ok) return false; - const data = await res.json().catch(() => null); - if (!_isEmailPrewarmCurrent(generation, signal)) return false; - if (!data || data.error || !Array.isArray(data.emails)) return false; - const sync = data.sync || {}; - _libCachePut(ck, { - emails: data.emails, - total: data.total || 0, - sync, - }); - _libLastPrewarmAt = Date.now(); - _setEmailSyncStatus({ - updatedAt: sync.updated_at || new Date().toISOString(), - source: sync.source || '', - warming: true, - }); - return true; - } catch (_) { - return false; - } finally { - _setEmailSyncStatus({ warming: false }); - } -} -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, - sync: { - updated_at: _libSyncStatus.updatedAt || new Date().toISOString(), - source: _libSyncStatus.source || 'local', - }, - }); - } -} - -// 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 (_) {} - try { - if (state._libAccountId) localStorage.setItem(_LIB_LAST_ACCOUNT_KEY, state._libAccountId); - } catch (_) {} - // Publish the active account's own address so reply-all can exclude us from - // the recipient list. This global was read in emailInbox.js but never set. - try { - const accts = state._libAccounts || []; - const active = accts.find(a => a && a.id === state._libAccountId) - || accts.find(a => a && a.is_default) - || accts[0]; - window._myEmailAddress = (active && (active.from_address || active.imap_user)) || ''; - // Also publish every configured address so reply-all can exclude all of - // the user's own mailboxes, not just the active one (multi-account users - // were getting their other addresses added to Cc). - const all = []; - for (const a of accts) { - if (a && a.from_address) all.push(a.from_address); - if (a && a.imap_user) all.push(a.imap_user); - } - window._myEmailAddresses = all; - } catch (_) {} -} - -export function initEmailLibrary(config) { - state._docModule = config.documentModule; - const onEmailClick = config.onEmailClick; - state._onEmailClick = typeof onEmailClick === 'function' ? (options = {}) => { - const accountId = String(state._libAccountId || ''); - const libraryFolder = String(state._libFolder || 'INBOX'); - const messageFolder = String(options.email?.folder || libraryFolder); - const mailboxGeneration = _emailMailboxGeneration; - const mailboxContext = Object.freeze({ - accountId, - libraryFolder, - messageFolder, - mailboxGeneration, - isCurrent: () => ( - String(state._libAccountId || '') === accountId && - String(state._libFolder || 'INBOX') === libraryFolder && - _emailMailboxGeneration === mailboxGeneration - ), - }); - return onEmailClick({ ...options, mailboxContext }); - } : null; -} - -export function isOpen() { return state._libOpen; } - -export function openEmailLibrary(opts = {}) { - // Foreground email always wins: cancel a delayed/idle callback and abort the - // one optional request if it has already started. Generation checks make a - // non-abortable response harmless if it races this transition. - _cancelEmailPrewarm(); - // Preserve a valid inbox snapshot while rebuilding the modal. Reader - // failures and mobile draft transitions can reopen this window before the - // next list request has completed; clearing the snapshot here made that - // normal recovery path look like an empty inbox. - const previousLibraryWasOpen = state._libOpen; - const previousAccountId = state._libAccountId || ''; - const previousFolder = state._libFolder || 'INBOX'; - const previousFilter = state._libFilter || 'all'; - const previousSearch = state._libSearch || ''; - const previousEmails = Array.isArray(state._libEmails) ? state._libEmails.slice() : []; - - // Force-clean any stale DOM state from previous attempts - const existing = document.getElementById('email-lib-modal'); - if (existing) existing.remove(); - if (state._libEscHandler) { - document.removeEventListener('keydown', state._libEscHandler, true); - state._libEscHandler = null; - } - if (state._libInnerEscHandler) { - window.removeEventListener('keydown', state._libInnerEscHandler, true); - state._libInnerEscHandler = null; - } - _emailMailboxGeneration += 1; - 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._libOffset = 0; - state._libSearch = ''; - state._libSearchDraft = ''; - // Reset select-mode on each open so the toolbar Select button - // never opens already-toggled-on after a previous session. - state._selectMode = false; - if (state._selectedUids) state._selectedUids.clear(); - state._libSearchPills = []; - _libSuggestionCache = null; - 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(); - } else if (!state._libAccountId) { - const rememberedAccount = _rememberedEmailAccountId(); - if (rememberedAccount) { - state._libAccountId = rememberedAccount; - _publishActiveAccount(); - } - } - state._libViewInlineImages = _readEmailInlineImagesPreference(); - if (opts.folder) state._libFolder = opts.folder; - state._libPendingExpandUid = opts.uid || null; - - const sameMailbox = previousLibraryWasOpen - && previousAccountId === (state._libAccountId || '') - && previousFolder === (state._libFolder || 'INBOX') - && previousFilter === 'all' - && !previousSearch - && previousEmails.length > 0; - state._libEmails = sameMailbox ? previousEmails : []; - state._libTotal = sameMailbox ? Math.max(state._libTotal || 0, previousEmails.length) : 0; - - const modal = document.createElement('div'); - modal.className = 'modal'; - modal.id = 'email-lib-modal'; - modal.innerHTML = ` - - `; - - document.body.appendChild(modal); - modal.style.display = 'block'; - _syncEmailAutoReplyTitle(!!state._libAutoReplyActive); - _refreshUnreadBadge({ syncAutoReplyTitle: true }).catch(() => {}); - document.getElementById('email-lib-grid')?.scrollTo(0, 0); - modal.querySelector('.email-settings-body')?.scrollTo(0, 0); - _renderEmailSyncStatus(); - if (_libSyncTicker) clearInterval(_libSyncTicker); - _libSyncTicker = setInterval(_renderEmailSyncStatus, 30000); - // 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(); - if (unreadBadge.dataset.mode === 'away') { - _openGlobalEmailSettings('auto-reply'); - return; - } - _toggleUnreadEmails(); - }); - unreadBadge?.addEventListener('keydown', (e) => { - if (e.key !== 'Enter' && e.key !== ' ') return; - e.preventDefault(); - if (unreadBadge.dataset.mode === 'away') { - _openGlobalEmailSettings('auto-reply'); - return; - } - _toggleUnreadEmails(); - }); - const autoReplyBadge = document.getElementById('email-lib-auto-reply-badge'); - const openAutoReplySettings = (e) => { - e.stopPropagation(); - _openGlobalEmailSettings('auto-reply'); - }; - autoReplyBadge?.addEventListener('click', openAutoReplySettings); - autoReplyBadge?.addEventListener('keydown', (e) => { - if (e.key !== 'Enter' && e.key !== ' ') return; - e.preventDefault(); - openAutoReplySettings(e); - }); - 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); - document.getElementById('email-settings-header-back')?.addEventListener('click', _hideEmailSettingsPage); - - // 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 = _resolveEmailFolderAlias(e.target.value); - if (e.target.value !== state._libFolder) e.target.value = state._libFolder; - _renderFolderPicker(); - _renderSearchPills(); - _loadEmailsFresh(); - }); - document.getElementById('email-lib-filter').addEventListener('change', (e) => { - state._libFilter = e.target.value; - _syncUnreadWindowGlow(); - _syncReminderClearButton(); - _renderSearchPills(); - _loadEmailsFresh(); - _syncSearchOptionsMenu(); - // Mirror the picker label/icon. - _renderFilterPickerCurrent(); - }); - _initFilterPicker(); - _initFolderPicker(); - const searchOptionsBtn = document.getElementById('email-search-options-btn'); - const searchOptionsMenu = document.getElementById('email-search-options-menu'); - searchOptionsBtn?.addEventListener('click', (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - const shouldOpen = !!searchOptionsMenu?.hidden; - if (searchOptionsMenu) searchOptionsMenu.hidden = !shouldOpen; - searchOptionsBtn.setAttribute('aria-expanded', String(shouldOpen)); - _syncSearchOptionsMenu(); - }); - searchOptionsMenu?.addEventListener('click', (ev) => { - const item = ev.target.closest('[data-email-search-option]'); - if (!item) return; - ev.preventDefault(); - ev.stopPropagation(); - const opt = item.dataset.emailSearchOption; - if (opt === 'attachments') _toggleEmailAttachmentFilter(); - else if (opt === 'undone' || opt === 'reminders') _setEmailListFilter(opt); - else if (opt === 'date') { - const panel = searchOptionsMenu.querySelector('.email-search-date-panel'); - panel.hidden = !panel.hidden; - const from = panel.querySelector('#email-search-date-from'); - const to = panel.querySelector('#email-search-date-to'); - if (from) from.value = state._libDateFrom || ''; - if (to) to.value = state._libDateTo || ''; - } - searchOptionsMenu.hidden = false; - searchOptionsBtn?.setAttribute('aria-expanded', 'true'); - }); - searchOptionsMenu?.querySelector('[data-email-date-apply]')?.addEventListener('click', (ev) => { - ev.preventDefault(); ev.stopPropagation(); - const nextFrom = searchOptionsMenu.querySelector('#email-search-date-from')?.value || ''; - const nextTo = searchOptionsMenu.querySelector('#email-search-date-to')?.value || ''; - if (nextFrom && nextTo && nextFrom > nextTo) { - showToast('From date must be before To date'); - return; - } - state._libDateFrom = nextFrom; - state._libDateTo = nextTo; - _syncSearchOptionsMenu(); - _renderSearchPills(); - _loadEmailsFresh({ useCache: false }); - searchOptionsMenu.querySelector('.email-search-date-panel').hidden = true; - }); - searchOptionsMenu?.querySelector('[data-email-date-clear]')?.addEventListener('click', (ev) => { - ev.preventDefault(); ev.stopPropagation(); - state._libDateFrom = ''; - state._libDateTo = ''; - _syncSearchOptionsMenu(); - _renderSearchPills(); - _loadEmailsFresh({ useCache: false }); - searchOptionsMenu.querySelector('.email-search-date-panel').hidden = true; - }); - if (state._emailSearchOptionsDismiss) { - document.removeEventListener('click', state._emailSearchOptionsDismiss); - } - state._emailSearchOptionsDismiss = (ev) => { - if (!searchOptionsMenu || searchOptionsMenu.hidden) return; - if (ev.target.closest('#email-search-options-menu, #email-search-options-btn')) return; - searchOptionsMenu.hidden = true; - searchOptionsBtn?.setAttribute('aria-expanded', 'false'); - }; - document.addEventListener('click', state._emailSearchOptionsDismiss); - _syncSearchOptionsMenu(); - 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'; - _renderFilterPickerCurrent(); - _syncSearchOptionsMenu(); - _syncReminderClearButton(); - _renderSearchPills(); - _loadEmailsFresh(); - } catch (err) { - console.error(err); - showToast('Failed to clear reminder emails'); - } - }); - // 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. - - // Chip-bar search: pills represent contact + free-text filters; the live - // input below drives the autocomplete dropdown. Old behavior — instant - // local filter on every keystroke + server-side IMAP search after 350ms - // — is replaced by deterministic local filtering against the snapshot. - _initEmailSearchChipBar(); - - document.getElementById('email-lib-refresh-btn').addEventListener('click', async () => { - await _refreshEmailLibraryFromUi(document.getElementById('email-lib-refresh-btn')); - }); - _initMobileEmailPullRefresh(); - document.getElementById('email-lib-settings-btn')?.addEventListener('click', (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - _openGlobalEmailSettings('show-tags'); - }); - - - 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 — icon + label swap matches the brain memories - // select button (dot+Select ↔ X+Cancel). - const _SELECT_BTN_DOT_SVG = ''; - const _SELECT_BTN_X_SVG = ''; - const _setSelectBtnState = (on) => { - const btn = document.getElementById('email-lib-select-btn'); - if (!btn) return; - if (on) { btn.classList.add('active'); btn.innerHTML = _SELECT_BTN_X_SVG + 'Cancel'; } - else { btn.classList.remove('active'); btn.innerHTML = _SELECT_BTN_DOT_SVG + 'Select'; } - }; - document.getElementById('email-lib-select-btn').addEventListener('click', () => { - state._selectMode = !state._selectMode; - state._selectedUids.clear(); - _setSelectBtnState(state._selectMode); - _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(); - _setSelectBtnState(false); - _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'); - }); - - const selectExpandedEmailText = () => { - const expanded = document.querySelector('#email-lib-modal .doclib-card.doclib-card-expanded'); - const reader = expanded?.querySelector('.email-card-reader') || expanded; - return _selectEmailReaderContents(reader); - }; - - // ESC to close + Arrow nav + Delete on the selected / currently-expanded email. - state._libEscHandler = (e) => { - const modal = document.getElementById('email-lib-modal'); - if (!modal || modal.classList.contains('hidden')) return; - if ((e.ctrlKey || e.metaKey) && String(e.key || '').toLowerCase() === 'a') { - const t = e.target; - if (_isEmailTypingTarget(t)) return; - if (selectExpandedEmailText()) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation?.(); - } - return; - } - if (e.key === 'Escape') { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation?.(); - if (modal.classList.contains('email-settings-mode')) { - _hideEmailSettingsPage(); - return; - } - if (state._selectMode) { - state._selectMode = false; - state._selectedUids.clear(); - _setSelectBtnState(false); - _updateBulkBar(); - _renderGrid(); - return; - } - const expanded = modal.querySelector('.doclib-card.doclib-card-expanded'); - if (expanded) { - _exitEmailReaderModeForList(); - expanded.focus?.({ preventScroll: true }); - return; - } - if (modal.classList.contains('email-reading')) { - _exitEmailReaderModeForList(); - return; - } - closeEmailLibrary(); - return; - } - // Don't hijack arrows / delete while the user is typing somewhere. - const t = e.target; - if (_isEmailTypingTarget(t)) 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, true); - - // The global UI Escape arbiter is a document-level capture listener and can - // close the hovered modal before this library handler sees the event. Give - // email's inner layers a window-level first pass so Escape closes exactly - // one inner state before the library itself is dismissed. - state._libInnerEscHandler = (e) => { - if (e.key !== 'Escape') return; - const modal = document.getElementById('email-lib-modal'); - if (!modal || modal.classList.contains('hidden')) return; - if (dismissTopMenu()) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation?.(); - return; - } - if (modal.classList.contains('email-settings-mode')) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation?.(); - _hideEmailSettingsPage(); - return; - } - if (state._selectMode) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation?.(); - state._selectMode = false; - state._selectedUids.clear(); - _setSelectBtnState(false); - _updateBulkBar(); - _renderGrid(); - return; - } - const expanded = modal.querySelector('.doclib-card.doclib-card-expanded'); - if (expanded || modal.classList.contains('email-reading')) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation?.(); - _exitEmailReaderModeForList(); - } - }; - window.addEventListener('keydown', state._libInnerEscHandler, true); - - const grid = document.getElementById('email-lib-grid'); - if (grid && !grid.children.length) _renderEmailLoading(grid); - if (Array.isArray(state._libAccounts) && state._libAccounts.length) { - _renderAccountsStrip(); - } else { - _renderAccountsLoading(); - } - const fastAccountAtOpen = state._libAccountId || ''; - if (fastAccountAtOpen) { - _loadEmails({ useCache: true }); - } - // If we already know the previous/default account, paint that inbox first - // from the durable index and validate accounts in parallel. Cold refreshes - // otherwise waited on `/accounts` before even trying the cheap indexed list. - (async () => { - await _loadAccounts(); - _refreshUnreadBadge().catch(() => {}); - _loadFolders(); - _loadEmailReminderBellVisibility(); - if (!fastAccountAtOpen || fastAccountAtOpen !== (state._libAccountId || '')) { - _loadEmails({ useCache: true }); - } - })(); -} - -async function _loadAccounts({ force = false } = {}) { - const hasCachedAccounts = Array.isArray(state._libAccounts) && state._libAccounts.length; - const accountsFresh = _libAccountsLoadedAt && (Date.now() - _libAccountsLoadedAt) < _LIB_ACCOUNTS_TTL_MS; - if (!force && hasCachedAccounts && accountsFresh) { - if (!state._libAccountId) { - const def = state._libAccounts.find(a => a.is_default) || state._libAccounts[0]; - state._libAccountId = def?.id || null; - _publishActiveAccount(); - } - state._libViewInlineImages = _readEmailInlineImagesPreference(); - _renderAccountsStrip(); - return; - } - try { - const r = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' }); - if (!r.ok) return; - const d = await r.json(); - state._libAccounts = d.accounts || []; - _libAccountsLoadedAt = Date.now(); - } catch (_) { - if (!hasCachedAccounts) state._libAccounts = []; - } - // The 'Default' chip is gone — pick an explicit account so the email - // list and any per-email actions (open in new tab, mark read, etc.) - // always carry an account_id and can't desync from the server's - // is_default state. - if (state._libAccountId && state._libAccounts.length && !state._libAccounts.some(a => a && a.id === state._libAccountId)) { - state._libAccountId = null; - } - if (!state._libAccountId && state._libAccounts.length) { - const def = state._libAccounts.find(a => a.is_default) || state._libAccounts[0]; - state._libAccountId = def.id; - _publishActiveAccount(); - } - state._libViewInlineImages = _readEmailInlineImagesPreference(); - _renderAccountsStrip(); - _refreshAccountUnreadHighlights().catch(() => {}); -} - -function _renderAccountsStrip() { - const strip = document.getElementById('email-lib-accounts'); - if (!strip) return; - strip.style.display = 'flex'; - const esc = s => String(s || '').replace(/&/g, '&').replace(/ Number(!!b.is_default) - Number(!!a.is_default)); - const nameCounts = new Map(); - accounts.forEach(a => { - const name = String(a.name || '').trim(); - if (name) nameCounts.set(name, (nameCounts.get(name) || 0) + 1); - }); - for (const a of accounts) { - const active = state._libAccountId === a.id ? ' active' : ''; - const accountAddress = a.from_address || a.imap_user || ''; - const accountName = String(a.name || '').trim(); - const accountLabel = accountName && nameCounts.get(accountName) > 1 && accountAddress - ? `${accountName} · ${accountAddress}` - : (accountName || accountAddress || 'account'); - const unread = _accountUnreadState.get(String(a.id || '')) || {}; - const unreadCount = Number(unread.unreadCount || 0); - const unreadClass = unreadCount > 0 ? ' email-account-has-unread' : ''; - const unreadTitle = unreadCount > 0 ? ` · ${unreadCount > 999 ? '999+' : unreadCount} unread` : ''; - const away = state._libAutoReplyActive && state._libAccountId === a.id; - const label = away ? (accountAddress || accountLabel) : accountLabel; - const awayLabel = away ? '(AWAY)' : ''; - const unreadDot = unreadCount > 0 - ? `` - : ''; - html += `` - + `` - + ``; - } - strip.innerHTML = html; - strip.querySelectorAll('button[data-acc-id]').forEach(btn => { - btn.addEventListener('click', async () => { - const nextAccountId = btn.dataset.accId || null; - if ((state._libAccountId || null) === nextAccountId) return; - // Tag filters belong to the current mailbox account. Keeping one while - // switching accounts can show an empty or misleading result set. - if (String(state._libFilter || '').startsWith('tag:')) { - state._libFilter = 'all'; - const filterEl = document.getElementById('email-lib-filter'); - if (filterEl) filterEl.value = 'all'; - } - state._libSearchPills = (state._libSearchPills || []).filter((pill) => { - const value = String(pill?.value || pill?.text || ''); - return pill?.type !== 'filter' || !value.includes('tag:'); - }); - state._libAccountId = nextAccountId; - state._libViewInlineImages = _readEmailInlineImagesPreference(nextAccountId); - _publishActiveAccount(); - _renderAccountsStrip(); - _renderFilterPickerCurrent(); - _syncSearchOptionsMenu(); - _renderSearchPills(); - _loadEmailsFresh({ force: true, useCache: true, refreshAfterCache: true, showRefreshSpinner: true }); - _loadFolders({ resetMissing: true }).catch(() => {}); - _refreshUnreadBadge({ syncAutoReplyTitle: true }).catch(() => {}); - _refreshAccountUnreadHighlights().catch(() => {}); - }); - }); - // Idempotent — wire wheel + grab-drag scroll once per strip element. - if (!strip._scrollWired) { - strip._scrollWired = true; - // Vertical wheel → horizontal scroll. Only intercept when there's - // actually horizontal overflow to scroll through, otherwise let the - // page do its normal vertical scroll. - strip.addEventListener('wheel', (e) => { - if (strip.scrollWidth <= strip.clientWidth) return; - if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; - e.preventDefault(); - strip.scrollLeft += e.deltaY; - }, { passive: false }); - // Click-and-drag scroll. Track mousedown, then mousemove deltas - // bump scrollLeft. Cancel a chip click if the user actually dragged - // more than a few pixels. - let dragging = false; - let startX = 0; - let startScroll = 0; - let moved = 0; - strip.style.cursor = 'grab'; - strip.addEventListener('mousedown', (e) => { - if (e.button !== 0) return; - dragging = true; - moved = 0; - startX = e.pageX; - startScroll = strip.scrollLeft; - strip.style.cursor = 'grabbing'; - strip.style.userSelect = 'none'; - }); - window.addEventListener('mousemove', (e) => { - if (!dragging) return; - const dx = e.pageX - startX; - moved = Math.max(moved, Math.abs(dx)); - strip.scrollLeft = startScroll - dx; - }); - window.addEventListener('mouseup', () => { - if (!dragging) return; - dragging = false; - strip.style.cursor = 'grab'; - strip.style.userSelect = ''; - }); - // Swallow chip clicks fired after a real drag — the user meant to scroll, - // not select. - strip.addEventListener('click', (e) => { - if (moved > 5) { e.stopPropagation(); e.preventDefault(); moved = 0; } - }, true); - } - _publishActiveAccount(); -} - -async function _refreshAccountUnreadHighlights() { - const accounts = Array.isArray(state._libAccounts) ? state._libAccounts.slice() : []; - if (!accounts.length) return; - const seq = ++_accountUnreadSeq; - const next = new Map(); - await Promise.all(accounts.map(async (a) => { - const id = String(a && a.id || ''); - if (!id) return; - try { - const res = await fetch(emailApiUrl('/api/email/unread-state', { - folder: 'INBOX', - account_id: id, - }), { credentials: 'same-origin' }); - if (!res.ok) return; - const data = await res.json().catch(() => ({})); - next.set(id, { - unreadCount: Number(data.unread_count || 0), - maxUid: Number(data.max_uid || 0), - }); - } catch (_) {} - })); - if (seq !== _accountUnreadSeq) return; - _accountUnreadState = next; - _renderAccountsStrip(); -} - -export async function mountEmailSettings(host) { - if (!host) return; - host.classList.add('email-settings-page'); - const head = host.querySelector('.email-settings-page-head'); - const body = host.querySelector('.email-settings-body'); - const defaultCard = document.getElementById('settings-email-default-card'); - const defaultHost = document.getElementById('settings-email-default-host'); - if (!head || !body) return; - - const render = async () => { - body.innerHTML = _emailSettingsLoadingHtml(); - try { - await _loadAccounts().catch(() => {}); - const multipleAccounts = (state._libAccounts || []).filter(a => a && a.enabled !== false).length > 1; - if (defaultCard) defaultCard.hidden = !multipleAccounts; - if (defaultHost) defaultHost.innerHTML = multipleAccounts ? _emailSettingsAccountSelectHtml() : ''; - head.innerHTML = ''; - const [cfg, writingStyle] = await Promise.all([ - _fetchEmailSettingsConfig(), - _fetchEmailWritingStyle().catch(() => ''), - ]); - body.innerHTML = _emailWritingStyleHtml(writingStyle) - + _emailDisplaySettingsHtml(cfg) - + _emailCleanupSettingsHtml() - + _emailSettingsFormHtml(cfg); - state._libAutoReplyActive = _isAutoReplyActiveForCurrentAccount(cfg); - state._libAutoReplyDraftActive = null; - _syncEmailAutoReplyTitle(state._libAutoReplyActive); - _syncAutoReplyCalendarEvent(cfg).catch((err) => console.warn('Failed to reconcile auto-reply calendar event:', err)); - const focus = host.dataset.emailSettingsFocus; - if (focus) { - const section = focus === 'show-tags' - ? body.querySelector('.email-settings-display-section') - : body.querySelector(`.email-settings-${focus}-section`); - if (section) { - section.scrollIntoView?.({ block: 'nearest' }); - } - delete host.dataset.emailSettingsFocus; - } - _bindEmailSettingsPageControls(host, defaultHost); - defaultHost?.querySelector('.email-settings-default')?.addEventListener('click', async (ev) => { - ev.stopPropagation(); - const acctId = ev.currentTarget.dataset.emailDefaultId; - const account = (state._libAccounts || []).find(a => a && a.id === acctId); - if (!acctId || account?.is_default) return; - try { - const response = await fetch(`${API_BASE}/api/email/accounts/${encodeURIComponent(acctId)}/set-default`, { - method: 'POST', credentials: 'same-origin', - }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - for (const a of state._libAccounts) a.is_default = a.id === acctId; - _syncEmailSettingsAccountPicker(defaultHost); - _renderAccountsStrip(); - } catch (err) { - console.error('Set default account failed:', err); - } - }); - defaultHost?.querySelector('.email-settings-default')?.addEventListener('keydown', (ev) => { - if (ev.key !== 'Enter' && ev.key !== ' ') return; - ev.preventDefault(); - ev.currentTarget.click(); - }); - defaultHost?.querySelector('#email-settings-account-select')?.addEventListener('change', async (ev) => { - state._libAccountId = ev.currentTarget.value || null; - _publishActiveAccount(); - _renderAccountsStrip(); - await render(); - }); - } catch (_) { - body.innerHTML = '
Could not load settings.
'; - } - }; - await render(); -} - -export async function openEmailLibrarySettings() { - openEmailLibrary(); - await _showEmailSettingsPage(); -} - -export function closeEmailLibrary() { - _cancelEmailPrewarm(); - const modal = document.getElementById('email-lib-modal'); - if (modal) modal.remove(); - if (_libSyncTicker) { - clearInterval(_libSyncTicker); - _libSyncTicker = null; - } - _clearEmailDocumentSplit(); - if (state._libEscHandler) { - document.removeEventListener('keydown', state._libEscHandler, true); - state._libEscHandler = null; - } - if (state._libInnerEscHandler) { - window.removeEventListener('keydown', state._libInnerEscHandler, true); - state._libInnerEscHandler = null; - } - if (state._emailSearchOptionsDismiss) { - document.removeEventListener('click', state._emailSearchOptionsDismiss); - state._emailSearchOptionsDismiss = 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 - onDragStart: ({ rect }) => { - if (!modal.classList.contains('email-snap-left')) return; - modal.classList.remove('email-snap-left'); - _clearEmailDocumentSplit(); - content.style.position = 'fixed'; - content.style.left = `${Math.round(rect.left)}px`; - content.style.top = `${Math.round(rect.top)}px`; - content.style.right = ''; - content.style.bottom = ''; - content.style.width = `${Math.max(420, Math.round(rect.width || 560))}px`; - content.style.maxWidth = ''; - content.style.height = `${Math.max(320, Math.round(rect.height || 620))}px`; - content.style.maxHeight = '85vh'; - content.style.borderRadius = ''; - content.style.transform = 'none'; - content.style.margin = '0'; - }, - 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; - // "Open in new tab" reader modals (id="email-view-…") are explicitly - // floating windows the user already positioned. Replying from one - // shouldn't yank it to the left edge — leave it on top in its current - // spot. Reply still opens the compose document; the user can drag the - // reader away or close it themselves. - if ((modal.id || '').startsWith('email-view-')) 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, live = false } = {}) { - const seq = ++_libFolderSeq; - const accountAtStart = state._libAccountId || ''; - try { - const res = await fetch(emailApiUrl('/api/email/folders', { - account_id: accountAtStart || undefined, - cached_only: live ? undefined : 1, - })); - let 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; - const resolvedFolder = _resolveEmailFolderAlias(state._libFolder); - if (resolvedFolder !== state._libFolder && data.folders.includes(resolvedFolder)) { - state._libFolder = resolvedFolder; - } - 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 folderHeader = document.createElement('option'); - folderHeader.disabled = true; - folderHeader.textContent = '─────────'; - sel.appendChild(folderHeader); - 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); - } - 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); - } - if (!data.folders.some(f => /trash|bin|deleted/i.test(String(f)))) { - const trashOpt = document.createElement('option'); - trashOpt.value = 'Trash'; - trashOpt.textContent = 'Trash'; - if (String(state._libFolder).toLowerCase() === 'trash') trashOpt.selected = true; - sel.appendChild(trashOpt); - } - // Some providers omit Drafts from the folder discovery response even - // though IMAP APPEND can still write to the standard Drafts mailbox. - // Keep it available beside the provider-discovered folders. - const hasDraftsFolder = data.folders.some(f => String(f).toLowerCase().includes('draft')); - if (!hasDraftsFolder) { - const draftOpt = document.createElement('option'); - draftOpt.value = 'Drafts'; - draftOpt.textContent = 'Drafts'; - if (String(state._libFolder).toLowerCase() === 'drafts') draftOpt.selected = true; - sel.appendChild(draftOpt); - } - // Scheduled (special virtual folder) - 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; - _renderFolderPicker(); - } 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))); -} - -function _findEmailFolder(patterns, fallback) { - const available = Array.isArray(state._libFolders) ? state._libFolders.filter(Boolean) : []; - const lower = new Map(available.map(f => [String(f).toLowerCase(), f])); - for (const p of patterns) { - const direct = lower.get(String(p).toLowerCase()); - if (direct) return direct; - } - return available.find(f => patterns.some(p => String(f).toLowerCase().includes(String(p).toLowerCase()))) || fallback; -} - -function _resolveEmailFolderAlias(folder) { - const raw = String(folder || '').trim(); - const key = raw.toLowerCase(); - if (!raw || key === 'inbox' || raw === '__scheduled__') return raw || 'INBOX'; - if (key === 'sent' || key === 'sent mail' || key === 'sent items') { - return _findEmailFolder(['[Gmail]/Sent Mail', '[Google Mail]/Sent Mail', 'Sent Mail', 'Sent Items', 'INBOX.Sent', 'Sent'], raw); - } - if (key === 'archive' || key === 'archives' || key === 'all mail' || key === 'archive / all mail') { - return _findEmailFolder(['[Gmail]/All Mail', '[Google Mail]/All Mail', 'All Mail', 'Archive', 'Archives'], raw); - } - if (key === 'starred' || key === 'favorites' || key === 'flagged') { - return _findEmailFolder(['[Gmail]/Starred', '[Google Mail]/Starred', 'Starred', 'Flagged'], raw); - } - if (key === 'junk' || key === 'spam') { - return _findEmailFolder(['[Gmail]/Spam', '[Google Mail]/Spam', 'Spam', 'Junk'], raw); - } - if (key === 'trash' || key === 'bin' || key === 'deleted') { - return _findEmailFolder(['[Gmail]/Trash', '[Google Mail]/Trash', '[Gmail]/Bin', 'Trash', 'Bin', 'Deleted Messages', 'Deleted Items'], raw); - } - if (key === 'draft' || key === 'drafts') { - return _findEmailFolder(['[Gmail]/Drafts', '[Google Mail]/Drafts', 'Drafts', 'Draft', 'INBOX.Drafts'], raw); - } - return raw; -} - -function _sentFolderName() { - return _findEmailFolder(['[Gmail]/Sent Mail', 'Sent Mail', 'Sent Items', 'INBOX.Sent', 'Sent'], 'Sent'); -} - -function _deriveSearchScope(rawQuery) { - const original = String(rawQuery || '').trim(); - const tokens = original.split(/\s+/).filter(Boolean); - let scope = 'all'; - const kept = []; - let forced = ''; - for (const token of tokens) { - const t = token.toLowerCase().replace(/^#+/, '').replace(/:$/, ''); - if (['sent', 'sentmail', 'sent-mail', 'outbox'].includes(t)) { - forced = 'sent'; - continue; - } - if (['inbox'].includes(t)) { - forced = 'inbox'; - continue; - } - kept.push(token); - } - if (forced) scope = forced; - let folder = 'INBOX'; - let serverScope = 'all'; - if (scope === 'sent') { - folder = _sentFolderName(); - serverScope = 'folder'; - } else if (scope === 'inbox') { - folder = 'INBOX'; - serverScope = 'folder'; - } else if (scope === 'current') { - folder = state._libFolder || 'INBOX'; - serverScope = 'folder'; - } - return { - scope, - folder, - serverScope, - q: forced ? kept.join(' ').trim() : original, - forced, - }; -} - -// Snapshot of state._libEmails taken right before search starts so we -// can both filter locally and restore on clear without re-fetching. -let _libPreSearchEmails = null; -let _libPreSearchTotal = 0; -let _libServerSearchEmails = null; -let _libServerSearchTotal = 0; - -// Cached contact suggestions for the chip-input autocomplete. Built on -// first focus / first keystroke from contacts + currently-loaded senders. -let _libSuggestionCache = null; -let _libSuggestionFocusIdx = 0; - -async function _buildSuggestionSource() { - // Combine the contacts list with senders/recipients visible in the - // loaded email list. Dedup by lowercased email address; prefer - // contact-supplied display names where present. - const map = new Map(); - const _add = (name, email) => { - const key = String(email || '').trim().toLowerCase(); - if (!key) return; - const prev = map.get(key); - if (!prev || (name && !prev.name)) { - map.set(key, { name: (name || '').trim(), email: key }); - } - }; - // 1) Senders / recipients already in the loaded grid. - for (const em of (state._libEmails || [])) { - _add(em.from_name, em.from_address); - const _parse = (s) => String(s || '').split(',').forEach(seg => { - const m = seg.match(/^\s*"?([^"<]*)"?\s*]+)>?\s*$/); - if (m) _add(m[1], m[2]); - }); - _parse(em.to); - _parse(em.cc); - } - // 2) Address book — best-effort. - try { - const r = await fetch(`${API_BASE}/api/contacts/list`, { credentials: 'same-origin' }); - if (r.ok) { - const d = await r.json(); - for (const c of (d.contacts || [])) { - const email = c.email || (c.emails && c.emails[0]) || ''; - _add(c.name || c.full_name, email); - } - } - } catch (_) {} - return Array.from(map.values()).filter(x => x.email); -} - -function _scoreSuggestion(s, needle) { - // Crude relevance: startsWith on name or email wins big; substring is fine. - const n = (s.name || '').toLowerCase(); - const e = (s.email || '').toLowerCase(); - if (n.startsWith(needle) || e.startsWith(needle)) return 3; - if (n.includes(needle) || e.includes(needle)) return 2; - return 0; -} - -function _formatEmailSuggestionDate(em) { - let d = null; - if (em && em.date) { - const parsed = new Date(em.date); - if (Number.isFinite(parsed.getTime())) d = parsed; - } - if (!d && em && em.date_epoch) { - const parsed = new Date(Number(em.date_epoch) * 1000); - if (Number.isFinite(parsed.getTime())) d = parsed; - } - if (!d) return ''; - const now = new Date(); - const opts = d.getFullYear() === now.getFullYear() - ? { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' } - : { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }; - return d.toLocaleDateString(undefined, opts); -} - -// Filter / attachment suggestions surfaced inside the same chip-bar -// dropdown. Typing 'attachment', 'unread', 'urgent' etc. surfaces the -// corresponding filter row with its icon; picking it pins a filter -// pill that drives state._libFilter or the has-attachments toggle. -const _LIB_FILTER_OPTIONS = [ - { value: 'filter:has-attachments', label: 'Has attachments', keywords: ['attachment', 'attachments', 'has attachment', 'attach'] }, - { value: 'filter:unread', label: 'Unread', keywords: ['unread', 'new', 'unseen'] }, - { value: 'filter:favorites', label: 'Favorites', keywords: ['favorite', 'favorites', 'starred', 'star', 'flagged'] }, - { value: 'filter:undone', label: 'Undone', keywords: ['undone', 'pending', 'todo'] }, - { value: 'filter:reminders', label: 'Reminders', keywords: ['reminder', 'reminders'] }, - { value: 'filter:unanswered', label: 'Unanswered', keywords: ['unanswered', 'unreplied', 'no reply'] }, - { value: 'filter:pending_30d', label: 'Pending · 30d', keywords: ['pending 30d', 'pending', 'recent pending'] }, - { value: 'filter:stale_30d', label: 'Stale · >30d', keywords: ['stale', 'old', 'stale 30d'] }, - { value: 'filter:tag:urgent', label: 'Urgent', keywords: ['urgent', 'critical'] }, - { value: 'filter:tag:reply-soon', label: 'Reply soon', keywords: ['reply soon', 'reply', 'follow up'] }, - { value: 'filter:tag:bills', label: 'Bills', keywords: ['bill', 'bills', 'billing'] }, - { value: 'filter:tag:receipt', label: 'Receipt', keywords: ['receipt', 'receipts', 'purchase'] }, - { value: 'filter:tag:travel', label: 'Travel', keywords: ['travel', 'trip', 'booking'] }, - { value: 'filter:tag:spam', label: 'Spam', keywords: ['spam', 'junk'] }, -]; - -function _libFilterIconFor(value) { - // value is 'filter:' — strip prefix and reuse the existing icon map. - const v = String(value || '').replace(/^filter:/, ''); - if (v === 'has-attachments') return ''; - if (v === 'date-range') return ''; - return _EMAIL_FILTER_ICONS[v] || _EMAIL_FILTER_ICONS['all']; -} - -function _scoreFilterOption(opt, needle) { - for (const kw of opt.keywords) { - if (kw === needle) return 4; - if (kw.startsWith(needle)) return 3; - if (kw.includes(needle)) return 2; - } - if (opt.label.toLowerCase().includes(needle)) return 2; - return 0; -} - -function _filterSuggestions(needle, limit = 10) { - const n = String(needle || '').trim().toLowerCase(); - if (!n) return []; - // Filter / attachment matches first — typing 'unread' should surface - // the filter row before contact suggestions, since 'unread' isn't a - // person. - const filterMatches = _LIB_FILTER_OPTIONS - .map(opt => ({ s: { kind: 'filter', value: opt.value, label: opt.label, icon: _libFilterIconFor(opt.value) }, score: _scoreFilterOption(opt, n) })) - .filter(x => x.score > 0); - const src = _libSuggestionCache || []; - const contactMatches = src - .map(s => ({ s: { kind: 'contact', ...s }, score: _scoreSuggestion(s, n) })) - .filter(x => x.score > 0); - // Email subject / sender-name matches — use the snapshot (unfiltered - // list) when available so suggestions don't shrink as pills narrow the - // visible grid. Cap to 4 so contacts + filters stay visible. - const emails = _libPreSearchEmails || state._libEmails || []; - const emailMatches = []; - for (const em of emails) { - const subj = String(em.subject || '').toLowerCase(); - const fromN = String(em.from_name || '').toLowerCase(); - let score = 0; - if (subj.startsWith(n) || fromN.startsWith(n)) score = 3; - else if (subj.includes(n) || fromN.includes(n)) score = 1; - if (score > 0) { - emailMatches.push({ - s: { - kind: 'email', - uid: em.uid, - subject: em.subject || '(no subject)', - from_name: em.from_name || em.from_address || '', - date_label: _formatEmailSuggestionDate(em), - }, - score, - }); - } - if (emailMatches.length >= 4) break; - } - return filterMatches.concat(contactMatches).concat(emailMatches) - .sort((a, b) => b.score - a.score) - .slice(0, limit) - .map(x => x.s); -} - -function _exactTypedFilterSuggestion(value) { - const needle = String(value || '').trim().toLowerCase(); - if (!needle) return null; - // Keep automatic conversion deliberately exact. A query such as - // "attachment invoice" should remain a text search, while the common - // one-word shortcut should become the existing filter pill. - const option = _LIB_FILTER_OPTIONS.find(opt => ( - opt.value === 'filter:has-attachments' && opt.keywords.includes(needle) - )); - return option - ? { kind: 'filter', value: option.value, label: option.label, icon: _libFilterIconFor(option.value) } - : null; -} - -function _emailMatchesPill(em, pill) { - if (!pill) return false; - if (pill.type === 'contact') { - const target = (pill.email || '').toLowerCase(); - if (!target) return false; - if (String(em.from_address || '').toLowerCase() === target) return true; - if (String(em.to || '').toLowerCase().includes(target)) return true; - if (String(em.cc || '').toLowerCase().includes(target)) return true; - return false; - } - if (pill.type === 'filter') { - // Filter pills delegate to the server-side filter (state._libFilter) - // or the has-attachments toggle. The list is already pre-filtered by - // those when this runs, so the pill is effectively always-true here - // — it lives in the pill bar purely as a visible affordance. - return true; - } - // text pill — broad local-match - const q = (pill.text || '').toLowerCase(); - if (!q) return true; - return _matchesQuery(em, q); -} - -function _matchesQuery(em, q) { - const needle = q.toLowerCase(); - const dateNeedle = _formatEmailSuggestionDate(em).toLowerCase(); - const dateOnlyNeedle = dateNeedle.replace(/\s+\d{1,2}:\d{2}\s*(am|pm)?$/i, ''); - const rawDate = String(em.date || em.date_display || '').toLowerCase(); - return ( - String(em.subject || '').toLowerCase().includes(needle) || - String(em.from_name || '').toLowerCase().includes(needle) || - String(em.from_address || '').toLowerCase().includes(needle) || - String(em.to || '').toLowerCase().includes(needle) || - String(em.cc || '').toLowerCase().includes(needle) || - String(em.snippet || em.preview || '').toLowerCase().includes(needle) || - dateNeedle.includes(needle) || - dateOnlyNeedle.includes(needle) || - rawDate.includes(needle) - ); -} - -// Apply the active pill filter to the snapshot. Each pill is OR-ed; an -// email shows up if ANY pill matches (a contact pill matches by from/to/cc -// equality, a text pill matches by the broad _matchesQuery substring). -function _applyPillFilter() { - _exitEmailReaderModeForList(); - const pills = state._libSearchPills || []; - const draft = (state._libSearchDraft || '').trim(); - const noPills = pills.length === 0; - const noDraft = draft.length === 0; - // First time we apply with anything active: snapshot the loaded list. - if (!noPills || draft.length >= 1) { - if (!_libPreSearchEmails) { - _libPreSearchEmails = (state._libEmails || []).slice(); - _libPreSearchTotal = state._libTotal; - } - } - if (noPills && noDraft) { - if (_libPreSearchEmails) { - state._libEmails = _libPreSearchEmails; - state._libTotal = _libPreSearchTotal; - _libPreSearchEmails = null; - _libPreSearchTotal = 0; - } - _renderGrid(); - return; - } - const source = _libServerSearchEmails || _libPreSearchEmails || state._libEmails || []; - // If the active server search covers a piece of text (either the live - // draft OR an Enter-committed text pill), skip the local re-filter for - // it — _emailMatchesPill only checks subject/from_name/from_address/ - // snippet (no BODY), so it was dropping legitimate server hits where - // the match was in body text. Real pills (contact, filter chips) still - // apply, and other text pills with different strings still apply. - const libSearchLower = (_libSearchHadResults ? (state._libSearch || '').trim().toLowerCase() : ''); - const hasRefinementBase = !!(_libServerSearchEmails && pills.length > 1); - const serverHandledDraft = !hasRefinementBase && !!(libSearchLower && draft && libSearchLower === draft.toLowerCase()); - const draftPill = (!serverHandledDraft && draft.length >= 1) ? { type: 'text', text: draft } : null; - // Filter out text pills whose text matches the active server search — - // those were the trigger for the IMAP query and don't need re-checking. - const effectiveBasePills = (libSearchLower && !hasRefinementBase) - ? pills.filter(p => !(p.type === 'text' && (p.text || '').toLowerCase() === libSearchLower)) - : pills; - const effective = draftPill ? effectiveBasePills.concat([draftPill]) : effectiveBasePills; - // AND across pills — "alice + bob" should mean both alice AND bob are - // somewhere on the email (from/to/cc), not "from alice OR from bob". - const filtered = source.filter(em => effective.every(p => _emailMatchesPill(em, p))); - state._libEmails = filtered; - _renderGrid(); -} -// Back-compat shim: older call sites still expect _localSearchFilter. -function _localSearchFilter(query) { - state._libSearchDraft = String(query || ''); - _applyPillFilter(); -} - -// Render the active pills inside the chip bar. Each pill carries a × to -// remove individually. Backspace on empty input also pops the last one. -function _emailFilterLabelFor(value) { - const v = String(value || ''); - if (v === 'has-attachments') return 'Attachments'; - const sel = document.getElementById('email-lib-filter'); - const opt = sel?.querySelector?.(`option[value="${CSS.escape(v)}"]`); - if (opt) return opt.textContent || v; - const match = _LIB_FILTER_OPTIONS.find(o => o.value === `filter:${v}`); - return match?.label || v; -} - -function _activeEmailFilterPills() { - const active = []; - if (state._libHasAttachments) { - active.push({ value: 'has-attachments', label: _emailFilterLabelFor('has-attachments') }); - } - const filter = String(state._libFilter || 'all'); - if (filter && filter !== 'all') { - active.push({ value: filter, label: _emailFilterLabelFor(filter) }); - } - if (state._libDateFrom || state._libDateTo) { - const range = state._libDateFrom && state._libDateTo - ? `${state._libDateFrom} – ${state._libDateTo}` - : state._libDateFrom ? `From ${state._libDateFrom}` : `Through ${state._libDateTo}`; - active.push({ value: 'date-range', label: range }); - } - return active; -} - -function _clearActiveEmailFilterPill(value) { - _resetBulkSelectionForContextChange({ rerender: true }); - const v = String(value || ''); - if (v === 'has-attachments') { - state._libHasAttachments = false; - } else if (v === 'date-range') { - state._libDateFrom = ''; - state._libDateTo = ''; - } else if (state._libFilter === v) { - state._libFilter = 'all'; - const sel = document.getElementById('email-lib-filter'); - if (sel) sel.value = 'all'; - } - _syncUnreadWindowGlow(); - _syncReminderClearButton(); - _renderFilterPickerCurrent(); - _syncSearchOptionsMenu(); - _renderSearchPills(); - _loadEmailsFresh(); -} - -function _renderSearchPills() { - const wrap = document.getElementById('email-lib-pills'); - if (!wrap) return; - const pills = (state._libSearchPills || []).filter(p => p.type !== 'filter'); - state._libSearchPills = pills; - const folderSelect = document.getElementById('email-lib-folder'); - const folder = String(folderSelect?.value || state._libFolder || 'INBOX'); - const showingFolderPill = folder && folder !== 'INBOX' && folder !== '__scheduled__'; - const filterPills = _activeEmailFilterPills(); - const chipBar = document.getElementById('email-lib-chip-bar'); - const input = document.getElementById('email-lib-search'); - const visiblePillCount = pills.length + filterPills.length + (showingFolderPill ? 1 : 0); - if (chipBar) chipBar.classList.toggle('has-email-lib-pills', visiblePillCount > 0); - if (input) input.placeholder = visiblePillCount > 0 ? '' : 'Search by name or text'; - const esc = s => String(s || '').replace(/&/g, '&').replace(/ - ${esc(folderLabel)} - - ` - : ''; - const filterPillHtml = filterPills.map(p => { - const titleAttr = esc(p.label); - return ``; - }).join(''); - wrap.innerHTML = folderPill + filterPillHtml + pills.map((p, i) => { - const label = p.type === 'contact' ? (p.name || p.email || '?') : (p.text || ''); - return ``; - }).join(''); - requestAnimationFrame(() => { - if (wrap.scrollWidth <= wrap.clientWidth) return; - for (const value of ['has-attachments', 'reminders']) { - const pill = wrap.querySelector(`.email-lib-filter-pill[data-email-filter-pill="${value}"]`); - if (!pill) continue; - pill.classList.add('email-lib-filter-pill-icon-only'); - if (wrap.scrollWidth <= wrap.clientWidth) break; - } - }); - wrap.querySelectorAll('.email-lib-filter-pill[data-email-filter-pill="date-range"]').forEach(pill => { - pill.style.cursor = 'pointer'; - pill.addEventListener('click', (ev) => { - if (ev.target.closest('.email-lib-filter-pill-x')) return; - const menu = document.getElementById('email-search-options-menu'); - const trigger = document.getElementById('email-search-options-btn'); - const panel = menu?.querySelector('.email-search-date-panel'); - if (!menu || !panel) return; - menu.hidden = false; - panel.hidden = false; - trigger?.setAttribute('aria-expanded', 'true'); - const from = panel.querySelector('#email-search-date-from'); - const to = panel.querySelector('#email-search-date-to'); - if (from) from.value = state._libDateFrom || ''; - if (to) to.value = state._libDateTo || ''; - from?.focus(); - }); - }); - wrap.querySelectorAll('.email-lib-pill-x').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const idx = Number(btn.dataset.pillIdx); - if (Number.isFinite(idx)) _removeSearchPillAt(idx); - }); - }); - wrap.querySelectorAll('.email-lib-filter-pill-x').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - _clearActiveEmailFilterPill(btn.dataset.emailFilterPill || ''); - }); - }); - wrap.querySelectorAll('.email-lib-folder-pill-x').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - _resetBulkSelectionForContextChange({ rerender: true }); - const folders = Array.isArray(state._libFolders) ? state._libFolders : []; - state._libFolder = folders.includes('INBOX') ? 'INBOX' : (folders.find(f => !/sent/i.test(String(f || ''))) || 'INBOX'); - const folderSel = document.getElementById('email-lib-folder'); - if (folderSel) folderSel.value = state._libFolder; - _renderFolderPicker(); - _renderSearchPills(); - _loadEmailsFresh(); - }); - }); -} - -function _applyFilterPillSideEffect(pill) { - // Filter pills drive the existing has-attachments toggle / filter - // dropdown so the server returns the right list. Only one filter - // pill is active at a time (see _addSearchPill). - const sel = document.getElementById('email-lib-filter'); - if (pill.value === 'filter:has-attachments') { - if (!state._libHasAttachments) { - state._libHasAttachments = true; - _syncSearchOptionsMenu(); - } - if (sel && sel.value !== 'all') { sel.value = 'all'; sel.dispatchEvent(new Event('change')); } - return; - } - // Any other filter pill — set the dropdown value, clear attachments - if (state._libHasAttachments) { - state._libHasAttachments = false; - _syncSearchOptionsMenu(); - } - if (sel) { - const v = pill.value.replace(/^filter:/, ''); - if (sel.value !== v) { sel.value = v; sel.dispatchEvent(new Event('change')); } - } -} - -function _clearFilterPillSideEffect() { - const sel = document.getElementById('email-lib-filter'); - if (state._libHasAttachments) { - state._libHasAttachments = false; - _syncSearchOptionsMenu(); - } - if (sel && sel.value !== 'all') { - sel.value = 'all'; sel.dispatchEvent(new Event('change')); - } -} - -function _addSearchPill(pill) { - if (!pill) return; - _resetBulkSelectionForContextChange({ rerender: true }); - if (!Array.isArray(state._libSearchPills)) state._libSearchPills = []; - // Dedup by email (contact), text (text pill), or filter value. - if (pill.type === 'contact') { - const key = (pill.email || '').toLowerCase(); - if (!key) return; - if (state._libSearchPills.some(p => p.type === 'contact' && (p.email || '').toLowerCase() === key)) return; - } else if (pill.type === 'text') { - const t = (pill.text || '').toLowerCase(); - if (!t) return; - if (state._libSearchPills.some(p => p.type === 'text' && (p.text || '').toLowerCase() === t)) return; - } else if (pill.type === 'filter') { - // Filter state renders as a synthetic pill from state._libFilter / - // state._libHasAttachments. Do not store a duplicate search pill. - state._libSearchPills = state._libSearchPills.filter(p => p.type !== 'filter'); - _applyFilterPillSideEffect(pill); - _renderSearchPills(); - return; - } - state._libSearchPills.push(pill); - _renderSearchPills(); - _applyPillFilter(); -} - -function _searchQueryFromPills() { - const parts = []; - for (const p of state._libSearchPills || []) { - if (p.type === 'text' && p.text) parts.push(String(p.text).trim()); - else if (p.type === 'contact' && (p.email || p.name)) parts.push(String(p.email || p.name).trim()); - } - return parts.filter(Boolean).join(' ').trim(); -} - -function _removeSearchPillAt(idx) { - if (!Array.isArray(state._libSearchPills)) return; - _resetBulkSelectionForContextChange({ rerender: true }); - const removed = state._libSearchPills[idx]; - state._libSearchPills.splice(idx, 1); - if (removed && removed.type === 'filter') _clearFilterPillSideEffect(); - _renderSearchPills(); - // Pill cleared all the way: if we got into search-result mode via the - // IMAP search, the pre-search snapshot is now those results too (set - // in _doSearch). Restoring from it would leave the user staring at - // the same results with the pill bar empty. Re-fetch the real inbox - // so removing the last pill genuinely "goes back". - const noPillsLeft = (state._libSearchPills || []).length === 0 - && !(state._libSearchDraft || '').trim(); - if (noPillsLeft && _libSearchHadResults) { - _libSearchHadResults = false; - _libPreSearchEmails = null; - _libPreSearchTotal = 0; - _libServerSearchEmails = null; - _libServerSearchTotal = 0; - state._libSearch = ''; - state._libOffset = 0; - const _searchInput = document.getElementById('email-lib-search'); - if (_searchInput) _searchInput.value = ''; - _loadEmails({ useCache: true }); - return; - } - const remainingQuery = _searchQueryFromPills(); - if (remainingQuery.length >= 2) { - state._libSearch = remainingQuery; - const _searchInput = document.getElementById('email-lib-search'); - if (_searchInput) _searchInput.value = ''; - state._libSearchDraft = ''; - _doSearch(); - return; - } - if ((state._libSearchPills || []).length && _libSearchHadResults) { - _libSearchHadResults = false; - _libPreSearchEmails = null; - _libPreSearchTotal = 0; - _libServerSearchEmails = null; - _libServerSearchTotal = 0; - state._libSearch = ''; - state._libOffset = 0; - _loadEmails({ useCache: true }); - return; - } - _applyPillFilter(); -} - -// Render the autocomplete dropdown below the input. focusIdx highlights -// the active row; Tab autocompletes / Enter accepts that row. -function _renderSearchSuggestions(items) { - const menu = document.getElementById('email-lib-suggest'); - if (!menu) return; - if (!items.length) { menu.style.display = 'none'; menu.innerHTML = ''; return; } - const esc = s => String(s || '').replace(/&/g, '&').replace(/ { - const highlight = i === _libSuggestionFocusIdx ? 'background:color-mix(in srgb, var(--fg) 8%, transparent);' : ''; - if (s.kind === 'filter') { - return ``; - } - if (s.kind === 'email') { - return ``; - } - return ``; - }).join(''); - menu.style.display = ''; - menu.querySelectorAll('.email-lib-suggest-item').forEach(row => { - row.addEventListener('mousedown', (e) => { - // mousedown (not click) so we beat the input blur handler that hides the menu. - e.preventDefault(); - const idx = Number(row.dataset.idx); - const item = items[idx]; - if (item) _acceptSuggestion(item); - }); - }); -} - -function _hideSearchSuggestions() { - const menu = document.getElementById('email-lib-suggest'); - if (menu) { menu.style.display = 'none'; menu.innerHTML = ''; } - _libSuggestionFocusIdx = 0; -} - -function _acceptSuggestion(s) { - const input = document.getElementById('email-lib-search'); - if (s.kind === 'filter') { - _addSearchPill({ type: 'filter', value: s.value, label: s.label }); - } else if (s.kind === 'email') { - // Clear the draft + dropdown and open the matching card directly. - if (input) input.value = ''; - state._libSearchDraft = ''; - _hideSearchSuggestions(); - _applyPillFilter(); - const grid = document.getElementById('email-lib-grid'); - const card = grid?.querySelector(`.doclib-card[data-uid="${CSS.escape(String(s.uid))}"]`); - const em = (state._libEmails || []).find(x => String(x.uid) === String(s.uid)) - || (_libPreSearchEmails || []).find(x => String(x.uid) === String(s.uid)); - if (card && em) { - card.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - _toggleCardPreview(card, em); - } - return; - } else { - _addSearchPill({ type: 'contact', name: s.name, email: s.email }); - // Same as the text-pill path in the Enter handler: trigger the IMAP - // search so unloaded emails (older than the current page) show up - // when picking a contact. The local pill filter then narrows the - // search results to that contact's address. - const _q = (s.email || s.name || '').trim(); - if (_q && _q.length >= 2) { - state._libSearch = _q; - _doSearch(); - } - } - if (input) input.value = ''; - state._libSearchDraft = ''; - _hideSearchSuggestions(); - _applyPillFilter(); - if (input) input.focus(); -} - -async function _initEmailSearchChipBar() { - const bar = document.getElementById('email-lib-chip-bar'); - const input = document.getElementById('email-lib-search'); - if (!bar || !input) return; - state._libSearchPills = state._libSearchPills || []; - state._libSearchDraft = ''; - _renderSearchPills(); - - // Lazy-load suggestion source on first focus / keystroke. - const _ensureSuggestionCache = async () => { - if (_libSuggestionCache) return; - _libSuggestionCache = await _buildSuggestionSource(); - }; - - // Click anywhere in the bar lands the cursor in the input field. - bar.addEventListener('click', (e) => { - if (e.target.closest('.email-lib-pill-x')) return; - input.focus(); - }); - - let _itemsRef = []; - const _refreshSuggestions = async () => { - await _ensureSuggestionCache(); - _itemsRef = _filterSuggestions(input.value); - // Default to no focused suggestion — text typing should feel like - // regular search; the user has to ArrowDown / Tab explicitly to - // pick a contact. Enter without a focused row commits as text. - _libSuggestionFocusIdx = -1; - _renderSearchSuggestions(_itemsRef); - }; - - input.addEventListener('focus', _refreshSuggestions); - // Debounced IMAP search — fires ~500ms after the user stops typing so - // searches for names/text not in the current inbox page actually surface - // hits, instead of just locally filtering the visible window. - // - // Live local filtering on EVERY keystroke was clobbering server hits: - // _emailMatchesPill / _matchesQuery check subject/from_name/from_address/ - // snippet but never body, so intermediate text like "sam" reduced the - // 61 server results to whatever matched just those four fields (often - // 0). User saw "no emails" while typing. So local filter is gone from - // the typing path — debounced server search drives the grid. Pill - // add/remove still re-runs the local filter through _applyPillFilter - // directly. - let _libSearchTypingTimer = null; - input.addEventListener('input', async () => { - _resetBulkSelectionForContextChange({ rerender: true }); - state._libSearchDraft = input.value; - await _refreshSuggestions(); - if (_libSearchTypingTimer) clearTimeout(_libSearchTypingTimer); - const v = input.value.trim(); - if (v.length >= 2) { - _libSearchTypingTimer = setTimeout(() => { - const cur = (input.value || '').trim(); - if (cur === v && cur.length >= 2) { - state._libSearch = cur; - _doSearch(); - } - }, 500); - } else if (!v && _libSearchHadResults) { - // Cleared the input → restore the inbox the same way the pill-clear - // path does. Otherwise the stale search results stayed up after the - // user backspaced everything out. - _libSearchHadResults = false; - _libPreSearchEmails = null; - _libPreSearchTotal = 0; - state._libSearch = ''; - state._libOffset = 0; - _loadEmails({ useCache: true }); - } - }); - input.addEventListener('keydown', (e) => { - const menu = document.getElementById('email-lib-suggest'); - const menuOpen = menu && menu.style.display !== 'none'; - if (e.key === 'Backspace' && !input.value && (state._libSearchPills || []).length) { - e.preventDefault(); - _removeSearchPillAt(state._libSearchPills.length - 1); - return; - } - if (e.key === 'ArrowDown' && menuOpen) { - e.preventDefault(); - // -1 → 0 → 1 → … → length-1, then wraps back to -1 (no selection) - const next = _libSuggestionFocusIdx + 1; - _libSuggestionFocusIdx = next >= _itemsRef.length ? -1 : next; - _renderSearchSuggestions(_itemsRef); - return; - } - if (e.key === 'ArrowUp' && menuOpen) { - e.preventDefault(); - // -1 → length-1 → length-2 → … → 0 → -1 - const next = _libSuggestionFocusIdx - 1; - _libSuggestionFocusIdx = next < -1 ? _itemsRef.length - 1 : next; - _renderSearchSuggestions(_itemsRef); - return; - } - if (e.key === 'Tab' && menuOpen) { - // Tab autocompletes the FIRST suggestion (most-relevant), regardless - // of whether the user arrowed down yet — matches the user's mental - // model of "type a name and tab to pick". - const pick = _libSuggestionFocusIdx >= 0 ? _itemsRef[_libSuggestionFocusIdx] : _itemsRef[0]; - if (pick) { e.preventDefault(); _acceptSuggestion(pick); return; } - } - if (e.key === 'Enter') { - e.preventDefault(); - // Only commit a contact if the user explicitly focused one. Plain - // Enter should default to a text pill so regular text search works - // without forcing a contact pick. - if (menuOpen && _libSuggestionFocusIdx >= 0 && _itemsRef[_libSuggestionFocusIdx]) { - _acceptSuggestion(_itemsRef[_libSuggestionFocusIdx]); - return; - } - const v = input.value.trim(); - if (v) { - const typedFilter = _exactTypedFilterSuggestion(v); - if (typedFilter) { - _acceptSuggestion(typedFilter); - return; - } - _addSearchPill({ type: 'text', text: v }); - input.value = ''; - state._libSearchDraft = ''; - _hideSearchSuggestions(); - // Pill-only filtering used to only check emails already loaded into - // state._libEmails (the visible page of the inbox). Searches for - // names/text that aren't in the current page returned "no emails" - // even when matches existed on the server. Trigger the IMAP - // search so state._libEmails is replaced with the actual hits, - // then the pill filter narrows to matches. - state._libSearch = v; - _doSearch(); - } - return; - } - if (e.key === 'Escape') { - if (menuOpen) { - // Just close the dropdown — let the modal Esc handler run on the - // next Esc to actually dismiss the library. - e.preventDefault(); - e.stopPropagation(); - _hideSearchSuggestions(); - } else { - // Blur first so the modal Esc handler doesn't get suppressed by - // any IME / typing-target check, and let the event propagate. - try { input.blur(); } catch (_) {} - } - } - }); -} - -// Click-to-add: clicking a recipient-chip in the email reader OR a -// .email-meta-sender in the library list drops the person into the -// library search as a contact pill so the user can pivot to "everything -// from / to this person" in one tap. -window.addEventListener('click', (e) => { - const lib = document.getElementById('email-lib-modal'); - // 1) Recipient chips inside the email reader area - const chip = e.target.closest && e.target.closest('.recipient-chip'); - if (chip && chip.closest('.email-reader-header, .email-card-reader, .email-reader-tab-modal')) { - // Don't pivot to library search for chips in the From / To / Cc - // meta — clicking those should just toggle the expanded address - // view via the per-reader handler. - if (chip.closest('.email-reader-meta')) return; - const email = (chip.dataset && chip.dataset.email) || ''; - const name = (chip.dataset && chip.dataset.name) || (chip.textContent || '').trim(); - if (!email) return; - e.preventDefault(); - e.stopPropagation(); - try { window.openEmailLibrary && window.openEmailLibrary(); } catch (_) {} - _addSearchPill({ type: 'contact', name, email }); - return; - } - // 2) Sender name in a library list card row (only when the library is open) - if (lib && !lib.classList.contains('hidden')) { - const senderEl = e.target.closest && e.target.closest('.email-meta-sender'); - if (senderEl && senderEl.closest('#email-lib-grid')) { - if (state._selectMode) return; - const email = (senderEl.dataset && senderEl.dataset.email) || ''; - const name = (senderEl.dataset && senderEl.dataset.name) || (senderEl.textContent || '').trim(); - if (!email) return; - e.preventDefault(); - e.stopPropagation(); - _addSearchPill({ type: 'contact', name, email }); - } - } -}, true); - -async function _doSearch() { - _exitEmailReaderModeForList(); - _resetBulkSelectionForContextChange({ rerender: true }); - const seq = ++_libSearchSeq; - const derived = _deriveSearchScope(state._libSearch); - const q = derived.q; - if (q.length < 2 && !derived.forced) { - // Empty or too short — restore the normal folder if a previous search - // had replaced the grid contents. - if (_libSearchHadResults) { - _libSearchHadResults = false; - state._libOffset = 0; - await _loadEmails({ useCache: true }); - return; - } - _renderGrid(); - return; - } - const accountAtStart = state._libAccountId || ''; - const folderAtStart = derived.folder || state._libFolder || 'INBOX'; - const serverScopeAtStart = derived.serverScope || 'all'; - // No grid-blanking spinner — the local filter already painted something - // useful. Surface progress in the stats badge instead so the user knows - // the server search is still grinding. - const stats = document.getElementById('email-lib-stats'); - const originalStatsText = stats?.textContent || ''; - if (stats) stats.textContent = 'Searching…'; - _libSearchInFlight = true; - _setEmailSyncStatus({ loading: true }); - // Force a re-render so the "Searching…" empty-state shows (and any - // existing "No emails" gets replaced) while the fetch is in flight. - _renderGrid(); - - const stillCurrent = () => ( - seq === _libSearchSeq && - q === _deriveSearchScope(state._libSearch).q && - accountAtStart === (state._libAccountId || '') && - folderAtStart === (_deriveSearchScope(state._libSearch).folder || state._libFolder || 'INBOX') && - // A local/index result can be opened while the slower provider search is - // still running. Do not let that second response re-render the grid and - // destroy the reader the user just opened. - !document.querySelector('#email-lib-grid .email-card-expanded') - ); - const searchUrl = (localOnly = false) => { - const params = new URLSearchParams({ - folder: folderAtStart, - q, - limit: '100', - scope: serverScopeAtStart, - }); - if (accountAtStart) params.set('account_id', accountAtStart); - if (localOnly) params.set('local_only', '1'); - return `${API_BASE}/api/email/search?${params.toString()}`; - }; - const folderListUrl = () => { - const params = new URLSearchParams({ - folder: folderAtStart, - limit: '100', - offset: '0', - filter: state._libFilter || 'all', - }); - if (accountAtStart) params.set('account_id', accountAtStart); - return `${API_BASE}/api/email/list?${params.toString()}`; - }; - const mergeSearchResults = (painted, incoming) => { - const byKey = new Map(); - const out = []; - const add = (em) => { - if (!em) return; - const key = `${em.account_id || accountAtStart || ''}:${em.folder || folderAtStart || ''}:${em.uid || em.message_id || JSON.stringify(em)}`; - if (byKey.has(key)) return; - byKey.set(key, em); - out.push(em); - }; - (painted || []).forEach(add); - const additions = []; - const addIncoming = (em) => { - if (!em) return; - const key = `${em.account_id || accountAtStart || ''}:${em.folder || folderAtStart || ''}:${em.uid || em.message_id || JSON.stringify(em)}`; - if (byKey.has(key)) return; - byKey.set(key, em); - additions.push(em); - }; - (incoming || []).forEach(addIncoming); - additions.sort((a, b) => { - const ad = Number(a?.date_epoch || 0); - const bd = Number(b?.date_epoch || 0); - if (bd !== ad) return bd - ad; - return String(b?.date || '').localeCompare(String(a?.date || '')); - }); - return out.concat(additions); - }; - let paintedInterimResults = false; - const paintSearchData = (data, interim = false) => { - if (!stillCurrent()) return false; - if (data.error) throw new Error(data.error); - let results = data.emails || []; - if (!interim && paintedInterimResults) { - results = mergeSearchResults(state._libEmails || [], results); - } - if (!interim && paintedInterimResults && results.length === 0) { - if (stats) { - const count = state._libTotal || (state._libEmails || []).length; - stats.textContent = `${count} cached match${count === 1 ? '' : 'es'}`; - } - _setEmailSyncStatus({ - updatedAt: data.sync?.updated_at || '', - source: data.sync?.source || data.source || '', - loading: false, - }); - return true; - } - _libSearchHadResults = true; - const pills = state._libSearchPills || []; - const preservingBase = !!(_libServerSearchEmails && pills.length > 1); - if (!preservingBase) { - _libServerSearchEmails = results.slice(); - _libServerSearchTotal = Math.max(Number(data.total || 0), results.length); - _libPreSearchEmails = results.slice(); - _libPreSearchTotal = _libServerSearchTotal; - state._libEmails = results; - state._libTotal = _libServerSearchTotal; - } else { - state._libEmails = _libServerSearchEmails.slice(); - state._libTotal = _libServerSearchTotal; - } - if (pills.length) { - _applyPillFilter(); - if (!(state._libEmails || []).length && !preservingBase) state._libEmails = results; - } - _renderGrid(); - const count = Math.max(Number(data.total || 0), results.length); - if (stats) { - if (interim) { - stats.textContent = `${count} cached match${count === 1 ? '' : 'es'} · searching…`; - } else { - const source = data.source === 'index' ? ' cached' : ''; - stats.textContent = `${count}${source} match${count === 1 ? '' : 'es'}`; - } - } - _setEmailSyncStatus({ - updatedAt: interim ? '' : (data.sync?.updated_at || ''), - source: data.sync?.source || data.source || '', - loading: interim, - }); - if (interim && results.length) paintedInterimResults = true; - return true; - }; - - try { - if (q.length < 2 && derived.forced) { - const res = await fetch(folderListUrl()); - const data = await res.json(); - if (!stillCurrent()) return; - paintSearchData({ - emails: (data.emails || []).map(em => ({ ...em, folder: folderAtStart })), - total: data.total || (data.emails || []).length, - source: 'folder', - sync: { source: 'folder' }, - }, false); - return; - } - const fullSearchPromise = fetch(searchUrl(false)).then(res => res.json()); - const localSearchPromise = fetch(searchUrl(true)).then(res => res.json()); - try { - const localData = await localSearchPromise; - if (!stillCurrent()) return; - if (!localData.error && (localData.emails || []).length) { - paintSearchData(localData, true); - } - } catch (_) { - if (!stillCurrent()) return; - } - - const data = await fullSearchPromise; - if (!stillCurrent()) return; - paintSearchData(data, false); - } catch (e) { - if (stats) stats.textContent = originalStatsText || 'Search failed'; - try { console.error('[email-search] fetch failed:', e); } catch {} - } finally { - _libSearchInFlight = false; - _setEmailSyncStatus({ loading: false }); - // If the full search was intentionally ignored because its result landed - // after the user opened a reader, finish the progress label without - // touching the grid or collapsing that reader. - const openReader = document.querySelector('#email-lib-grid .email-card-expanded'); - if (openReader && stats && /searching/i.test(stats.textContent || '')) { - const count = state._libTotal || (state._libEmails || []).length; - stats.textContent = `${count} cached match${count === 1 ? '' : 'es'}`; - } - } -} - -// Custom dropdown for the email filter (All/Unread/Favorites/...). Replaces -// the native stays as the value source — clicking a -// menu item updates its value and dispatches 'change', so every existing -// listener keeps working. -const _EMAIL_FILTER_ICONS = { - 'all': '', - 'unread': '', - 'favorites': '', - 'undone': '', - 'reminders': '', - 'unanswered': '', - 'pending_30d': '', - 'stale_30d': '', - 'tag:urgent': '', - 'tag:reply-soon':'', - 'tag:spam': '', -}; - -function _filterIcon(value) { - return _EMAIL_FILTER_ICONS[value] || _EMAIL_FILTER_ICONS['all']; -} - -function _folderIcon(value) { - const v = String(value || '').toLowerCase(); - if (value === '__scheduled__') { - return ''; - } - if (v.includes('sent')) { - return ''; - } - if (v.includes('star') || v.includes('favorite')) { - return ''; - } - if (v.includes('archive') || v.includes('all mail')) { - return ''; - } - if (v.includes('junk') || v.includes('spam')) { - return ''; - } - if (v.includes('trash') || v.includes('bin')) { - return ''; - } - if (v.includes('draft')) { - return ''; - } - if (v === 'inbox' || v.includes('inbox')) { - return ''; - } - return ''; -} - -function _renderFolderPicker() { - const sel = document.getElementById('email-lib-folder'); - const btn = document.getElementById('email-folder-btn'); - const menu = document.getElementById('email-folder-menu'); - if (!sel || !btn || !menu) return; - const value = sel.value || state._libFolder || 'INBOX'; - const selectedOpt = [...sel.options].find(o => o.value === value && !o.disabled); - const label = selectedOpt?.textContent || folderDisplayName(value); - const iconWrap = btn.querySelector('.email-folder-current-icon'); - const labelEl = btn.querySelector('.email-folder-label'); - if (iconWrap) iconWrap.innerHTML = _folderIcon(value); - if (labelEl) labelEl.textContent = label; - const esc = s => String(s || '').replace(/&/g, '&').replace(/Folders'); - continue; - } - rows.push(``); - } - menu.innerHTML = rows.join(''); -} - -function _initFolderPicker() { - const sel = document.getElementById('email-lib-folder'); - const picker = document.getElementById('email-folder-picker'); - const btn = document.getElementById('email-folder-btn'); - const menu = document.getElementById('email-folder-menu'); - if (!sel || !picker || !btn || !menu || picker._wired) return; - picker._wired = true; - let dismiss = () => {}; - const finishClose = () => { - menu.hidden = true; - btn.setAttribute('aria-expanded', 'false'); - dismiss = () => {}; - }; - const open = () => { - const filterMenu = document.getElementById('email-filter-menu'); - const filterBtn = document.getElementById('email-filter-btn'); - filterMenu?._dismiss?.(); - if (filterMenu && !filterMenu.hidden) filterMenu.hidden = true; - if (filterBtn) filterBtn.setAttribute('aria-expanded', 'false'); - _renderFolderPicker(); - menu.hidden = false; - btn.setAttribute('aria-expanded', 'true'); - dismiss = bindMenuDismiss(menu, finishClose, e => !picker.contains(e.target)); - }; - btn.addEventListener('click', (e) => { - e.stopPropagation(); - if (menu.hidden) open(); else dismiss(); - }); - menu.addEventListener('click', (e) => { - const item = e.target.closest('.email-folder-item'); - if (!item) return; - sel.value = item.dataset.value; - sel.dispatchEvent(new Event('change', { bubbles: true })); - dismiss(); - }); - document.addEventListener('click', (e) => { - if (!menu.hidden && !picker.contains(e.target)) dismiss(); - }); - _renderFolderPicker(); -} - -function _renderFilterPickerCurrent() { - const sel = document.getElementById('email-lib-filter'); - const btn = document.getElementById('email-filter-btn'); - if (!sel || !btn) return; - const value = sel.value || 'all'; - const opt = sel.querySelector(`option[value="${CSS.escape(value)}"]`); - const label = opt ? opt.textContent : value; - const iconWrap = btn.querySelector('.email-filter-icon'); - const labelEl = btn.querySelector('.email-filter-label'); - if (iconWrap) iconWrap.innerHTML = _filterIcon(value); - if (labelEl) labelEl.textContent = label; -} - -function _initFilterPicker() { - const sel = document.getElementById('email-lib-filter'); - const picker = document.getElementById('email-filter-picker'); - const btn = document.getElementById('email-filter-btn'); - const menu = document.getElementById('email-filter-menu'); - if (!sel || !picker || !btn || !menu || picker._wired) return; - picker._wired = true; - - // Build menu from the hidden -
- -
- - `; - const noteInput = menu.querySelector('[data-note-input]'); - const contextDraftKey = _aiReplyContextDraftKey(em, data); - menu.dataset.contextDraftKey = contextDraftKey; - noteInput.value = _loadAiReplyContextDraft(contextDraftKey); - noteInput.addEventListener('input', () => { - _saveAiReplyContextDraft(contextDraftKey, noteInput.value || ''); - }); - setTimeout(() => noteInput.focus(), 0); - menu.addEventListener('click', async (ev) => { - const choice = ev.target.closest('[data-mode]'); - if (!choice) return; - ev.preventDefault(); - ev.stopPropagation(); - const mode = choice.getAttribute('data-mode') || 'ai-reply-fast'; - const noteHint = (noteInput.value || '').trim(); - _saveAiReplyContextDraft(contextDraftKey, noteInput.value || ''); - _closeAiReplyChoice(); - const draftOpened = await _runAiReplyFromButton(btn, em, data, mode, noteHint); - if (draftOpened === true) _clearAiReplyContextDraft(contextDraftKey); - }); - // Esc closes the popover; ignore plain clicks inside the menu so the - // textarea stays focused. - menu.addEventListener('mousedown', (ev) => ev.stopPropagation()); - document.body.appendChild(menu); - // Outside-click closer: only fires when the click target is OUTSIDE - // the menu. The original handler closed on any click which made - // focusing the textarea immediately dismiss the popover. - const outsideClose = (ev) => { - if (menu.contains(ev.target)) return; - document.removeEventListener('click', outsideClose, true); - if (_aiReplyChoiceOutsideClose === outsideClose) _aiReplyChoiceOutsideClose = null; - _closeAiReplyChoice(); - }; - _aiReplyChoiceOutsideClose = outsideClose; - setTimeout(() => { - if (_aiReplyChoiceOutsideClose === outsideClose) { - document.addEventListener('click', outsideClose, true); - } - }, 0); -} - -function _handleAiReplyButton(ev, em, data, positionAnchor = ev.currentTarget) { - ev.stopPropagation(); - const btn = ev.currentTarget; - // First click on a cached email surfaces the cached draft. Second - // click clears the cache and opens the Fast/Full + context menu so - // the user can ask for a fresh draft (with new steering). - if (data?.cached_ai_reply && !btn.dataset.shownOnce) { - btn.dataset.shownOnce = '1'; - _runAiReplyFromButton(btn, em, data, 'ai-reply'); - return; - } - if (data?.cached_ai_reply) { - data.cached_ai_reply = null; - btn.dataset.shownOnce = ''; - } - _showAiReplyChoice(btn, em, data, positionAnchor); -} - -function _hasMultipleRecipients(data) { - // Count distinct addresses in To + Cc (minus the current user). Empty - // fallback when the user's address isn't yet known — no exclusion. - const myAddress = (window._myEmailAddress || '').toLowerCase(); - const extractEmails = (str) => { - if (!str) return []; - return str.split(',') - .map(s => { - const m = s.match(/<([^>]+)>/); - return (m ? m[1] : s).trim().toLowerCase(); - }) - .filter(e => e && e !== myAddress); - }; - const recipients = new Set([ - ...extractEmails(data.to), - ...extractEmails(data.cc), - ]); - // Sender counts as one other person too - if (data.from_address && data.from_address.toLowerCase() !== myAddress) { - recipients.add(data.from_address.toLowerCase()); - } - return recipients.size > 1; -} - -// _esc lives in ./emailLibrary/utils.js - -function _showEmailTranslateSubmenu(reader, parentDropdown) { - parentDropdown.innerHTML = ''; - const header = document.createElement('div'); - header.className = 'dropdown-item-compact'; - header.style.cssText = 'opacity:0.5;font-size:10px;pointer-events:none;text-transform:uppercase;letter-spacing:0.5px;padding-top:6px;'; - header.innerHTML = 'Translate to'; - parentDropdown.appendChild(header); - - const customRow = document.createElement('div'); - customRow.className = 'dropdown-item-compact email-translate-custom-row'; - customRow.style.cssText = 'display:flex;gap:5px;align-items:center;padding:5px 7px;cursor:default;'; - customRow.innerHTML = ` - - - `; - parentDropdown.appendChild(customRow); - const input = customRow.querySelector('.email-translate-custom-input'); - const go = customRow.querySelector('.email-translate-custom-go'); - const runCustom = async () => { - const language = (input?.value || '').trim(); - if (!language) { - input?.focus(); - return; - } - parentDropdown.remove(); - await _translateEmail(reader, language); - }; - customRow.addEventListener('click', e => e.stopPropagation()); - go?.addEventListener('click', async e => { - e.stopPropagation(); - await runCustom(); - }); - input?.addEventListener('keydown', async e => { - if (e.key === 'Enter') { - e.preventDefault(); - e.stopPropagation(); - await runCustom(); - } - }); - _emailTranslateLanguage().then(language => { - if (input && !input.value) input.value = language || 'English'; - }).catch(() => {}); - - const languages = ['English', 'Swedish', 'Japanese', 'Spanish', 'French', 'German']; - for (const language of languages) { - const item = document.createElement('div'); - item.className = 'dropdown-item-compact'; - item.innerHTML = `${language}`; - item.addEventListener('click', async (e) => { - e.stopPropagation(); - parentDropdown.remove(); - await _translateEmail(reader, language); - }); - parentDropdown.appendChild(item); - } -} - -// ---- Reminder submenu (used by both email menus) ---- -function _showLibRemindSubmenu(em, parentDropdown) { - parentDropdown.innerHTML = ''; - const header = document.createElement('div'); - header.className = 'dropdown-item-compact'; - header.style.cssText = 'opacity:0.5;font-size:10px;pointer-events:none;text-transform:uppercase;letter-spacing:0.5px;padding-top:6px;'; - header.innerHTML = 'Remind me'; - parentDropdown.appendChild(header); - - const now = new Date(); - const laterToday = new Date(now); - const sixPm = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0); - if (sixPm - now < 60*60*1000) laterToday.setTime(now.getTime() + 3*60*60*1000); - else laterToday.setTime(sixPm.getTime()); - const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate()+1); tomorrow.setHours(8,0,0,0); - const daysUntilMon = (8 - now.getDay()) % 7 || 7; - const nextWeek = new Date(now); nextWeek.setDate(now.getDate()+daysUntilMon); nextWeek.setHours(8,0,0,0); - - const presets = [ - { label: 'Later today', sub: laterToday.toLocaleTimeString([], { hour:'numeric', minute:'2-digit' }), date: laterToday }, - { label: 'Tomorrow', sub: tomorrow.toLocaleTimeString([], { hour:'numeric', minute:'2-digit' }), date: tomorrow }, - { label: 'Next week', sub: nextWeek.toLocaleDateString([], { weekday:'short' }) + ' ' + nextWeek.toLocaleTimeString([], { hour:'numeric', minute:'2-digit' }), date: nextWeek }, - ]; - for (const p of presets) { - const item = document.createElement('div'); - item.className = 'dropdown-item-compact'; - item.innerHTML = `${p.label}${p.sub}`; - item.addEventListener('click', async (e) => { - e.stopPropagation(); - parentDropdown.remove(); - await _createEmailReplyReminder(em, p.date); - }); - parentDropdown.appendChild(item); - } - const customItem = document.createElement('div'); - customItem.className = 'dropdown-item-compact'; - customItem.innerHTML = 'Pick date and time…'; - customItem.addEventListener('click', (e) => { - e.stopPropagation(); - parentDropdown.remove(); - const tmp = document.createElement('input'); - tmp.type = 'datetime-local'; - const def = new Date(tomorrow); - const pad = n => String(n).padStart(2,'0'); - tmp.value = `${def.getFullYear()}-${pad(def.getMonth()+1)}-${pad(def.getDate())}T${pad(def.getHours())}:${pad(def.getMinutes())}`; - tmp.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:99999;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:13px;'; - document.body.appendChild(tmp); - tmp.focus(); - if (typeof tmp.showPicker === 'function') { try { tmp.showPicker(); } catch {} } - tmp.addEventListener('change', async () => { - if (tmp.value) await _createEmailReplyReminder(em, new Date(tmp.value)); - tmp.remove(); - }); - tmp.addEventListener('blur', () => setTimeout(() => tmp.remove(), 200)); - }); - parentDropdown.appendChild(customItem); - // "Note" — prompts for free-text and saves it as a note without a - // due_date, so no timer/reminder fires. - const noteItem = document.createElement('div'); - noteItem.className = 'dropdown-item-compact'; - noteItem.innerHTML = 'Note'; - noteItem.addEventListener('click', (e) => { - e.stopPropagation(); - parentDropdown.remove(); - _promptEmailNote(em); - }); - parentDropdown.appendChild(noteItem); -} - -function _promptEmailNote(em) { - const overlay = document.createElement('div'); - overlay.style.cssText = 'position:fixed;inset:0;z-index:99998;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;padding:16px;'; - const card = document.createElement('div'); - card.style.cssText = 'background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:14px;min-width:280px;max-width:min(420px, 92vw);display:flex;flex-direction:column;gap:8px;box-shadow:0 12px 32px rgba(0,0,0,0.4);'; - const subject = em.subject || '(no subject)'; - card.innerHTML = ` -
Note about ${_esc(subject)}
- - - `; - overlay.appendChild(card); - document.body.appendChild(overlay); - const ta = card.querySelector('[data-note]'); - setTimeout(() => ta.focus(), 0); - const close = () => overlay.remove(); - overlay.addEventListener('click', (ev) => { if (ev.target === overlay) close(); }); - card.querySelector('[data-act="cancel"]').addEventListener('click', close); - card.querySelector('[data-act="save"]').addEventListener('click', async () => { - const text = (ta.value || '').trim(); - if (!text) { ta.focus(); return; } - close(); - await _createEmailReplyReminder(em, null, text); - }); - ta.addEventListener('keydown', (ev) => { - if (ev.key === 'Escape') close(); - else if ((ev.ctrlKey || ev.metaKey) && ev.key === 'Enter') card.querySelector('[data-act="save"]').click(); - }); -} - -async function _createEmailReplyReminder(em, dueDate, customText = '') { - const pad = n => String(n).padStart(2,'0'); - const iso = dueDate - ? `${dueDate.getFullYear()}-${pad(dueDate.getMonth()+1)}-${pad(dueDate.getDate())}T${pad(dueDate.getHours())}:${pad(dueDate.getMinutes())}` - : null; - const fullFrom = em.from || em.sender || ''; - // Extract just the first name from "First Last " or fall back to email local part - let from = 'someone'; - if (fullFrom) { - const fullName = _extractName(fullFrom); - if (fullName) { - // Strip quotes, take the first whitespace-separated word, capitalize - const first = fullName.replace(/^["']|["']$/g, '').trim().split(/[\s,]+/)[0] || ''; - if (first) from = first.charAt(0).toUpperCase() + first.slice(1); - } - } - const subject = em.subject || '(no subject)'; - const folder = state._libFolder || 'INBOX'; - const deepLink = `${window.location.origin}/#email=${encodeURIComponent(folder)}:${em.uid}`; - const itemText = customText || `Reply to ${from}: ${subject}`; - const payload = { - title: `Reply: ${subject}`, - note_type: 'todo', - items: [ - { text: itemText, checked: false }, - ], - content: `Open email: ${deepLink}`, - label: 'email reminder', - source: 'email', - }; - if (iso) payload.due_date = iso; - try { - const res = await fetch(`${API_BASE}/api/notes`, { - method: 'POST', credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - if (!res.ok) throw new Error('Failed'); - const { showToast } = await import('./ui.js?v=20260916largetoolscroll1'); - if (dueDate) { - const fmt = dueDate.toLocaleString([], { month:'short', day:'numeric', hour:'numeric', minute:'2-digit' }); - showToast(`Todo reminder set for ${fmt}`); - } else { - showToast('Reply note saved'); - } - if ('Notification' in window && Notification.permission === 'default') { - try { Notification.requestPermission(); } catch {} - } - } catch (e) { - const { showError } = await import('./ui.js?v=20260916largetoolscroll1'); - showError('Failed to create reminder'); - } -} - -// Sanitize untrusted HTML email bodies before injecting via innerHTML. +// The email library now lives in `static/js/emailLibrary/`. This file stays at +// the old path because five call sites import it, four of them dynamically +// with a version query string (`emailInbox.js`, `chatStream.js`, +// `chatRenderer.js`, `document.js`, `settings.js`), and `sw.js` caches URLs +// verbatim. Re-exporting here means the move needed no coordinated edit to any +// of them. // -// Denylist sanitizer — has to block every well-known XSS sink: -// -