/** * emailInbox.js — Email inbox list in sidebar. * Follows the session list pattern: list items, click to open as document, archive, etc. */ import spinnerModule from './spinner.js'; import sessionModule from './sessions.js'; import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibOpen } from './emailLibrary.js'; import * as Modals from './modalManager.js'; import { applyEdgeDock } from './modalSnap.js'; import { buildReplyAllCc, extractEmail } from './emailLibrary/replyRecipients.js'; import { emailApiUrl, emailAccountQuery } from './emailShared.js'; import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js'; const API_BASE = window.location.origin; const _acct = () => emailAccountQuery('&'); const _emailSetupHint = () => '
Setup: Settings › Integrations
'; // SVG icons matching sessions.js dropdown style const _replyIcon = ''; const _archiveIcon = ''; const _deleteIcon = ''; const _unreadIcon = ''; const _starIcon = ''; const _starFilledIcon = ''; const _bellIcon = ''; const _icon = (svg) => `${svg}`; const _replySeparator = '---------- Previous message ----------'; const _DONE_RESPONSE_TAGS = new Set(['urgent', 'reply-soon', 'action-needed']); function _splitEmailAddresses(raw) { return (typeof raw === 'string' ? raw : '') .split(',') .map((x) => x.trim()) .filter(Boolean); } function _isMyEmailAddress(addr, myAddresses) { const email = extractEmail(addr); if (!email) return false; return new Set((myAddresses || []).map(a => String(a || '').trim().toLowerCase()).filter(Boolean)).has(email); } function _withoutMyAddresses(raw, myAddresses) { return _splitEmailAddresses(raw).filter(addr => !_isMyEmailAddress(addr, myAddresses)); } 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 _openEmailTagFilter(tag) { const normalized = String(tag || '').trim().toLowerCase().replace(/_/g, '-'); if (!normalized || normalized === 'calendar') return; try { openEmailLibrary(); } catch (_) {} setTimeout(() => { document.dispatchEvent(new CustomEvent('odysseus:email-filter-tag', { detail: { tag: normalized } })); }, 0); } 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}`; } function _visibleEmailTagsForRender(em) { const tags = Array.isArray(em?.tags) ? em.tags : []; if (!em?.is_answered) return tags; return tags.filter(t => !_DONE_RESPONSE_TAGS.has(String(t || '').trim().toLowerCase().replace(/_/g, '-'))); } function _clearDoneResponseTagsLocal(em) { if (!em || !Array.isArray(em.tags)) return; em.tags = em.tags.filter(t => !_DONE_RESPONSE_TAGS.has(String(t || '').trim().toLowerCase().replace(/_/g, '-'))); } function _cleanAiReplyText(text) { if (!text) return ''; let t = String(text); const open = /<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/i; const close = /<<<\s*END\s*>>+/i; const m = open.exec(t); if (m) { const rest = t.slice(m.index + m[0].length); const c = close.exec(rest); t = c ? rest.slice(0, c.index) : rest; } return t .replace(/<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/gi, '') .replace(/<<<\s*END\s*>>+/gi, '') .trim(); } function _shouldUseFastAiReply(data) { const body = String(data?.body || data?.body_html || ''); const subject = String(data?.subject || ''); const atts = Array.isArray(data?.attachments) ? data.attachments : []; if (atts.length > 0) return false; const text = `${subject}\n${body}`.toLowerCase(); if (/\b(attach(?:ed|ment)?|pdf|document|contract|invoice|receipt|quote|estimate|proposal|question|questions|details|schedule|booking|reservation|meeting|calendar|availability|confirm|confirmation|review|sign|signature)\b/.test(text)) { return false; } return body.length < 2500; } let _emails = []; let _currentFolder = 'INBOX'; let _offset = 0; let _total = 0; // Replying to an email marks the source \Answered server-side and fires // `email-answered`. Reflect it live in the inbox list so it shows as done // immediately (no manual refresh needed). window.addEventListener('email-answered', (e) => { const uid = e.detail && e.detail.uid; if (uid == null) return; const em = _emails.find(x => String(x.uid) === String(uid)); if (em) { em.is_answered = true; em.is_read = true; _clearDoneResponseTagsLocal(em); } document.querySelectorAll('.email-item[data-uid="' + CSS.escape(String(uid)) + '"]').forEach(item => { item.classList.remove('email-unread'); item.querySelectorAll('.email-tag-urgent, .email-tag-reply-soon, .email-tag-action-needed').forEach(n => n.remove()); const check = item.querySelector('.email-done-check'); if (check) check.classList.add('active'); // Auto-mark from sending a reply — flash the row so the user sees the // state change without staring at it. Class self-removes after the // animation so it doesn't replay on re-renders. item.classList.add('email-auto-done-flash'); setTimeout(() => item.classList.remove('email-auto-done-flash'), 1200); }); }); let _loading = false; let _expanded = false; let _docModule = null; let _listSpinner = null; let _senderFilter = null; // email address (lowercased) to filter by, or null let _senderFilterLabel = null; // display label for the active filter chip let _showEmailTags = localStorage.getItem('odysseus.email.showTags') !== '0'; export function init(documentModule) { _docModule = documentModule; _bindEvents(); document.addEventListener('odysseus:email-tags-toggle', (e) => { _showEmailTags = e.detail?.show !== false; _renderList(); }); // Init the library popup with a callback to open emails initEmailLibrary({ documentModule, onEmailClick: async (opts) => { // Reply / AI Reply / Compose open a draft in the doc editor. // - Desktop: dock the email to the LEFT so it stays visible beside the // reply draft (which opens on the right) — read-while-you-reply. // - Mobile: there's no room for a split, so minimize the email modal; // the draft comes to the front and the inbox stays a tap away as a // minimized chip. // Never call closeEmailLibrary() here — that destroys state. try { if (Modals.isRegistered('email-lib-modal')) { const emailModal = document.getElementById('email-lib-modal'); if (window.innerWidth > 768 && emailModal && !emailModal.classList.contains('hidden')) { applyEdgeDock(emailModal, 'left'); } // Mobile: do NOT pre-mount the pane here. The load path (open/inject) // mounts it exactly once when the doc is ready; the doc-view z-index // rule slides it up OVER the email (which stays behind). Pre-mounting // here caused a double-mount — the early pane was torn down by the // compose session-switch, then remounted, which looked like a doc // flashing before the smooth slide. } } catch (_) {} if (opts.compose) { _composeNew(); return; } if (opts.email) { await _openEmail(opts.email, null, opts.emailData, opts.mode || 'reply', opts.noteHint || ''); } }, }); _watchDocOpenToReDockEmail(); } export async function openReplyDraft(uid, folder = 'INBOX', mode = 'reply', prefilledBody = '') { if (!uid) return; const previousFolder = _currentFolder; _currentFolder = folder || 'INBOX'; try { await _openEmail({ uid: String(uid), subject: '' }, null, null, mode || 'reply', '', prefilledBody || ''); } finally { _currentFolder = previousFolder || _currentFolder; } } function _bringEmailReplyDraftToFrontOnMobile() { if (window.innerWidth > 768) return; document.body.classList.remove('email-front', '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'); // Keep the email sheet visible behind the reply document on mobile. The // document panel sits above it via the normal doc-view z-index rules, and // swiping the document down minimizes it to a chip to reveal the email. document.querySelectorAll('#email-lib-modal, .modal[id^="email-reader-"]').forEach(modal => { modal.classList.remove('email-snap-left', 'modal-left-docked', 'modal-right-docked'); modal.style.removeProperty('z-index'); }); const docPane = document.getElementById('doc-editor-pane'); if (docPane) docPane.style.setProperty('z-index', '10010', 'important'); } // When the document editor pane opens (body.doc-view turns on), make sure the // email modal is on the LEFT — even if it was previously docked RIGHT or // floating — so the email and the doc always end up side-by-side. The actual // width math lives in modalSnap.js (`_anchorLeftDock` shrinks the email when // the doc is rendered to the right). let _docOpenObs = null; function _watchDocOpenToReDockEmail() { if (_docOpenObs) return; if (typeof MutationObserver === 'undefined') return; let last = document.body.classList.contains('doc-view'); _docOpenObs = new MutationObserver(() => { const cur = document.body.classList.contains('doc-view'); if (cur && !last) { if (window.innerWidth > 768) { const emailModal = document.getElementById('email-lib-modal'); if (emailModal && !emailModal.classList.contains('hidden')) { // Already left-docked → nothing to do (modalSnap re-anchors on its own). if (!emailModal.classList.contains('modal-left-docked')) { try { applyEdgeDock(emailModal, 'left'); } catch (_) {} } } // Same treatment for an open email-reader modal (one specific email // open standalone — typical "click email, click doc" flow). document.querySelectorAll('.modal[id^="email-reader-"]').forEach(m => { if (m.classList.contains('hidden')) return; if (m.classList.contains('modal-left-docked')) return; try { applyEdgeDock(m, 'left'); } catch (_) {} }); } } last = cur; }); _docOpenObs.observe(document.body, { attributes: true, attributeFilter: ['class'] }); } function _bindEvents() { // Clicking anywhere in the email section header opens the popup // (except the compose button which has its own handler) const section = document.getElementById('email-section'); const header = section?.querySelector('.section-header-flex'); if (header) { header.style.cursor = 'pointer'; header.addEventListener('click', (e) => { if (e.target.closest('#email-compose-btn')) return; openEmailLibrary(); markInboxAsSeen(); }); } // Compose button creates a new email document const composeBtn = document.getElementById('email-compose-btn'); if (composeBtn) { composeBtn.addEventListener('click', (e) => { e.stopPropagation(); _composeNew(); }); } // Delay the lightweight unread badge check so opening Odysseus doesn't // compete with the initial chat/session paint. The full email list now loads // only when the inbox is actually opened. setTimeout(_refreshUnreadCount, 8000); setInterval(_refreshUnreadCount, 60000); // Deep-link: #email=: opens the library and expands that card _maybeOpenFromHash(); window.addEventListener('hashchange', _maybeOpenFromHash); } function _maybeOpenFromHash() { const h = window.location.hash || ''; const m = h.match(/^#email=([^:]+):(\d+)/); if (!m) return; const folder = decodeURIComponent(m[1]); const uid = m[2]; try { openEmailLibrary({ folder, uid }); } catch (e) { console.error(e); } // Clear the hash so reloads don't reopen try { history.replaceState(null, '', window.location.pathname + window.location.search); } catch (_) {} } // Tint helper — turns the urgent-email-scanner's max_score into a dot color. // Falls back to the default (blue / unset) when scanner is off or no urgent. function _urgencyColor(score) { if (score >= 3) return 'var(--color-error, #e06c75)'; // red — urgent now if (score === 2) return '#f0ad4e'; // orange — reply soon return ''; // default (blue / theme) } async function _refreshUnreadCount() { // Default the dot to hidden — only the verified "new mail above threshold" // path below should turn it on. Without this, a fetch error or a backend // returning malformed data left a stale dot from a previous account/session. const dot = document.getElementById('email-unread-dot'); if (dot && !dot._stickyState) dot.style.display = 'none'; try { // Parallel: cheap unread state + urgency state. const [stateRes, urgRes] = await Promise.all([ fetch(emailApiUrl('/api/email/unread-state', { folder: 'INBOX' })), fetch(`${API_BASE}/api/email/urgency-state`, { credentials: 'same-origin' }).catch(() => null), ]); if (!stateRes || !stateRes.ok) return; const data = await stateRes.json(); if (!dot) return; const unreadCount = Number(data.unread_count || 0); if (unreadCount <= 0) { dot.style.display = 'none'; return; } // Compare highest unread UID to the last-seen threshold in localStorage const lastSeen = parseInt(localStorage.getItem('odysseus-email-last-seen-uid') || '0', 10); const maxUid = parseInt(data.max_uid || '0', 10) || 0; // Only show dot if there's a new email above the threshold dot.style.display = maxUid > lastSeen ? '' : 'none'; // Color the dot by urgency tier. Cache the per-uid map so the per-row // renderer can reuse it without a second fetch. if (dot.style.display !== 'none' && urgRes && urgRes.ok) { try { const ud = await urgRes.json(); window._emailUrgencyState = ud; const tint = _urgencyColor(ud.max_score || 0); if (tint) dot.style.backgroundColor = tint; else dot.style.backgroundColor = ''; } catch (_) {} } else if (dot.style.display !== 'none') { dot.style.backgroundColor = ''; } } catch (e) { // Network/parse error — keep the dot hidden (default at the top). if (dot) dot.style.display = 'none'; } } export function markInboxAsSeen() { // Called when the user opens the inbox popup — clears the notif dot try { // Find current max UID so subsequent arrivals trigger the dot fetch(emailApiUrl('/api/email/unread-state', { folder: 'INBOX' })) .then(r => r.json()) .then(data => { const maxUid = parseInt(data.max_uid || '0', 10) || 0; if (maxUid > 0) { localStorage.setItem('odysseus-email-last-seen-uid', String(maxUid)); } const dot = document.getElementById('email-unread-dot'); if (dot) dot.style.display = 'none'; }) .catch(() => {}); } catch (e) {} } export async function loadEmails(append = false) { if (_loading) return; _loading = true; const list = document.getElementById('email-list'); if (!list) { _loading = false; return; } if (!append) { list.innerHTML = ''; // Show whirlpool spinner if (_listSpinner) { _listSpinner.destroy(); _listSpinner = null; } const sp = spinnerModule.createWhirlpool(20); _listSpinner = sp; list.appendChild(sp.element); } try { const fromQS = _senderFilter ? `&from=${encodeURIComponent(_senderFilter)}` : ''; const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(_currentFolder)}&limit=50&offset=${_offset}${fromQS}${_acct()}`); const data = await res.json(); if (data.error) throw new Error(data.error); if (!append) _emails = []; _emails.push(...(data.emails || [])); _total = data.total || 0; // Remove spinner if (_listSpinner) { _listSpinner.destroy(); _listSpinner = null; } _renderList(); const unreadCount = _emails.filter(e => !e.is_read).length; const dot = document.getElementById('email-unread-dot'); if (dot) dot.style.display = unreadCount > 0 ? '' : 'none'; } catch (e) { console.error('Failed to load emails:', e); if (_listSpinner) { _listSpinner.destroy(); _listSpinner = null; } if (!append && list) { const msg = e && e.message ? `Failed to load: ${e.message}` : 'Failed to load'; list.innerHTML = `