/** * 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, prewarmEmailLibrary } from './emailLibrary.js'; import * as Modals from './modalManager.js'; import { applyEdgeDock } from './modalSnap.js'; import { buildReplyAllCc } from './emailLibrary/replyRecipients.js'; const API_BASE = window.location.origin; const _acct = () => window.__odysseusActiveEmailAccount ? `&account_id=${encodeURIComponent(window.__odysseusActiveEmailAccount)}` : ''; 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 ----------'; 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; } document.querySelectorAll('.email-item[data-uid="' + CSS.escape(String(uid)) + '"]').forEach(item => { item.classList.remove('email-unread'); const check = item.querySelector('.email-done-check'); if (check) check.classList.add('active'); }); }); 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 export function init(documentModule) { _docModule = documentModule; _bindEvents(); // 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'); } }, }); _watchDocOpenToReDockEmail(); } export async function openReplyDraft(uid, folder = 'INBOX', mode = 'reply') { if (!uid) return; const previousFolder = _currentFolder; _currentFolder = folder || 'INBOX'; try { await _openEmail({ uid: String(uid), subject: '' }, null, null, mode || 'reply'); } finally { _currentFolder = previousFolder || _currentFolder; } } // 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(); }); } // Initial unread count check, refresh every 60s _refreshUnreadCount(); setInterval(_refreshUnreadCount, 60000); prewarmEmailLibrary({ delay: 3000 }); // 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: unread list + urgency state. const [listRes, urgRes] = await Promise.all([ fetch(`${API_BASE}/api/email/list?folder=INBOX&limit=50&filter=unread${_acct()}`), fetch(`${API_BASE}/api/email/urgency-state`, { credentials: 'same-origin' }).catch(() => null), ]); if (!listRes || !listRes.ok) return; const data = await listRes.json(); if (!dot) return; const emails = data.emails || []; if (emails.length === 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 = Math.max(...emails.map(e => parseInt(e.uid, 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(`${API_BASE}/api/email/list?folder=INBOX&limit=1${_acct()}`) .then(r => r.json()) .then(data => { const emails = data.emails || []; if (emails.length > 0) { const maxUid = Math.max(...emails.map(e => parseInt(e.uid, 10) || 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()}&_=${Date.now()}`); 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 = `