/** * emailLibrary.js — Email library popup modal. * Similar pattern to documentLibrary.js. Shows emails in a grid with search/filter. */ import spinnerModule from './spinner.js'; import { styledConfirm, showToast, emptyStateIcon } from './ui.js'; import { folderDisplayName, sortedFolders } from './emailInbox.js?v=20260722emailfastindex1'; import settingsModule from './settings.js'; import * as Modals from './modalManager.js'; import { topPortalZ } from './toolWindowZOrder.js'; import { makeWindowDraggable } from './windowDrag.js'; import { _esc, _escLinkify, _extractName, _parseTurnMeta, _formatBubbleDate, _formatRecipients, _senderColor, _initials, _sanitizeHtml, _TALON_WROTE, _TALON_FROM, _TALON_SENT, _TALON_SUBJ, _TALON_TO, _TALON_ORIG_RE, _SIG_BLOAT_MIN_CHARS, } from './emailLibrary/utils.js'; import { _looksLikeSignature, _harvestAttribution, _extractTurnMetaFromBlockquote, _foldSummary, _extractQuoteMeta, _peelSigNameLine, _isBloatedSig, _tryFoldHintSig, _foldSignature, _SIG_ICON, _QUOTE_ICON, } from './emailLibrary/signatureFold.js'; import { state } from './emailLibrary/state.js'; import { collapseSidebarToRail } from './modalSnap.js'; import { emailApiUrl } from './emailShared.js'; import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js'; const API_BASE = window.location.origin; let _emailUnreadChipClickWired = false; let _libLoadSeq = 0; 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 } const _EMAIL_SETTINGS_ICON = ``; 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 _normalizeAutoReplyConfig(cfg = {}) { return { enabled: !!cfg.email_auto_reply, start: String(cfg.email_auto_reply_start || ''), end: String(cfg.email_auto_reply_end || ''), subject: String(cfg.email_auto_reply_subject || _DEFAULT_AUTO_REPLY_SUBJECT), message: String(cfg.email_auto_reply_message || ''), cooldown: String(cfg.email_auto_reply_cooldown || 'period'), scope: String(cfg.email_auto_reply_scope || 'all'), accountId: String(cfg.email_auto_reply_account_id || ''), excludeAutomated: cfg.email_auto_reply_exclude_automated !== false, pauseNotifications: !!cfg.email_auto_reply_pause_notifications, }; } function _autoReplyDateValue(value) { const s = String(value || '').trim(); const m = s.match(/^(\d{4}-\d{2}-\d{2})/); return m ? m[1] : ''; } 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; } 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}`); return _normalizeAutoReplyConfig(await res.json()); } 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 accountLabel = activeAccount ? (activeAccount.name || activeAccount.from_address || activeAccount.imap_user || 'selected account') : 'selected account'; return `
Applies to
`; } function _emailCleanupSettingsHtml() { return `
`; } function _emailSettingsAccountSelectHtml() { const accounts = Array.isArray(state._libAccounts) ? state._libAccounts.filter(a => a && a.enabled !== false) : []; if (!accounts.length) return ''; const opts = accounts.map(a => { const label = a.name || a.from_address || a.imap_user || 'account'; const suffix = a.is_default ? ' · default' : ''; return ``; }).join(''); return ``; } 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; 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
${_emailSettingsAccountSelectHtml()}
${_emailSettingsLoadingHtml()}
`; let cfg; let writingStyle = ''; try { await _loadAccounts().catch(() => {}); const head = page.querySelector('.email-settings-page-head'); const accountSelectHtml = _emailSettingsAccountSelectHtml(); const oldSelect = page.querySelector('#email-settings-account-select'); if (oldSelect) oldSelect.remove(); if (head && accountSelectHtml) head.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 = _emailCleanupSettingsHtml() + _emailSettingsFormHtml(cfg) + _emailWritingStyleHtml(writingStyle); page.querySelector('#email-settings-account-select')?.addEventListener('change', async (ev) => { state._libAccountId = ev.currentTarget.value || null; _publishActiveAccount(); _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 = _emailCleanupSettingsHtml() + _emailSettingsFormHtml(nextCfg) + _emailWritingStyleHtml(nextStyle); _bindEmailSettingsPageControls(page); } catch (_) { if (body) body.innerHTML = `
Could not load settings.
`; } }); _bindEmailSettingsPageControls(page); } function _bindEmailSettingsPageControls(page) { page.querySelector('.email-settings-clean-btn')?.addEventListener('click', (ev) => { _openUnsubscribeReviewModal(ev.currentTarget); }); const enabledToggle = page.querySelector('#email-auto-reply-enabled'); const syncEnabledState = () => { const section = page.querySelector('.email-settings-auto-reply-section'); const stateLabel = page.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; if (status) status.textContent = quiet ? 'Saving…' : '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_exclude_automated: !!page.querySelector('#email-auto-reply-exclude')?.checked, 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}`); if (status) status.textContent = 'Saved'; if (!quiet) showToast?.('Email settings saved'); _refreshUnreadBadge().catch(() => {}); setTimeout(() => { if (status) status.textContent = ''; }, quiet ? 900 : 1400); return true; } catch (err) { if (status) status.textContent = 'Save failed'; showToast?.('Failed to save email settings'); return false; } finally { if (saveBtn) saveBtn.disabled = false; } }; enabledToggle?.addEventListener('change', () => { syncEnabledState(); 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; if (status) 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.textContent = 'Saved'; showToast?.('Email writing style saved'); setTimeout(() => { if (status) status.textContent = ''; }, 1400); } catch (err) { if (status) 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').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 ''; if (visible.length === 1) return visible[0]; const extra = visible.slice(1).map(html => `${html}`).join(''); return `${visible[0]}${extra}`; } 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, '-'))); } // 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 _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 _syncEmailReadState(uid, isRead = true) { if (uid == null) return; const uidStr = String(uid); const read = !!isRead; const match = (state._libEmails || []).find(x => String(x.uid) === uidStr); if (match) match.is_read = read; document.querySelectorAll('.doclib-card[data-uid="' + CSS.escape(uidStr) + '"]').forEach(card => { card.classList.toggle('email-card-unread', !read); const titleRow = card.querySelector('.email-card-titlerow'); if (read) { card.querySelectorAll('.email-card-unread-dot, [data-unread-dot]').forEach(n => n.remove()); if (titleRow) { titleRow.querySelectorAll('span').forEach(s => { const st = s.getAttribute('style') || ''; if (/width:\s*6px/.test(st) && /border-radius:\s*50%/.test(st)) s.remove(); }); } return; } if (!titleRow || titleRow.querySelector('.email-card-unread-dot, [data-unread-dot]')) return; const isSentFolder = /sent/i.test(state._libFolder || ''); if (isSentFolder) return; const senderName = match ? (match.from_name || match.from_address || '') : ''; const dot = document.createElement('span'); dot.className = 'email-card-unread-dot'; dot.style.cssText = `width:6px;height:6px;border-radius:50%;background:${_senderColor(senderName)};flex-shrink:0;margin-left:2px;`; const done = titleRow.querySelector('.email-card-done'); const navArrows = titleRow.querySelector('.email-card-nav-arrows'); if (done) done.insertAdjacentElement('afterend', dot); else if (navArrows) titleRow.insertBefore(dot, navArrows); else titleRow.appendChild(dot); }); } // When a reply is sent (from the doc editor), the source email is marked // \Answered server-side and an `email-answered` event fires. Reflect that live // so the email shows as done without waiting for a manual refresh. window.addEventListener('email-answered', (e) => { const uid = e.detail && e.detail.uid; if (uid == null) return; const em = (state._libEmails || []).find(x => String(x.uid) === String(uid)); if (em) { em.is_answered = true; em.is_read = true; _clearDoneResponseTagsLocal(em); } _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; document.getElementById('email-undone-btn')?.classList.remove('active'); document.getElementById('email-reminder-btn')?.classList.remove('active'); _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 _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 btn = document.getElementById('email-reminder-btn'); const wrap = document.querySelector('#email-lib-modal .email-search-wrap'); btn?.classList.toggle('hidden', !enabled); wrap?.classList.toggle('email-reminder-bell-hidden', !enabled); } async function _loadEmailReminderBellVisibility() { try { const res = await fetch('/api/auth/settings', { credentials: 'same-origin' }); const settings = await res.json(); _syncEmailReminderBellVisibility(settings.reminder_channel === 'email'); } catch (_) { _syncEmailReminderBellVisibility(false); } } // 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); }); } // URL-suffix helper — appends &account_id=... when an account is actively selected. // Every email route call in this file goes through here so switching accounts // is a single-variable flip. // Open the Settings modal and activate a specific tab. Used by empty-state // "Set up at: Settings › X" links across email/calendar/etc. function _openSettingsTab(tab) { if (tab === 'integrations' && window.adminModule && typeof window.adminModule.open === 'function') { window.adminModule.open('integrations'); return; } if (settingsModule && typeof settingsModule.open === 'function') { settingsModule.open(tab || 'services'); return; } const modal = document.getElementById('settings-modal'); if (!modal) return; modal.classList.remove('hidden'); const tabBtn = modal.querySelector(`[data-settings-tab="${tab || 'services'}"]`); if (tabBtn) tabBtn.click(); } function _emailSetupHintHtml() { return '
' + 'Setup: Settings › Integrations' + '
'; } function _wireEmailSetupHint(root) { root?.querySelectorAll?.('[data-open-settings]').forEach(link => { if (link.dataset.emailSetupBound === '1') return; link.dataset.emailSetupBound = '1'; link.addEventListener('click', (e) => { e.preventDefault(); _openSettingsTab(link.dataset.openSettings || 'integrations'); }); }); } function _acct() { return state._libAccountId ? `&account_id=${encodeURIComponent(state._libAccountId)}` : ''; } 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 _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'; 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; } function _setUnsubStatusBusy(statusEl, label) { if (!statusEl) return null; statusEl.innerHTML = ''; statusEl.style.display = 'inline-flex'; statusEl.style.alignItems = 'center'; statusEl.style.gap = '6px'; 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 = '-2px'; sp.element.style.flexShrink = '0'; statusEl.appendChild(sp.element); return (next) => { statusEl.style.display = ''; statusEl.style.alignItems = ''; statusEl.style.gap = ''; statusEl.textContent = next || ''; }; } function _askAgentToUnsubscribe(candidate) { const method = candidate?.recommended_method || (candidate?.methods || []).find(m => m.kind === 'url') || null; const url = method?.kind === 'url' ? method.target : ''; const uid = candidate?.uid || ''; const folder = candidate?.folder || state._libFolder || 'INBOX'; const account = state._libAccountId || ''; const prompt = url ? `Use the email unsubscribe tools and browser/web tools to unsubscribe from this email's web unsubscribe page. Ask me before any destructive step if the page is ambiguous.\n\nEmail UID: ${uid}\nFolder: ${folder}\nAccount: ${account || '(default)'}\nUnsubscribe URL: ${url}` : `Use scan_email_unsubscribes/unsubscribe_email to unsubscribe this email if safe.\n\nEmail UID: ${uid}\nFolder: ${folder}\nAccount: ${account || '(default)'}`; const input = document.getElementById('message') || document.getElementById('message-input'); if (!input) { showToast?.('Chat composer not found'); return; } input.value = prompt; input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); try { document.getElementById('chat-form')?.requestSubmit?.(); } catch (_) { document.getElementById('chat-form')?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); } } 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 _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'); const key = String(c?.list_id || urlMethod?.target || method.target || c?.from_address || 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 topRow = card.querySelector('.email-unsub-card-top'); topRow?.insertAdjacentHTML('beforeend', `${_esc(label)}`); } card.querySelectorAll('.email-unsub-link-btn, .email-unsub-agent-btn, .email-unsub-send-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'; }); } 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 restoreBusy = _setUnsubButtonBusy(btn, action === 'junk' ? 'Marking spam' : 'Deleting'); 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 (_) {} modal.querySelector('.email-unsub-followup')?.remove(); showToast?.(action === 'junk' ? `Marked ${d.changed || 0} email${Number(d.changed || 0) === 1 ? '' : 's'} as spam` : `Deleted ${d.changed || 0} email${Number(d.changed || 0) === 1 ? '' : 's'}`); } catch (err) { console.error(err); showToast?.(err?.message || 'Cleanup failed'); } finally { restoreBusy?.(); } } function _showUnsubscribeCleanupPrompt(modal, candidates) { 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 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 = `
Done. Mark all ${count} of these email${count === 1 ? '' : 's'} as spam?
${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-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()); } async function _openUnsubscribeReviewModal(anchor) { 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'); modal.className = 'modal'; modal.id = 'email-unsubscribe-review-modal'; const settingsPage = anchor?.closest?.('#email-lib-settings-page'); const inlineHost = settingsPage?.querySelector?.('.email-settings-cleanup-section'); const inlineMode = !!inlineHost; if (inlineMode) { modal.className = 'email-unsubscribe-inline-panel'; modal.style.cssText = 'display:block;margin-top:10px;'; modal.innerHTML = `
Unsubscribe review
`; 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 = modal.querySelector('.email-unsub-status'); const listEl = modal.querySelector('.email-unsub-list'); const finishScanStatus = _setUnsubStatusBusy(statusEl, `Scanning recent ${state._libFolder || 'INBOX'} headers…`); try { const res = await fetch(emailApiUrl('/api/email/unsubscribe/scan', { folder: state._libFolder || 'INBOX', limit: 30, max_scan: 180, account_id: state._libAccountId || undefined, }), { credentials: 'same-origin' }); const data = await res.json().catch(() => ({})); if (!data.success) { finishScanStatus?.(data.error || 'Failed to scan email headers'); return; } const candidates = _dedupeUnsubscribeCandidatesForDisplay(data.candidates || []); const rawTotal = Number(data.raw_total || candidates.length || 0); finishScanStatus?.(candidates.length ? `Found ${candidates.length} unsubscribe target${candidates.length === 1 ? '' : 's'} from ${data.scanned || 0} recent emails${rawTotal > candidates.length ? `, collapsed from ${rawTotal} matching emails` : ''}. Review before sending unsubscribe.` : `No unsubscribe candidates found in ${data.scanned || 0} recent emails.`); modal.querySelector('.email-unsub-actions').style.display = candidates.some(c => c.can_execute) ? '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}` : ''; return `
${_esc(c.subject || '(no subject)')}
${_esc(c.from_name || c.from_address || '')} · ${_esc(c.from_address || '')}
${duplicateBadge}
${reasons}
${urlMethod ? `` : ''} ${urlMethod ? `` : ''} ${c.can_execute ? `` : ''}
`; }).join(''); modal._unsubscribeCandidates = candidates; 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) { showToast?.('No safe mail unsubscribe actions found'); 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 = []; 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.textContent = 'Sent'; } _markUnsubscribeCardDone(modal, item.idx); } catch (err) { failed += 1; console.error(err); } } } finally { restoreBusy?.(); } showToast?.(failed ? `Sent ${sent}, failed ${failed}` : `Sent ${sent} unsubscribe email${sent === 1 ? '' : 's'}`); if (sentCandidates.length) _showUnsubscribeCleanupPrompt(modal, sentCandidates); }); 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; const restoreBusy = _setUnsubButtonBusy(btn, 'Starting'); setTimeout(() => restoreBusy?.(), 1400); _askAgentToUnsubscribe(c); }); }); 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.textContent = 'Sent'; _markUnsubscribeCardDone(modal, idx); showToast('Unsubscribe email sent'); _showUnsubscribeCleanupPrompt(modal, [c]); } catch (err) { console.error(err); btn.disabled = false; btn.textContent = _unsubscribeMethodLabel(c.recommended_method); showToast(err?.message || 'Unsubscribe failed'); } }); }); } catch (err) { console.error(err); finishScanStatus?.('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 reopen-after-close paint the previous // list instantly while the network refresh runs behind it — the modal // used to wipe its DOM and spinner-from-empty on every open, even when // the same view was just visible a second ago. // // Session-only (lives in module scope, cleared on hard reload). Search // results and __scheduled__ are deliberately not cached. const _libListCache = new Map(); const _LIB_CACHE_MAX = 24; const _LIB_SESSION_CACHE_PREFIX = 'odysseus.email.list.'; const _LIB_SESSION_CACHE_TTL_MS = 10 * 60 * 1000; const _LIB_LAST_ACCOUNT_KEY = 'odysseus.email.lastAccountId'; let _libPrewarmTimer = null; let _libPrewarmPromise = null; 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; if (_libSyncStatus.loading) { el.textContent = state._libEmails.length ? 'Updating...' : ''; el.style.visibility = state._libEmails.length ? 'visible' : 'hidden'; return; } const parts = []; const rel = _libRelativeTime(_libSyncStatus.updatedAt); if (rel) parts.push(`Last updated: ${rel}`); 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, ].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 _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 _libSessionCachePut(key, value) { try { sessionStorage.setItem(_libSessionCacheKey(key), JSON.stringify({ savedAt: Date.now(), value })); } catch (_) {} } function _libCacheGet(key) { const memory = _libListCache.get(key); if (memory) return memory; const stored = _libSessionCacheGet(key); if (stored) { _libListCache.set(key, stored); return stored; } return null; } function _libCachePut(key, value) { // Re-insert to bump LRU recency. _libListCache.delete(key); _libListCache.set(key, value); _libSessionCachePut(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 } = {}) { _exitEmailReaderModeForList(); _resetBulkSelectionForContextChange(); state._libOffset = 0; _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: false }); _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 => { card.classList.remove('email-card-expanded'); card.classList.remove('doclib-card-expanded'); card.style.minHeight = ''; card.querySelector('.email-card-reader')?.remove(); }); } function _loadEmailsFresh() { _resetEmailListForFreshLoad({ useCache: false }); return _loadEmails({ force: true, useCache: false }); } 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); } } } 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 _loadEmailsWhenChatIdle({ delay = 50, retries = 180, options = {} } = {}) { const run = () => { if (!state._libOpen || !document.getElementById('email-lib-modal')) return; if (_isChatInteractionBusy() && retries > 0) { setTimeout(() => _loadEmailsWhenChatIdle({ delay: 1000, retries: retries - 1, options }), 1000); return; } _loadEmails(options); }; setTimeout(run, Math.max(0, Number(delay) || 0)); } export function prewarmEmailLibrary({ delay = 2500 } = {}) { if (_libPrewarmTimer || _libPrewarmPromise) return; const elapsed = Date.now() - _libLastPrewarmAt; if (elapsed >= 0 && elapsed < 5 * 60 * 1000) return; _libPrewarmTimer = setTimeout(() => { _libPrewarmTimer = null; _libPrewarmPromise = _prewarmEmailViews() .catch(() => {}) .finally(() => { _libPrewarmPromise = null; }); }, Math.max(0, Number(delay) || 0)); } async function _ensureEmailAccountsForPrewarm() { const accountsFresh = _libAccountsLoadedAt && (Date.now() - _libAccountsLoadedAt) < _LIB_ACCOUNTS_TTL_MS; if (Array.isArray(state._libAccounts) && state._libAccounts.length && accountsFresh) { if (!state._libAccountId) { const def = state._libAccounts.find(a => a.is_default) || state._libAccounts[0]; state._libAccountId = def?.id || null; _publishActiveAccount(); } return; } try { const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' }); if (!accountsRes.ok) return; const accountsData = await accountsRes.json().catch(() => ({})); if (Array.isArray(accountsData.accounts)) { state._libAccounts = accountsData.accounts; _libAccountsLoadedAt = Date.now(); if (!state._libAccountId && state._libAccounts.length) { const def = state._libAccounts.find(a => a.is_default) || state._libAccounts[0]; state._libAccountId = def?.id || null; _publishActiveAccount(); } } } catch (_) {} } export async function prewarmUnreadEmails({ limit = 8, maxUid = 0 } = {}) { if (state._libOpen) return; await _ensureEmailAccountsForPrewarm(); if (state._libOpen) return; const accountId = state._libAccountId || ''; 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; _libUnreadPrewarmKey = key; _libUnreadPrewarmAt = Date.now(); 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' }); if (state._libOpen) return; if (!res.ok) return; const data = await res.json().catch(() => null); if (!data || data.error || !Array.isArray(data.emails) || !data.emails.length) return; const sync = data.sync || {}; _libCachePut(_libCacheKeyFor(accountId, folder, 'unread', false), { emails: data.emails, total: data.total || data.emails.length, sync, }); } catch (_) {} } function _sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } async function _prewarmEmailViews() { if (state._libOpen) return; _libLastPrewarmAt = Date.now(); _setEmailSyncStatus({ warming: true }); const folder = 'INBOX'; const filter = 'all'; // The accounts request is cheap and warms the account strip for first open. // Then folder/list requests warm both the client cache and the backend // IMAP/read caches. Failure stays silent: no configured mail should not nag. try { const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' }); if (accountsRes.ok) { const accountsData = await accountsRes.json().catch(() => ({})); if (Array.isArray(accountsData.accounts)) { state._libAccounts = accountsData.accounts; _libAccountsLoadedAt = Date.now(); } } } catch (_) {} const accounts = Array.isArray(state._libAccounts) ? state._libAccounts.filter(a => a && a.enabled !== false) : []; const preferred = state._libAccountId || (accounts.find(a => a.is_default)?.id) || (accounts[0]?.id) || ''; if (!state._libAccountId && preferred) { state._libAccountId = preferred; _publishActiveAccount(); } const orderedAccountIds = [ preferred, ...accounts.map(a => a.id).filter(id => id && id !== preferred), ].filter((id, idx, arr) => arr.indexOf(id) === idx); if (!orderedAccountIds.length) orderedAccountIds.push(''); try { for (const accountId of orderedAccountIds.slice(0, 4)) { if (state._libOpen) return; const ck = _libCacheKeyFor(accountId, folder, filter, false); if (_libCacheGet(ck)) continue; await fetch(emailApiUrl('/api/email/folders', { account_id: accountId || undefined }), { credentials: 'same-origin' }).catch(() => null); await fetch(emailApiUrl('/api/email/unread-state', { folder, account_id: accountId || undefined }), { credentials: 'same-origin' }).catch(() => null); const res = await fetch(emailApiUrl('/api/email/list', { folder, limit: 100, offset: 0, filter, account_id: accountId || undefined, }), { credentials: 'same-origin', }); if (res.ok) { const data = await res.json().catch(() => null); if (data && !data.error) { const sync = data.sync || {}; _libCachePut(ck, { emails: data.emails || [], total: data.total || 0, sync, }); _setEmailSyncStatus({ updatedAt: sync.updated_at || new Date().toISOString(), source: sync.source || '', warming: true, }); } } await _sleep(900); } } 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; state._onEmailClick = config.onEmailClick; } export function isOpen() { return state._libOpen; } export function openEmailLibrary(opts = {}) { if (_libPrewarmTimer) { clearTimeout(_libPrewarmTimer); _libPrewarmTimer = null; } // Force-clean any stale state from previous attempts const existing = document.getElementById('email-lib-modal'); if (existing) existing.remove(); if (state._libEscHandler) { document.removeEventListener('keydown', state._libEscHandler, true); state._libEscHandler = null; } state._libOpen = true; // On mobile the sidebar overlays content — close it so the email view isn't // opened behind it (same pattern as session-switch/delete). if (window.innerWidth <= 768) { const _sb = document.getElementById('sidebar'); if (_sb) _sb.classList.add('hidden'); const _bd = document.getElementById('sidebar-backdrop'); if (_bd) _bd.classList.remove('visible'); // Email was opened last → bring the email windows IN FRONT of any open doc // (they alternate: whichever was opened last wins). The doc stays open // behind it; reopening the doc flips it back on top. document.body.classList.add('email-front'); } state._libEmails = []; state._libOffset = 0; state._libSearch = ''; state._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(); } } if (opts.folder) state._libFolder = opts.folder; state._libPendingExpandUid = opts.uid || null; const modal = document.createElement('div'); modal.className = 'modal'; modal.id = 'email-lib-modal'; modal.innerHTML = ` `; document.body.appendChild(modal); modal.style.display = 'block'; _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') { _showEmailSettingsPage(); return; } _toggleUnreadEmails(); }); unreadBadge?.addEventListener('keydown', (e) => { if (e.key !== 'Enter' && e.key !== ' ') return; e.preventDefault(); if (unreadBadge.dataset.mode === 'away') { _showEmailSettingsPage(); return; } _toggleUnreadEmails(); }); const content = modal.querySelector('.modal-content'); if (content) { const isMobile = window.innerWidth <= 768; if (isMobile) { // Bottom-anchored sheet on mobile content.style.position = 'fixed'; content.style.pointerEvents = 'auto'; content.style.left = '0'; content.style.right = '0'; content.style.bottom = '0'; content.style.top = 'auto'; content.style.transform = 'none'; } else { // Center on screen using fixed positioning + computed offsets content.style.position = 'fixed'; content.style.pointerEvents = 'auto'; // Wait a frame for size to stabilize, then center. Center against the // modal's max-height (85vh) — NOT the live offsetHeight, which is tiny // while the email list is still loading and put the window ~1/3 down // (then it grew off the bottom as the list filled in). requestAnimationFrame(() => { const w = content.offsetWidth; const refH = window.innerHeight * 0.85; content.style.left = Math.max(20, (window.innerWidth - w) / 2) + 'px'; content.style.top = Math.max(20, (window.innerHeight - refH) / 2) + 'px'; content.style.transform = 'none'; }); } } // Wire events document.getElementById('email-lib-close').addEventListener('click', closeEmailLibrary); 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 = e.target.value; _loadEmailsFresh(); }); document.getElementById('email-lib-filter').addEventListener('change', (e) => { state._libFilter = e.target.value; _syncUnreadWindowGlow(); _syncReminderClearButton(); _loadEmailsFresh(); // Sync quick-toggle active states so they mirror the dropdown. document.getElementById('email-undone-btn')?.classList.toggle('active', state._libFilter === 'undone'); document.getElementById('email-reminder-btn')?.classList.toggle('active', state._libFilter === 'reminders'); // Mirror the picker label/icon. _renderFilterPickerCurrent(); }); _initFilterPicker(); document.getElementById('email-attach-btn')?.addEventListener('click', () => { const btn = document.getElementById('email-attach-btn'); state._libHasAttachments = !state._libHasAttachments; btn?.classList.toggle('active', state._libHasAttachments); _syncReminderClearButton(); _loadEmailsFresh(); }); const tagsToggle = document.getElementById('email-tags-toggle-btn'); if (tagsToggle) { tagsToggle.classList.toggle('active', !!state._libShowTags); tagsToggle.setAttribute('aria-pressed', String(!!state._libShowTags)); tagsToggle.addEventListener('click', () => { state._libShowTags = !state._libShowTags; localStorage.setItem('odysseus.email.showTags', state._libShowTags ? '1' : '0'); tagsToggle.classList.toggle('active', !!state._libShowTags); tagsToggle.setAttribute('aria-pressed', String(!!state._libShowTags)); _renderGrid(); document.dispatchEvent(new CustomEvent('odysseus:email-tags-toggle', { detail: { show: state._libShowTags } })); }); } document.getElementById('email-reminders-clear-btn')?.addEventListener('click', async () => { const ok = await styledConfirm('Permanently delete all Odysseus reminder emails?', { confirmText: 'Delete', cancelText: 'Cancel', danger: true, }); if (!ok) return; try { const res = await fetch(`${API_BASE}/api/email/odysseus/reminders?permanent=1${_acct()}`, { method: 'DELETE', credentials: 'same-origin', }); const data = await res.json().catch(() => ({})); showToast(`Deleted ${data.deleted || 0} reminder email${(data.deleted || 0) === 1 ? '' : 's'}`); if ((data.deleted || 0) > 0) { const visibleUids = Array.from(document.querySelectorAll('#email-lib-grid .doclib-card[data-uid]')) .map(card => card.dataset.uid) .filter(Boolean); await _animateEmailCardRemoval(visibleUids); } state._libFilter = 'all'; const filterEl = document.getElementById('email-lib-filter'); if (filterEl) filterEl.value = 'all'; document.getElementById('email-reminder-btn')?.classList.remove('active'); _syncReminderClearButton(); _loadEmailsFresh(); } catch (err) { console.error(err); showToast('Failed to clear reminder emails'); } }); document.getElementById('email-undone-btn')?.addEventListener('click', () => { const btn = document.getElementById('email-undone-btn'); const filterEl = document.getElementById('email-lib-filter'); if (state._libFilter === 'undone') { state._libFilter = 'all'; filterEl.value = 'all'; btn.classList.remove('active'); } else { state._libFilter = 'undone'; filterEl.value = 'undone'; btn.classList.add('active'); document.getElementById('email-reminder-btn')?.classList.remove('active'); } _syncUnreadWindowGlow(); _syncReminderClearButton(); _loadEmailsFresh(); }); document.getElementById('email-reminder-btn')?.addEventListener('click', () => { const btn = document.getElementById('email-reminder-btn'); const filterEl = document.getElementById('email-lib-filter'); if (state._libFilter === 'reminders') { state._libFilter = 'all'; filterEl.value = 'all'; btn.classList.remove('active'); } else { state._libFilter = 'reminders'; filterEl.value = 'reminders'; btn.classList.add('active'); document.getElementById('email-undone-btn')?.classList.remove('active'); } _syncUnreadWindowGlow(); _syncReminderClearButton(); _loadEmailsFresh(); }); // 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(); _showEmailSettingsPage(); }); 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?.(); const expanded = modal.querySelector('.doclib-card.doclib-card-expanded'); if (expanded) { _exitEmailReaderModeForList(); expanded.focus?.({ preventScroll: true }); return; } if (state._selectMode) { state._selectMode = false; state._selectedUids.clear(); _updateBulkBar(); _renderGrid(); return; } closeEmailLibrary(); return; } // Don't hijack arrows / delete while the user is typing somewhere. const t = e.target; if (_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); 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) { _loadEmailsWhenChatIdle({ delay: 0 }); } // 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(); _loadFolders(); _loadEmailReminderBellVisibility(); if (!fastAccountAtOpen || fastAccountAtOpen !== (state._libAccountId || '')) { _loadEmailsWhenChatIdle(); } })(); } 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(); } _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(); } _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(/ 0 ? ' email-account-has-unread' : ''; const unreadTitle = unreadCount > 0 ? ` · ${unreadCount > 999 ? '999+' : unreadCount} unread` : ''; const unreadDot = unreadCount > 0 ? `` : ''; const dot = a.is_default ? _dotFilled : _dotHollow; const dotTitle = a.is_default ? 'Default account' : 'Set as default'; html += `` + `` + `` + ``; } strip.innerHTML = html; strip.querySelectorAll('button[data-acc-id]').forEach(btn => { btn.addEventListener('click', async () => { state._libAccountId = btn.dataset.accId || null; _publishActiveAccount(); _resetEmailListForFreshLoad({ useCache: false }); _renderAccountsStrip(); _loadEmails({ force: true, useCache: false }); _loadFolders({ resetMissing: true }).catch(() => {}); _refreshAccountUnreadHighlights().catch(() => {}); }); }); // Star handler: POST set-default, then reload accounts + re-render so // the chip stars reflect the new default. strip.querySelectorAll('button[data-set-default]').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const acctId = btn.dataset.setDefault; if (!acctId) return; try { await fetch(`${API_BASE}/api/email/accounts/${encodeURIComponent(acctId)}/set-default`, { method: 'POST', credentials: 'same-origin', }); // Refresh the local accounts cache and re-render the strip. for (const a of state._libAccounts) a.is_default = (a.id === acctId); _renderAccountsStrip(); } catch (err) { console.error('Set default account failed:', err); } }); }); // 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 openEmailLibrarySettings() { openEmailLibrary(); await _showEmailSettingsPage(); } export function closeEmailLibrary() { 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; } 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; if (resetMissing && state._libFolder !== '__scheduled__' && !data.folders.includes(state._libFolder)) { state._libFolder = data.folders.includes('INBOX') ? 'INBOX' : (data.folders[0] || 'INBOX'); state._libFilter = 'all'; state._libSearch = ''; state._libHasAttachments = false; _libListCache.clear(); const searchEl = document.getElementById('email-lib-search'); const filterEl = document.getElementById('email-lib-filter'); const attachEl = document.getElementById('email-attachments-btn'); if (searchEl) searchEl.value = ''; if (filterEl) filterEl.value = 'all'; if (attachEl) attachEl.classList.remove('active'); _syncUnreadWindowGlow(); _syncReminderClearButton(); } sel.innerHTML = ''; const { priority, others } = sortedFolders(data.folders); for (const f of priority) { const opt = document.createElement('option'); opt.value = f; opt.textContent = folderDisplayName(f); if (f === state._libFolder) opt.selected = true; sel.appendChild(opt); } if (priority.length > 0 && others.length > 0) { const sep = document.createElement('option'); sep.disabled = true; sep.textContent = '─────────'; sel.appendChild(sep); } for (const f of others) { const opt = document.createElement('option'); opt.value = f; opt.textContent = folderDisplayName(f); if (f === state._libFolder) opt.selected = true; sel.appendChild(opt); } // Scheduled (special virtual folder) const sep2 = document.createElement('option'); sep2.disabled = true; sep2.textContent = '─────────'; sel.appendChild(sep2); const schedOpt = document.createElement('option'); schedOpt.value = '__scheduled__'; schedOpt.textContent = 'Scheduled'; if (state._libFolder === '__scheduled__') schedOpt.selected = true; sel.appendChild(schedOpt); sel.value = state._libFolder; } catch (e) {} } function _crossFolderCandidates() { const available = Array.isArray(state._libFolders) ? state._libFolders.filter(Boolean) : []; const lower = new Map(available.map(f => [String(f).toLowerCase(), f])); const pick = (patterns, fallback) => { for (const p of patterns) { const direct = lower.get(String(p).toLowerCase()); if (direct) return direct; } const match = available.find(f => patterns.some(p => String(f).toLowerCase().includes(String(p).toLowerCase()))); return match || fallback; }; const candidates = [ pick(['INBOX'], 'INBOX'), pick(['[Gmail]/Sent Mail', 'Sent Mail', 'Sent Items', 'INBOX.Sent', 'Sent'], '[Gmail]/Sent Mail'), pick(['Archive', '[Gmail]/All Mail', 'All Mail'], '[Gmail]/All Mail'), ]; return Array.from(new Set(candidates.filter(Boolean))); } 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 _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:action-needed', label: 'Action needed', keywords: ['action needed', 'action', 'needs action'] }, { 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 ''; 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 _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 _renderSearchPills() { const wrap = document.getElementById('email-lib-pills'); if (!wrap) return; const pills = state._libSearchPills || []; const chipBar = document.getElementById('email-lib-chip-bar'); const input = document.getElementById('email-lib-search'); if (chipBar) chipBar.classList.toggle('has-email-lib-pills', pills.length > 0); if (input) input.placeholder = pills.length > 0 ? '' : 'Search by name or text'; const esc = s => String(s || '').replace(/&/g, '&').replace(/ { // Filter pills render as icon-only (the icon is the affordance); // contact + text pills carry their label as text. if (p.type === 'filter') { const titleAttr = `${(p.label || p.value).replace(/"/g, '"')}`; return ` `; } const label = p.type === 'contact' ? (p.name || p.email || '?') : (p.text || ''); return ` ${esc(label)} `; }).join(''); 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); }); }); } 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'); const attachBtn = document.getElementById('email-attach-btn'); if (pill.value === 'filter:has-attachments') { if (!state._libHasAttachments) { state._libHasAttachments = true; if (attachBtn) attachBtn.classList.add('active'); } 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; if (attachBtn) attachBtn.classList.remove('active'); } 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'); const attachBtn = document.getElementById('email-attach-btn'); if (state._libHasAttachments) { state._libHasAttachments = false; if (attachBtn) attachBtn.classList.remove('active'); } 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') { // Single-filter rule — drop any existing filter pill before adding. state._libSearchPills = state._libSearchPills.filter(p => p.type !== 'filter'); state._libSearchPills.push(pill); _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) { _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')) { 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') ); 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 }); } } // 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 _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]'); 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(); _closeAiReplyChoice(); await _runAiReplyFromButton(btn, em, data, mode, noteHint); }); // 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); _closeAiReplyChoice(); }; setTimeout(() => 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'); 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'); showError('Failed to create reminder'); } } // Sanitize untrusted HTML email bodies before injecting via innerHTML. // // Denylist sanitizer — has to block every well-known XSS sink: // -