/**
* 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 = `
${msg.replace(/&/g, '&').replace(/`;
}
} finally {
_loading = false;
}
}
async function loadFolders() {
try {
const accountQS = _acct().replace(/^&/, '');
const res = await fetch(`${API_BASE}/api/email/folders${accountQS ? `?${accountQS}` : ''}`);
const data = await res.json();
const select = document.getElementById('email-folder-select');
if (!select || !data.folders) return;
_populateFolderSelect(select, data.folders);
} catch (e) {
console.error('Failed to load folders:', e);
}
}
export function sortedFolders(folders) {
const roleOf = (folder) => {
const f = String(folder || '').toLowerCase();
if (f === 'inbox') return 'inbox';
if (f.includes('sent')) return 'sent';
if (f.includes('starred') || f.includes('flagged')) return 'starred';
if (f.includes('draft')) return 'drafts';
if (f.includes('all mail') || f.includes('archive')) return 'archive';
if (f.includes('spam') || f.includes('junk')) return 'junk';
if (f.includes('trash') || f.includes('bin') || f.includes('deleted')) return 'trash';
return '';
};
const roleOrder = ['inbox', 'sent', 'starred', 'archive', 'junk', 'trash', 'drafts'];
const found = new Map();
const others = [];
for (const f of folders) {
const role = roleOf(f);
if (role && !found.has(role)) found.set(role, f);
else others.push(f);
}
return { priority: roleOrder.map(role => found.get(role)).filter(Boolean), others };
}
export function folderDisplayName(folder) {
const raw = String(folder || '');
const f = raw.toLowerCase();
if (f === 'inbox') return 'INBOX';
if (f.includes('all mail')) return 'Archive / All Mail';
if (f.includes('archive')) return 'Archive';
if (f.includes('spam')) return 'Spam';
if (f.includes('junk')) return 'Junk';
if (f.includes('trash') || f.includes('bin') || f.includes('deleted')) return 'Trash';
if (f.includes('sent')) return 'Sent';
if (f.includes('draft')) return 'Drafts';
return raw;
}
function _populateFolderSelect(select, folders) {
select.innerHTML = '';
const { priority, others } = sortedFolders(folders);
for (const folder of priority) {
const opt = document.createElement('option');
opt.value = folder;
opt.textContent = folderDisplayName(folder);
if (folder === _currentFolder) opt.selected = true;
select.appendChild(opt);
}
if (priority.length > 0 && others.length > 0) {
const sep = document.createElement('option');
sep.disabled = true;
sep.textContent = '─────────';
select.appendChild(sep);
}
for (const folder of others) {
const opt = document.createElement('option');
opt.value = folder;
opt.textContent = folderDisplayName(folder);
if (folder === _currentFolder) opt.selected = true;
select.appendChild(opt);
}
}
function _renderList() {
const list = document.getElementById('email-list');
if (!list) return;
list.innerHTML = '';
if (_senderFilter) {
const chip = document.createElement('div');
chip.className = 'email-filter-chip';
chip.innerHTML = `From: ${_esc(_senderFilterLabel || _senderFilter)}`;
chip.querySelector('.email-filter-chip-clear').addEventListener('click', () => _clearSenderFilter());
list.appendChild(chip);
}
if (_emails.length === 0) {
const empty = document.createElement('div');
empty.className = 'email-loading';
empty.textContent = _senderFilter ? `No emails from ${_senderFilterLabel || _senderFilter}` : 'No emails';
list.appendChild(empty);
return;
}
for (const em of _emails) {
list.appendChild(_createEmailItem(em));
}
const loadMore = document.getElementById('email-load-more');
if (loadMore) {
loadMore.style.display = (_emails.length < _total) ? '' : 'none';
}
}
function _setSenderFilter(addr, label) {
_senderFilter = addr;
_senderFilterLabel = label || addr;
_offset = 0;
loadEmails(false);
}
function _clearSenderFilter() {
_senderFilter = null;
_senderFilterLabel = null;
_offset = 0;
loadEmails(false);
}
function _createEmailItem(em) {
const item = document.createElement('div');
item.className = 'list-item email-item' + (em.is_spam_verdict ? ' email-item-spam' : '');
item.setAttribute('role', 'option');
item.setAttribute('data-uid', em.uid);
let dateStr = '';
if (em.date) {
try {
const d = new Date(em.date);
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
if (isToday) {
dateStr = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else {
dateStr = d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
} catch (_) {}
}
const senderName = em.from_name || em.from_address;
const initial = (senderName || '?')[0].toUpperCase();
const color = _senderColor(senderName);
const attachIcon = em.has_attachments
? ''
: '';
// Per-row dot tint: if the urgency scanner flagged this UID, override the
// per-sender pastel with red (3) / orange (2). Look up by any cached key
// ending in `:` since the per_uid map is keyed `:`
// and the inbox list doesn't surface the account id per row.
let _unreadColor = color;
let _unreadTitle = 'Unread';
try {
const us = window._emailUrgencyState;
if (us && us.per_uid && em.uid != null) {
const suffix = ':' + String(em.uid);
for (const k of Object.keys(us.per_uid)) {
if (k.endsWith(suffix)) {
const v = us.per_uid[k] || {};
const score = v.score || 0;
if (score >= 3) { _unreadColor = 'var(--color-error, #e06c75)'; _unreadTitle = 'Urgent — ' + (v.reason || 'needs reply now'); }
else if (score === 2) { _unreadColor = '#f0ad4e'; _unreadTitle = 'Reply soon — ' + (v.reason || ''); }
break;
}
}
}
} catch (_) {}
const unreadIcon = (!em.is_read && !em.is_answered)
? ``
: '';
const tags = _showEmailTags ? _visibleEmailTagsForRender(em) : [];
const tagPills = _emailTagGroupHtml(tags, em);
const spamTag = _showEmailTags && em.is_spam_verdict
? `spam `
: '';
const senderAddr = (em.from_address || '').toLowerCase();
item.innerHTML = `
${initial}