';
}
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=100&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 = '';
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
document.getElementById('email-lib-select-btn').addEventListener('click', () => {
state._selectMode = !state._selectMode;
state._selectedUids.clear();
_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();
_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;
}
function _filterSuggestions(needle, limit = 6) {
const n = String(needle || '').trim().toLowerCase();
if (!n) return [];
const src = _libSuggestionCache || [];
return src
.map(s => ({ s, score: _scoreSuggestion(s, n) }))
.filter(x => x.score > 0)
.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;
}
// 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 || [];
const draftPill = draft.length >= 1 ? { type: 'text', text: draft } : null;
const effective = draftPill ? pills.concat([draftPill]) : pills;
// 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(/ {
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 _addSearchPill(pill) {
if (!pill) return;
if (!Array.isArray(state._libSearchPills)) state._libSearchPills = [];
// Dedup by email (contact) or text (text pill).
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;
}
state._libSearchPills.push(pill);
_renderSearchPills();
_applyPillFilter();
}
function _removeSearchPillAt(idx) {
if (!Array.isArray(state._libSearchPills)) return;
state._libSearchPills.splice(idx, 1);
_renderSearchPills();
_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(/ `