/** * 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=20260908weekhoverfix1'; import { folderDisplayName, sortedFolders } from './emailInbox.js?v=20260903emailsend2'; import settingsModule from './settings.js?v=20260909defaultmodelfix1'; 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 `
` + `default` + `` + `
`; } 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 = `

${_EMAIL_SETTINGS_ICON}Email Settings

${_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 `
Could not load email
${_esc(message || 'Failed to load email')}
`; } 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=20260903weekscrollstable1').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=20260901taskskilldensity1'); 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 { await fetch(`${API_BASE}/api/email/delete/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' }); } catch (err) { console.error('Failed to delete email:', err); 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 _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 ? `x${uids.length}` : ''}
`; }).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 ` ${esc(label)} `; }).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 `
${s.icon} ${esc(s.label)}
`; } if (s.kind === 'email') { return `
${esc(s.subject)} ${s.from_name ? `— ${esc(s.from_name)}` : ''} ${s.date_label ? `${esc(s.date_label)}` : ''}
`; } return `
${esc(s.name || s.email)} ${s.name ? `${esc(s.email)}` : ''}
`; }).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) { 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); } 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=20260908weekhoverfix1'); 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=20260908weekhoverfix1'); showError('Failed to create reminder'); } } // Sanitize untrusted HTML email bodies before injecting via innerHTML. // // Denylist sanitizer — has to block every well-known XSS sink: // -