';
}
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)}` : '';
}
// 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;
let _libPrewarmTimer = null;
let _libPrewarmPromise = null;
let _libLastPrewarmAt = 0;
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 _libCacheGet(key) { return _libListCache.get(key) || null; }
function _libCachePut(key, value) {
// Re-insert to bump LRU recency.
_libListCache.delete(key);
_libListCache.set(key, value);
if (_libListCache.size > _LIB_CACHE_MAX) {
const oldest = _libListCache.keys().next().value;
_libListCache.delete(oldest);
}
}
function _resetEmailListForFreshLoad() {
state._libOffset = 0;
state._libEmails = [];
state._libTotal = 0;
_libLoadSeq += 1;
const grid = document.getElementById('email-lib-grid');
if (grid) _renderEmailLoading(grid);
const stats = document.getElementById('email-lib-stats');
if (stats) stats.textContent = 'Loading...';
}
function _loadEmailsFresh() {
_resetEmailListForFreshLoad();
return _loadEmails({ force: true, useCache: false });
}
export function prewarmEmailLibrary({ delay = 2500 } = {}) {
if (_libPrewarmTimer || _libPrewarmPromise) return;
const elapsed = Date.now() - _libLastPrewarmAt;
if (elapsed >= 0 && elapsed < 60000) return;
_libPrewarmTimer = setTimeout(() => {
_libPrewarmTimer = null;
_libPrewarmPromise = _prewarmDefaultEmailView()
.catch(() => {})
.finally(() => { _libPrewarmPromise = null; });
}, Math.max(0, Number(delay) || 0));
}
async function _prewarmDefaultEmailView() {
if (state._libOpen) return;
_libLastPrewarmAt = Date.now();
const folder = 'INBOX';
const filter = 'all';
const accountId = state._libAccountId || '';
const ck = _libCacheKeyFor(accountId, folder, filter, false);
if (_libCacheGet(ck)) return;
// The accounts request is cheap and warms the account strip for first open.
// Then the list request warms both the client cache and the backend IMAP/read
// cache. Failure stays silent: no configured mail should not nag on app boot.
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;
}
} catch (_) {}
const accountQS = accountId ? `&account_id=${encodeURIComponent(accountId)}` : '';
const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folder)}${accountQS}&limit=500&offset=0&filter=${filter}`, {
credentials: 'same-origin',
});
if (!res.ok) return;
const data = await res.json().catch(() => null);
if (!data || data.error) return;
_libCachePut(ck, { emails: data.emails || [], total: data.total || 0 });
}
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 });
}
}
// 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 (_) {}
// 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 = {}) {
// 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();
}
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 = `
Email
0 Selected
`;
document.body.appendChild(modal);
modal.style.display = 'block';
// 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();
_toggleUnreadEmails();
});
unreadBadge?.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
_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);
// 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();
});
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 () => {
const btn = document.getElementById('email-lib-refresh-btn');
btn?.classList.add('email-lib-refreshing');
state._libOffset = 0;
// Don't wipe state._libEmails — _loadEmails will paint the cached
// 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);
}
}
});
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?.();
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);
_renderAccountsLoading();
// Await accounts before loading emails so the list request carries the
// right account_id from the very first fetch (now that we auto-select
// an explicit account instead of relying on a 'Default' chip).
(async () => {
await _loadAccounts();
_loadFolders();
_loadEmailReminderBellVisibility();
_loadEmails();
})();
}
async function _loadAccounts() {
try {
const r = await fetch(`${API_BASE}/api/email/accounts`);
if (!r.ok) return;
const d = await r.json();
state._libAccounts = d.accounts || [];
} catch (_) { 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) {
const def = state._libAccounts.find(a => a.is_default) || state._libAccounts[0];
state._libAccountId = def.id;
_publishActiveAccount();
}
_renderAccountsStrip();
}
function _renderAccountsStrip() {
const strip = document.getElementById('email-lib-accounts');
if (!strip) return;
strip.style.display = 'flex';
const esc = s => String(s || '').replace(/&/g, '&').replace(/';
const _dotHollow = '';
for (const a of state._libAccounts) {
const active = state._libAccountId === a.id ? ' active' : '';
const label = a.name || a.from_address || a.imap_user || 'account';
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();
_renderAccountsStrip();
await _loadFolders({ resetMissing: true });
_loadEmails({ force: true, useCache: false });
});
});
// 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();
}
export function closeEmailLibrary() {
const modal = document.getElementById('email-lib-modal');
if (modal) modal.remove();
_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 } = {}) {
const seq = ++_libFolderSeq;
const accountAtStart = state._libAccountId || '';
try {
const res = await fetch(`${API_BASE}/api/email/folders?_=${Date.now()}${_acct()}`);
const 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)));
}
// 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;
// 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;
}
// 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:spam', label: 'Spam', keywords: ['spam', 'junk'] },
{ value: 'filter:tag:newsletter', label: 'Newsletter', keywords: ['newsletter', 'newsletters', 'subscriptions'] },
{ value: 'filter:tag:marketing', label: 'Marketing', keywords: ['marketing', 'promo', 'promotional'] },
];
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 || '' }, 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();
return (
String(em.subject || '').toLowerCase().includes(needle) ||
String(em.from_name || '').toLowerCase().includes(needle) ||
String(em.from_address || '').toLowerCase().includes(needle) ||
String(em.snippet || em.preview || '').toLowerCase().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() {
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 = _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 serverHandledDraft = !!(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
? 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 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 `${_libFilterIconFor(p.value)}`;
}
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;
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 _removeSearchPillAt(idx) {
if (!Array.isArray(state._libSearchPills)) return;
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;
state._libSearch = '';
state._libOffset = 0;
const _searchInput = document.getElementById('email-lib-search');
if (_searchInput) _searchInput.value = '';
_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 `
`;
}).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 () => {
state._libSearchDraft = input.value;
try { console.log('[email-search] input event, value=', JSON.stringify(input.value)); } catch {}
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;
try { console.log('[email-search] firing _doSearch for', cur); } catch {}
_doSearch();
} else {
try { console.log('[email-search] debounce expired but input changed (was', v, 'now', cur, ')'); } catch {}
}
}, 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('blur', () => {
// Delay so click/mousedown on a suggestion fires first.
setTimeout(_hideSearchSuggestions, 120);
});
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() {
const seq = ++_libSearchSeq;
const q = state._libSearch.trim();
if (q.length < 2) {
// 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 = state._libFolder || 'INBOX';
// 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;
// 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 accountQS = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : '';
try {
// Single fast fetch — limit=100 so the IMAP fetch loop doesn't spend
// 60 s pulling 500 headers serially. We can wire "Load more" later
// off `state._libTotal` if needed.
const res = await fetch(`${API_BASE}/api/email/search?folder=${encodeURIComponent(folderAtStart)}${accountQS}&q=${encodeURIComponent(q)}&limit=100`);
const data = await res.json();
if (
seq !== _libSearchSeq ||
q !== state._libSearch.trim() ||
accountAtStart !== (state._libAccountId || '') ||
folderAtStart !== (state._libFolder || 'INBOX')
) {
return;
}
if (data.error) throw new Error(data.error);
const results = data.emails || [];
_libSearchHadResults = true;
state._libEmails = results; // temporarily replace with search results
state._libTotal = data.total || results.length;
// Refresh the pre-search snapshot so any subsequent _applyPillFilter
// call (focus / pill edit / etc.) sources from the actual search
// results, not the stale inbox page that was loaded before the
// search ran. Without this, active pills (a contact pill from the
// suggestion the user just clicked) would filter the inbox snapshot
// → near-always empty → user sees "no emails" even though the
// server search succeeded.
_libPreSearchEmails = results.slice();
_libPreSearchTotal = state._libTotal;
// If pills are active (and they usually are after a contact-pill or
// text-pill add), re-run the pill filter so the visible grid is the
// pill-narrowed intersection of the new search results. Otherwise
// _renderGrid below would render the raw server response, which
// might not match the active pills the user just added.
if ((state._libSearchPills || []).length) {
_applyPillFilter();
// Fall back to rendering the raw results if the pill intersection
// hid everything but the user just confirmed they want this query.
if (!(state._libEmails || []).length) state._libEmails = results;
}
_renderGrid();
const count = data.total || results.length;
if (stats) stats.textContent = `${count} match${count === 1 ? '' : 'es'} on server`;
try { console.log('[email-search]', JSON.stringify({ q, folder: folderAtStart, count, returned: results.length })); } catch {}
} catch (e) {
if (stats) stats.textContent = originalStatsText || 'Search failed';
try { console.error('[email-search] fetch failed:', e); } catch {}
} finally {
_libSearchInFlight = false;
}
}
// Custom dropdown for the email filter (All/Unread/Favorites/...). Replaces
// the native