/**
* 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 = `
${msg.replace(/&/g, '&').replace(/`;
}
} finally {
_loading = false;
}
}
async function loadFolders() {
try {
const res = await fetch(`${API_BASE}/api/email/folders?_=1${_acct()}`);
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 = Array.isArray(em.tags) ? em.tags : [];
const tagPills = tags.length
? `${tags.map(t => `${_esc(t)}`).join('')}`
: '';
const spamTag = em.is_spam_verdict
? `spam `
: '';
const senderAddr = (em.from_address || '').toLowerCase();
item.innerHTML = `
${initial}