/**
* emailLibrary/index.js — Email library popup modal.
* Similar pattern to documentLibrary.js. Shows emails in a grid with search/filter.
*
* `static/js/emailLibrary.js` re-exports this module's public surface so the
* old import path keeps resolving; see the comment there.
*/
import spinnerModule from '../spinner.js';
import { styledConfirm, showToast, emptyStateIcon } from '../ui.js?v=20260916largetoolscroll1';
import { folderDisplayName, sortedFolders } from '../emailInbox.js?v=20260914aireply4';
import settingsModule from '../settings.js?v=20260912writingstyle3';
import * as Modals from '../modalManager.js';
import { makeWindowDraggable } from '../windowDrag.js';
import {
_esc,
_escLinkify,
_extractName,
_formatRecipients,
_senderColor,
_sanitizeHtml,
} from './utils.js';
import { state } from './state.js';
import { getSettings } from '../appConfig.js';
import { collapseSidebarToRail } from '../modalSnap.js';
import { emailApiUrl } from '../emailShared.js';
import {
bindMenuDismiss,
dismissTopMenu,
bindExpandedCardDismiss,
unbindExpandedCardDismiss,
} from '../escMenuStack.js';
import { _aiReplyIcon, _handleAiReplyButton, _hasMultipleRecipients } from './aiReply.js';
import {
_buildAttsHtmlFor,
_loadDeferredAttachmentsIntoReader,
_wireEmailAttachmentWrap,
} from './attachments.js';
import { _safeRenderEmailBody, _wireEmailInlineImages } from './bodyRender.js';
import {
_bulkAction,
_showBulkActionsMenu,
_showCardMenu,
_showReaderMoreMenu,
_updateBulkBar,
} from './menus.js';
import { _emailBodyTextForTranslate, _openEmailAsTab, _summarizeEmail } from './reader.js';
import {
_EMAIL_SETTINGS_ICON,
_bindEmailSettingsPageControls,
_emailCleanupSettingsHtml,
_emailDisplaySettingsHtml,
_emailSettingsAccountSelectHtml,
_emailSettingsFormHtml,
_emailSettingsLoadingHtml,
_emailWritingStyleHtml,
_fetchEmailSettingsConfig,
_fetchEmailWritingStyle,
_hideEmailSettingsPage,
_isAutoReplyActiveForCurrentAccount,
_readEmailInlineImagesPreference,
_showEmailSettingsPage,
_syncAutoReplyCalendarEvent,
_syncEmailAutoReplyTitle,
_syncEmailSettingsAccountPicker,
} from './settingsPage.js';
const API_BASE = window.location.origin;
let _emailUnreadChipClickWired = false;
let _libLoadSeq = 0;
let _emailMailboxGeneration = 0;
let _emailCardOpenSeq = 0;
let _emailReadMutationSeq = 0;
const _emailReadMutations = new Map();
let _libFolderSeq = 0;
let _libSearchSeq = 0;
let _libSearchHadResults = false;
let _libSearchInFlight = false;
let _activeEmailReaderForSelectAll = null;
let _libAccountsLoadedAt = 0;
const _LIB_ACCOUNTS_TTL_MS = 5 * 60 * 1000;
let _accountUnreadSeq = 0;
let _accountUnreadState = new Map(); // account_id -> { unreadCount, maxUid }
function _hasActiveEmailSearchResults() {
if (!_libSearchHadResults) return false;
if (String(state._libSearch || '').trim()) return true;
return (state._libSearchPills || []).some(p => p?.type === 'text' || p?.type === 'contact');
}
export function _normalizeEmailStateFlags(em) {
if (!em || typeof em !== 'object') return em;
const flags = String(em.flags || '');
const answered = !!(
em.is_answered ||
em.is_done ||
em.done ||
em.answered ||
flags.includes('\\Answered')
);
// Once the normalized field exists, it is the source of truth. Some
// cached/fixture rows also retain legacy aliases such as `favorite`; OR-ing
// those aliases on every render would resurrect a favorite immediately
// after the user turns it off.
const flagged = Object.prototype.hasOwnProperty.call(em, 'is_flagged')
? !!em.is_flagged
: !!(
em.is_favorite ||
em.favorite ||
em.flagged ||
em.starred ||
flags.includes('\\Flagged')
);
const read = !!(
em.is_read ||
em.read ||
flags.includes('\\Seen')
);
em.is_answered = answered;
em.is_done = answered;
em.is_flagged = flagged;
em.is_read = read;
return em;
}
function _isEmailTypingTarget(t) {
return !!(t && (
t.tagName === 'INPUT' ||
t.tagName === 'TEXTAREA' ||
t.tagName === 'SELECT' ||
t.isContentEditable
));
}
function _selectEmailReaderContents(reader) {
if (!reader || !reader.isConnected) return false;
const hiddenModal = reader.closest('.modal.hidden');
if (hiddenModal) return false;
const range = document.createRange();
range.selectNodeContents(reader);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
return true;
}
export function _markEmailReaderActive(reader) {
if (!reader) return;
_activeEmailReaderForSelectAll = reader;
if (reader.dataset.selectAllWired === '1') return;
reader.dataset.selectAllWired = '1';
reader.addEventListener('pointerdown', () => { _activeEmailReaderForSelectAll = reader; }, true);
reader.addEventListener('focusin', () => { _activeEmailReaderForSelectAll = reader; }, true);
}
function _emailReaderLoadErrorHtml(message) {
return `
Could not load email
${_esc(message || 'Failed to load email')}
`;
}
export function _showEmailReaderLoadError(reader, message, onRetry) {
if (!reader) return;
reader.classList.remove('email-card-reader-loading');
reader.classList.add('email-card-reader-error');
reader.style.minHeight = '';
reader.innerHTML = _emailReaderLoadErrorHtml(message);
const retry = reader.querySelector('.email-reader-retry-btn');
retry?.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
onRetry?.();
});
_markEmailReaderActive(reader);
}
export function _openCalendarEventFromEmail(uid) {
const target = String(uid || '').trim();
if (!target) return;
import('../calendar.js?v=20260914emailsource11').then(mod => {
const open = mod.openCalendarTo || (mod.default && mod.default.openCalendarTo);
if (open) open(target);
}).catch(() => {});
}
function _applyTagFilterFromPill(tag) {
const normalized = String(tag || '').trim().toLowerCase().replace(/_/g, '-');
if (!normalized || normalized === 'calendar') return;
const value = `filter:tag:${normalized}`;
const existingIdx = Array.isArray(state._libSearchPills)
? state._libSearchPills.findIndex(p => p?.type === 'filter' && p.value === value)
: -1;
if (existingIdx >= 0) {
_removeSearchPillAt(existingIdx);
return;
}
_addSearchPill({
type: 'filter',
value,
label: normalized.replace(/-/g, ' '),
});
}
document.addEventListener('odysseus:email-filter-tag', (e) => {
_applyTagFilterFromPill(e.detail?.tag);
});
function _emailTagPillHtml(tag, em) {
const normalized = String(tag || '').trim().toLowerCase().replace(/_/g, '-');
if (!normalized) return '';
const eventUid = normalized === 'calendar' && Array.isArray(em?.calendar_event_uids)
? String(em.calendar_event_uids[0] || '').trim()
: '';
if (normalized === 'calendar') {
if (!eventUid) return '';
return ``;
}
return ``;
}
function _emailTagGroupHtml(tags, em) {
const visible = (Array.isArray(tags) ? tags : [])
.map(t => _emailTagPillHtml(t, em))
.filter(Boolean);
if (!visible.length) return '';
// A lone tag is already the most useful compact representation. Never
// replace it with a misleading "+1" overflow control.
if (visible.length === 1) return visible[0];
// Two tags still fit as useful labels. Overflow starts at three so the UI
// never renders a low-value "+1" control.
if (visible.length === 2) return visible.join('');
const extra = visible.slice(1).map(html => ``).join('');
const additionalCount = visible.length - 1;
const mini = ``;
return `${visible[0]}${extra}${mini}`;
}
function _fitEmailCardTags(titleRow) {
const tagWrap = titleRow?.querySelector('.email-card-tags');
const title = titleRow?.querySelector('.memory-item-title');
const status = titleRow?.querySelector('.email-card-status');
if (!tagWrap || !title || !status) return;
tagWrap.classList.remove('email-tags-mini');
tagWrap.style.setProperty('--email-status-width', `${status.offsetWidth}px`);
const minimumTitleWidth = Math.max(96, Math.min(160, titleRow.clientWidth * 0.45));
const availableForTags = Math.max(0, titleRow.clientWidth - status.offsetWidth - minimumTitleWidth - 12);
const hasOverflowControl = !!tagWrap.querySelector('[data-email-tags-more]');
tagWrap.classList.toggle('email-tags-mini', hasOverflowControl && tagWrap.scrollWidth > availableForTags);
}
const _emailTagFitObserver = typeof ResizeObserver === 'function'
? new ResizeObserver(entries => {
for (const entry of entries) _fitEmailCardTags(entry.target);
})
: null;
const _DONE_RESPONSE_TAGS = new Set(['urgent', 'reply-soon', 'action-needed']);
function _visibleEmailTagsForRender(em) {
const tags = Array.isArray(em?.tags) ? em.tags : [];
if (!em?.is_answered) return tags;
return tags.filter(t => !_DONE_RESPONSE_TAGS.has(String(t || '').trim().toLowerCase().replace(/_/g, '-')));
}
export function _clearDoneResponseTagsLocal(em) {
if (!em || !Array.isArray(em.tags)) return;
em.tags = em.tags.filter(t => !_DONE_RESPONSE_TAGS.has(String(t || '').trim().toLowerCase().replace(/_/g, '-')));
}
export function _loadedEmailsHaveVisibleTags() {
return (state._libEmails || []).some(em =>
_visibleEmailTagsForRender(em).length > 0 || !!em?.is_spam_verdict
);
}
export async function _openTasksForEmailTags() {
try {
const mod = await import('../tasks.js?v=20260914taskmodel1');
const openTasks = mod.openTasks || mod.default?.openTasks;
if (typeof openTasks === 'function') {
openTasks(null, { filter: 'Email', focusAction: 'check_email_urgency' });
return;
}
} catch (_) {}
document.getElementById('tool-tasks-btn')?.click();
}
export function _notifyNoLoadedEmailTags() {
const count = (state._libEmails || []).length;
const msg = count
? `No tags found in the ${count} loaded emails.`
: 'No email tags loaded yet.';
showToast(`${msg} Turn on email tagging in Tasks.`, {
duration: 6500,
action: 'Open Tasks',
onAction: _openTasksForEmailTags,
});
}
// Stash the email identity (uid + folder + account) on the reader element
// so chat submits and other code paths can ask "what email is the user
// currently looking at?" without re-deriving from the DOM hierarchy.
export function _stampReaderContext(reader, em, folder, account) {
if (!reader || !em) return;
reader.dataset.emailUid = String(em.uid || '');
reader.dataset.emailFolder = String(folder || state._libFolder || 'INBOX');
reader.dataset.emailAccount = String(account || state._libAccountId || '');
if (em.subject) reader.dataset.emailSubject = String(em.subject);
if (em.from_address || em.from_name) {
reader.dataset.emailFrom = String(em.from_address || em.from_name);
}
}
// Returns { uid, folder, account, subject, from } for the email the user
// is most likely referring to — the last reader they interacted with, then
// any open reader-modal as a fallback. Returns null when no email reader
// is open. Exported below for chat.js to read on submit.
function _getActiveEmailContext() {
const candidates = [];
if (_activeEmailReaderForSelectAll && _activeEmailReaderForSelectAll.isConnected) {
candidates.push(_activeEmailReaderForSelectAll);
}
// Visible reader-tab modals (popped-out windows).
document.querySelectorAll('.modal[id^="email-reader-"]:not(.hidden):not(.modal-minimized) .email-card-reader').forEach(el => candidates.push(el));
// Expanded inline reader in the library list.
document.querySelectorAll('#email-lib-modal:not(.hidden) .doclib-card.email-card-expanded .email-card-reader').forEach(el => candidates.push(el));
for (const r of candidates) {
const uid = r?.dataset?.emailUid;
if (uid) {
return {
uid,
folder: r.dataset.emailFolder || 'INBOX',
account: r.dataset.emailAccount || '',
subject: r.dataset.emailSubject || '',
from: r.dataset.emailFrom || '',
};
}
}
return null;
}
// Frontend reads via the global so chat.js doesn't need a separate import
// path (emailLibrary loads lazily in some entry points).
try { window.__odysseusGetActiveEmailContext = _getActiveEmailContext; } catch (_) {}
const _COPY_EMAIL_ICON = '';
function _decodeAttrValue(v) {
const tmp = document.createElement('textarea');
tmp.innerHTML = v || '';
return tmp.value;
}
function _emailAddressFromRecipientText(text) {
const raw = String(text || '').trim();
const angle = raw.match(/<\s*([^<>@\s]+@[^<>\s]+)\s*>/);
if (angle) return angle[1].trim();
const any = raw.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i);
return any ? any[0].trim() : raw;
}
export function _splitRecipientList(raw) {
const out = [];
let cur = '';
let quote = false;
let angle = false;
const s = String(raw || '');
for (let i = 0; i < s.length; i += 1) {
const ch = s[i];
if (ch === '"' && s[i - 1] !== '\\') quote = !quote;
else if (ch === '<' && !quote) angle = true;
else if (ch === '>' && !quote) angle = false;
if (ch === ',' && !quote && !angle) {
const part = cur.trim();
if (part) out.push(part);
cur = '';
continue;
}
cur += ch;
}
const tail = cur.trim();
if (tail) out.push(tail);
return out;
}
async function _copyTextToClipboard(text) {
const value = String(text || '');
if (!value) return false;
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return true;
}
} catch (_) {}
try {
const ta = document.createElement('textarea');
ta.value = value;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.top = '0';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
ta.remove();
return !!ok;
} catch (_) {
return false;
}
}
export function _wireMetaToggle(root) {
const toggle = root && root.querySelector('.email-reader-meta-toggle');
const details = root && root.querySelector('.email-reader-meta-details');
if (!toggle || !details) return;
const meta = details.closest('.email-reader-meta');
toggle.addEventListener('click', (ev) => {
ev.stopPropagation();
const open = details.hasAttribute('hidden');
if (open) details.removeAttribute('hidden');
else details.setAttribute('hidden', '');
toggle.setAttribute('aria-expanded', String(open));
toggle.classList.toggle('open', open);
if (meta) meta.classList.toggle('email-reader-meta-expanded', open);
});
}
export function _recipientMetaToggleHtml(data = {}) {
const ccCount = _splitRecipientList(data.cc || '').length;
if (ccCount) {
return ``;
}
if (!data.to) return '';
return ``;
}
export function _recipientChipHtml(full, label, extraClass = '') {
const fullText = String(full || '').trim();
const addr = _emailAddressFromRecipientText(fullText);
const labelText = String(label || addr || fullText || '').trim();
const cls = `recipient-chip${extraClass ? ` ${extraClass}` : ''}`;
return `${_esc(labelText)}`;
}
let _recipientChipPopoverCtl = null;
function _closeRecipientChipPopover() {
try { _recipientChipPopoverCtl?.abort(); } catch {}
_recipientChipPopoverCtl = null;
document.querySelector('.recipient-chip-popover')?.remove();
document.querySelectorAll('.recipient-chip.popover-open').forEach(chip => {
chip.classList.remove('popover-open');
});
}
function _showRecipientChipPopover(chip) {
if (!chip) return false;
_closeRecipientChipPopover();
const full = _decodeAttrValue(chip.dataset.full || '').trim();
const email = chip.dataset.email || _emailAddressFromRecipientText(full);
const name = chip.dataset.name || chip.querySelector('.recipient-chip-label')?.textContent?.trim() || '';
const detail = full || email || name;
if (!detail) return true;
chip.classList.add('popover-open');
const pop = document.createElement('div');
pop.className = 'recipient-chip-popover';
pop.setAttribute('role', 'dialog');
pop.setAttribute('aria-label', 'Sender details');
pop.innerHTML = `
${name && detail !== name ? `
${_esc(name)}
` : ''}
${_esc(detail)}
${email ? `` : ''}
`;
document.body.appendChild(pop);
const rect = chip.getBoundingClientRect();
const margin = 10;
const maxLeft = Math.max(margin, window.innerWidth - pop.offsetWidth - margin);
let left = Math.min(Math.max(margin, rect.left), maxLeft);
let top = rect.bottom + 6;
if (top + pop.offsetHeight + margin > window.innerHeight) {
top = Math.max(margin, rect.top - pop.offsetHeight - 6);
}
pop.style.left = `${Math.round(left)}px`;
pop.style.top = `${Math.round(top)}px`;
const ctl = new AbortController();
_recipientChipPopoverCtl = ctl;
pop.querySelector('.recipient-chip-popover-copy')?.addEventListener('click', async (ev) => {
ev.preventDefault();
ev.stopPropagation();
try {
const copied = await _copyTextToClipboard(email);
if (!copied) throw new Error('copy failed');
ev.currentTarget.classList.add('copied');
showToast?.('Email copied');
setTimeout(_closeRecipientChipPopover, 650);
} catch (_) {
showToast?.('Copy failed');
}
}, { signal: ctl.signal });
setTimeout(() => {
document.addEventListener('pointerdown', (ev) => {
if (pop.contains(ev.target) || chip.contains(ev.target)) return;
_closeRecipientChipPopover();
}, { signal: ctl.signal, capture: true });
}, 0);
document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') _closeRecipientChipPopover();
}, { signal: ctl.signal });
window.addEventListener('resize', _closeRecipientChipPopover, { signal: ctl.signal });
window.addEventListener('scroll', _closeRecipientChipPopover, { signal: ctl.signal, capture: true });
return true;
}
export function _wireRecipientChips(root) {
if (!root || root.dataset.recipientChipsWired === '1') return;
root.dataset.recipientChipsWired = '1';
root.addEventListener('click', async (ev) => {
const copyBtn = ev.target.closest?.('.recipient-chip-copy');
if (copyBtn && root.contains(copyBtn)) {
ev.stopPropagation();
ev.preventDefault();
const chip = copyBtn.closest('.recipient-chip');
const email = chip?.dataset.email || _emailAddressFromRecipientText(_decodeAttrValue(chip?.dataset.full || ''));
if (!email) return;
try {
const copied = await _copyTextToClipboard(email);
if (!copied) throw new Error('copy failed');
copyBtn.classList.add('copied');
copyBtn.title = 'Copied';
showToast?.('Email copied');
setTimeout(() => {
copyBtn.classList.remove('copied');
copyBtn.title = 'Copy email';
}, 900);
} catch (_) {
showToast?.('Copy failed');
}
return;
}
const chip = ev.target.closest?.('.recipient-chip');
if (!chip || !root.contains(chip)) return;
ev.stopPropagation();
ev.preventDefault();
if (_showRecipientChipPopover(chip)) return;
const label = chip.querySelector('.recipient-chip-label');
const copy = chip.querySelector('.recipient-chip-copy');
if (chip.classList.contains('expanded')) {
chip.classList.remove('expanded');
if (label) label.textContent = chip.dataset.name || label.textContent;
if (copy) copy.hidden = true;
} else {
if (!chip.dataset.name && label) chip.dataset.name = label.textContent.trim();
chip.classList.add('expanded');
const expandedText = _decodeAttrValue(chip.dataset.full || '').trim()
|| chip.dataset.name
|| chip.dataset.email
|| label?.textContent?.trim()
|| '';
if (label && expandedText) label.textContent = expandedText;
if (copy) copy.hidden = false;
}
});
}
function _emailReaderForSelectAllTarget(target) {
if (_isEmailTypingTarget(target)) return null;
const direct = target?.closest?.('.email-card-reader, #email-lib-modal .doclib-card.doclib-card-expanded');
if (direct) return direct.querySelector?.('.email-card-reader') || direct;
const expanded = document.querySelector('#email-lib-modal:not(.hidden) .doclib-card.doclib-card-expanded .email-card-reader');
if (expanded) return expanded;
return _activeEmailReaderForSelectAll;
}
document.addEventListener('keydown', (e) => {
if (!(e.ctrlKey || e.metaKey) || String(e.key || '').toLowerCase() !== 'a') return;
const reader = _emailReaderForSelectAllTarget(e.target);
if (!_selectEmailReaderContents(reader)) return;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation?.();
}, true);
function _emailReadContextKey(context) {
return [context.accountId, context.folder, context.uid].map(value => String(value || '')).join('\u0000');
}
function _emailReadContextIsCurrent(context) {
if (!context) return true;
return (
String(state._libAccountId || '') === context.accountId &&
String(state._libFolder || 'INBOX') === context.libraryFolder &&
_emailMailboxGeneration === context.mailboxGeneration
);
}
function _emailMatchesReadContext(email, context) {
if (String(email?.uid || '') !== context.uid) return false;
const accountId = String(email?.account_id || context.accountId);
const folder = String(email?.folder || context.folder);
return accountId === context.accountId && folder === context.folder;
}
export function _syncEmailReadState(uid, isRead = true, context = null) {
if (uid == null) return;
const uidStr = String(uid);
const read = !!isRead;
if (context && (!_emailReadContextIsCurrent(context) || uidStr !== context.uid)) return;
const match = (state._libEmails || []).find(x => (
context ? _emailMatchesReadContext(x, context) : String(x.uid) === uidStr
));
if (match) match.is_read = read;
document.querySelectorAll('.doclib-card[data-uid="' + CSS.escape(uidStr) + '"]').forEach(card => {
if (context && (
String(card.dataset.emailAccount || '') !== context.accountId ||
String(card.dataset.emailFolder || '') !== context.folder
)) return;
card.classList.toggle('email-card-unread', !read);
const titleRow = card.querySelector('.email-card-titlerow');
if (read) {
card.querySelectorAll('.email-card-unread-dot, [data-unread-dot]').forEach(n => n.remove());
if (titleRow) {
titleRow.querySelectorAll('span').forEach(s => {
const st = s.getAttribute('style') || '';
if (/width:\s*6px/.test(st) && /border-radius:\s*50%/.test(st)) s.remove();
});
}
return;
}
if (!titleRow || titleRow.querySelector('.email-card-unread-dot, [data-unread-dot]')) return;
const isSentFolder = /sent/i.test(state._libFolder || '');
if (isSentFolder) return;
const senderName = match ? (match.from_name || match.from_address || '') : '';
const dot = document.createElement('span');
dot.className = 'email-card-unread-dot';
dot.style.cssText = `width:6px;height:6px;border-radius:50%;background:${_senderColor(senderName)};flex-shrink:0;margin-left:2px;`;
const done = titleRow.querySelector('.email-card-done');
const navArrows = titleRow.querySelector('.email-card-nav-arrows');
if (done) done.insertAdjacentElement('afterend', dot);
else if (navArrows) titleRow.insertBefore(dot, navArrows);
else titleRow.appendChild(dot);
});
}
export function _syncEmailDoneState(uid, isDone = true, context = null) {
if (uid == null) return;
const uidStr = String(uid);
const done = !!isDone;
const match = (state._libEmails || []).find(x => (
context ? _emailMatchesReadContext(x, context) : String(x.uid) === uidStr
));
if (match) {
match.is_answered = done;
match.is_done = done;
}
document.querySelectorAll('.doclib-card[data-uid="' + CSS.escape(uidStr) + '"]').forEach(card => {
if (context && (
String(card.dataset.emailAccount || '') !== context.accountId ||
String(card.dataset.emailFolder || '') !== context.folder
)) return;
card.classList.toggle('email-card-answered', done);
const check = card.querySelector('.email-card-done');
if (check) {
check.classList.toggle('active', done);
check.title = done ? 'Mark not done' : 'Mark done';
}
if (done) {
card.querySelectorAll('.email-tag-urgent, .email-tag-reply-soon, .email-tag-action-needed').forEach(n => n.remove());
}
});
}
// When a reply is sent (from the doc editor), the source email is marked
// \Answered server-side and an `email-answered` event fires. Reflect that live
// so the email shows as done without waiting for a manual refresh.
window.addEventListener('email-answered', (e) => {
const uid = e.detail && e.detail.uid;
if (uid == null) return;
const em = (state._libEmails || []).find(x => String(x.uid) === String(uid));
if (em) {
em.is_answered = true;
em.is_done = true;
em.is_read = true;
_clearDoneResponseTagsLocal(em);
}
_syncEmailDoneState(uid, true);
_syncEmailReadState(uid, true);
document.querySelectorAll('.doclib-card[data-uid="' + CSS.escape(String(uid)) + '"]').forEach(card => {
card.classList.add('email-card-answered');
card.classList.remove('email-card-unread');
card.querySelectorAll('.email-tag-urgent, .email-tag-reply-soon, .email-tag-action-needed').forEach(n => n.remove());
const check = card.querySelector('.email-card-done');
if (check) check.classList.add('active');
});
});
function _toggleUnreadEmails() {
if (state._libFolder === '__scheduled__') state._libFolder = 'INBOX';
state._libFilter = state._libFilter === 'unread' ? 'all' : 'unread';
_syncUnreadWindowGlow();
const folderEl = document.getElementById('email-lib-folder');
const filterEl = document.getElementById('email-lib-filter');
if (folderEl) folderEl.value = state._libFolder || 'INBOX';
if (filterEl) filterEl.value = state._libFilter;
_syncSearchOptionsMenu();
_renderSearchPills();
_loadEmailsFresh();
}
function _syncUnreadTabBadge(count) {
const label = count > 999 ? '999+ unread' : `${count} unread`;
document.querySelectorAll('.minimized-dock-chip[data-modal-id="email-lib-modal"]').forEach(chip => {
if (count > 0) {
chip.dataset.emailUnreadLabel = label;
chip.title = `Open ${label}`;
} else {
delete chip.dataset.emailUnreadLabel;
chip.title = 'Restore Email';
}
});
}
function _syncCurrentAccountUnreadCount(count) {
const accountId = String(state._libAccountId || '');
if (!accountId) return;
const nextCount = Math.max(0, Number(count) || 0);
const prev = _accountUnreadState.get(accountId) || {};
_accountUnreadState.set(accountId, {
...prev,
unreadCount: nextCount,
});
_renderAccountsStrip();
}
function _syncUnreadWindowGlow() {
document.getElementById('email-lib-modal')?.classList.toggle('email-lib-unread-active', state._libFilter === 'unread');
}
function _syncReminderClearButton() {
document.getElementById('email-reminders-clear-btn')?.classList.toggle('hidden', state._libFilter !== 'reminders');
}
function _syncSearchOptionsMenu() {
const btn = document.getElementById('email-search-options-btn');
const menu = document.getElementById('email-search-options-menu');
const hasDate = !!(state._libDateFrom || state._libDateTo);
const hasActive = state._libFilter === 'undone' || state._libFilter === 'reminders' || !!state._libHasAttachments || hasDate;
btn?.classList.toggle('active', hasActive);
menu?.querySelector('[data-email-search-option="undone"]')?.classList.toggle('active', state._libFilter === 'undone');
menu?.querySelector('[data-email-search-option="reminders"]')?.classList.toggle('active', state._libFilter === 'reminders');
menu?.querySelector('[data-email-search-option="attachments"]')?.classList.toggle('active', !!state._libHasAttachments);
menu?.querySelector('[data-email-search-option="date"]')?.classList.toggle('active', hasDate);
}
function _setEmailListFilter(value) {
const filterEl = document.getElementById('email-lib-filter');
state._libFilter = state._libFilter === value ? 'all' : value;
if (filterEl) filterEl.value = state._libFilter;
_syncUnreadWindowGlow();
_syncReminderClearButton();
_renderFilterPickerCurrent();
_syncSearchOptionsMenu();
_renderSearchPills();
_loadEmailsFresh();
}
function _toggleEmailAttachmentFilter() {
state._libHasAttachments = !state._libHasAttachments;
_syncReminderClearButton();
_syncSearchOptionsMenu();
_renderSearchPills();
_loadEmailsFresh();
}
function _renderAccountsLoading() {
const strip = document.getElementById('email-lib-accounts');
if (!strip) return;
strip.style.display = 'flex';
strip.innerHTML = '';
try {
const wp = spinnerModule.createWhirlpool(14);
wp.element.classList.add('email-accounts-loading-whirlpool');
strip.appendChild(wp.element);
} catch (_) {}
}
function _syncEmailReminderBellVisibility(enabled) {
const item = document.querySelector('#email-search-options-menu [data-email-search-option="reminders"]');
item?.classList.toggle('hidden', !enabled);
_syncSearchOptionsMenu();
}
async function _loadEmailReminderBellVisibility() {
try {
const settings = await getSettings();
_syncEmailReminderBellVisibility(settings.reminder_channel === 'email');
} catch (_) {
_syncEmailReminderBellVisibility(false);
}
}
// Live-update the bell when the reminder channel changes in Settings,
// so the user doesn't have to reopen Email to see the change apply.
window.addEventListener('odysseus-reminder-channel-changed', (e) => {
const ch = e?.detail?.channel;
_syncEmailReminderBellVisibility(ch === 'email');
});
function _readCssPx(name) {
const v = getComputedStyle(document.documentElement).getPropertyValue(name);
const n = parseFloat(v);
return Number.isFinite(n) ? n : 0;
}
function _emailSplitLeftEdge() {
return _readCssPx('--icon-rail-w') + _readCssPx('--sidebar-w');
}
function _setEmailDocumentSplit(leftEdge, emailWidth) {
if (window.innerWidth <= 768) return;
// Zero gap so the doc-pane sits flush against the email's right edge.
// modalSnap.js's left-dock path publishes the same vars with 0 gap — both
// systems agree on flush so handoffs between them don't cause the doc to
// "jump" sideways. The 1px modal border on each side is the visual seam.
const splitGap = 0;
const left = Math.max(0, Math.round(leftEdge || 0));
const width = Math.max(320, Math.round(emailWidth || 420));
const x = left + width + splitGap;
document.body.classList.add('email-doc-split-active');
document.documentElement.style.setProperty('--email-doc-split-left-x', `${left}px`);
document.documentElement.style.setProperty('--email-doc-split-email-w', `${width}px`);
document.documentElement.style.setProperty('--email-doc-split-right-x', `${x}px`);
}
function _measureEmailDocumentSplit(modal) {
if (window.innerWidth <= 768 || !document.body.classList.contains('email-doc-split-active')) return;
const content = modal?.querySelector?.('.modal-content');
const rect = content?.getBoundingClientRect?.();
if (!rect || !rect.width) return;
const splitGap = 0;
document.documentElement.style.setProperty('--email-doc-split-right-x', `${Math.ceil(rect.right + splitGap)}px`);
try {
modal.style.setProperty('z-index', '150', 'important');
if (content) {
content.style.setProperty('position', 'absolute', 'important');
content.style.setProperty('left', '0px', 'important');
content.style.setProperty('right', 'auto', 'important');
content.style.setProperty('width', `${Math.ceil(rect.width)}px`, 'important');
content.style.setProperty('max-width', `${Math.ceil(rect.width)}px`, 'important');
}
const docPane = document.getElementById('doc-editor-pane');
if (docPane) {
docPane.style.setProperty('position', 'fixed', 'important');
docPane.style.setProperty('left', `${Math.ceil(rect.right + splitGap)}px`, 'important');
docPane.style.setProperty('right', '0px', 'important');
docPane.style.setProperty('top', '0px', 'important');
docPane.style.setProperty('bottom', '0px', 'important');
docPane.style.setProperty('width', 'auto', 'important');
docPane.style.setProperty('max-width', 'none', 'important');
docPane.style.setProperty('height', '100vh', 'important');
docPane.style.setProperty('z-index', '260', 'important');
}
} catch (_) {}
}
function _scheduleEmailDocumentSplitMeasure(modal) {
requestAnimationFrame(() => {
_measureEmailDocumentSplit(modal);
requestAnimationFrame(() => _measureEmailDocumentSplit(modal));
});
setTimeout(() => _measureEmailDocumentSplit(modal), 260);
setTimeout(() => _measureEmailDocumentSplit(modal), 700);
}
function _clearEmailDocumentSplit() {
document.body.classList.remove('email-doc-split-active');
document.documentElement.style.removeProperty('--email-doc-split-left-x');
document.documentElement.style.removeProperty('--email-doc-split-email-w');
document.documentElement.style.removeProperty('--email-doc-split-right-x');
const docPane = document.getElementById('doc-editor-pane');
if (!docPane) return;
[
'position', 'left', 'right', 'top', 'bottom', 'width', 'max-width',
'height', 'z-index', 'transform',
].forEach(prop => docPane.style.removeProperty(prop));
}
// Compute the left-edge x assuming the wide sidebar has collapsed to the
// rail. Used by the "try collapsing the sidebar first" path so we can decide
// whether collapsing recovers enough room before minimizing email.
function _emailSplitLeftEdgeIfSidebarCollapsed() {
return _readCssPx('--icon-rail-w');
}
function _hasDesktopRoomForEmailAndDocument(modal, opts = {}) {
if (window.innerWidth <= 768) return false;
if (window.innerWidth >= 1100) return true;
const content = modal?.querySelector?.('.modal-content');
const rect = content?.getBoundingClientRect?.();
const isFullscreen = modal?.classList?.contains('email-lib-fullscreen')
|| modal?.classList?.contains('email-window-fullscreen');
const emailWidth = isFullscreen
? Math.min(440, Math.max(360, Math.round(window.innerWidth * 0.30)))
: Math.max(360, Math.round(rect?.width || 440));
// Relaxed thresholds — the old 560 + 72 forced an unnecessary tab-down
// on ~1200–1300px viewports where there was visually plenty of room.
const docMinWidth = 460;
const breathingRoom = 40;
const leftEdgeNow = isFullscreen ? _emailSplitLeftEdge() : Math.max(0, Math.round(rect?.left || _emailSplitLeftEdge()));
const leftEdge = opts.assumeSidebarCollapsed ? _emailSplitLeftEdgeIfSidebarCollapsed() : leftEdgeNow;
return (window.innerWidth - leftEdge - emailWidth) >= (docMinWidth + breathingRoom);
}
export function _prepareEmailWindowForDocument(modal) {
if (window.innerWidth <= 768) return true;
if (!modal) return false;
// Try to make breathing room by collapsing the wide sidebar to the rail
// when there isn't enough horizontal space for both panes. The
// route-collapse marker that collapseSidebarToRail() sets means the
// sidebar will auto-restore when the doc closes. Crucially, we no
// longer fall back to clearing the split when even that isn't enough —
// the user opted out of auto-tab-down, so we proceed with the dock
// even if it's cramped.
if (!_hasDesktopRoomForEmailAndDocument(modal)) {
const sidebar = document.getElementById('sidebar');
const sidebarWasOpen = sidebar && !sidebar.classList.contains('hidden');
if (sidebarWasOpen && _hasDesktopRoomForEmailAndDocument(modal, { assumeSidebarCollapsed: true })) {
try { collapseSidebarToRail(); } catch (_) {}
}
}
if (modal.classList.contains('modal-left-docked')) {
const content = modal.querySelector('.modal-content');
const rect = content?.getBoundingClientRect?.();
if (content?._leftDockNavObs) {
try { content._leftDockNavObs.navObs.disconnect(); } catch (_) {}
try { content._leftDockNavObs.bodyObs && content._leftDockNavObs.bodyObs.disconnect(); } catch (_) {}
try { content._leftDockNavObs.disconnectDocObs && content._leftDockNavObs.disconnectDocObs(); } catch (_) {}
try { window.removeEventListener('resize', content._leftDockNavObs.reanchor); } catch (_) {}
delete content._leftDockNavObs;
}
modal.classList.remove('modal-left-docked');
modal.classList.add('email-snap-left');
document.body.classList.remove('left-dock-active');
document.documentElement.style.removeProperty('--left-dock-w');
if (content) {
delete content._dockSide;
content.style.position = 'fixed';
content.style.left = Math.round(rect?.left || _emailSplitLeftEdge()) + 'px';
content.style.top = '0';
content.style.right = 'auto';
content.style.bottom = '0';
content.style.width = Math.round(rect?.width || 440) + 'px';
content.style.maxWidth = Math.round(rect?.width || 440) + 'px';
content.style.height = '100vh';
content.style.maxHeight = '100vh';
content.style.borderRadius = '0';
content.style.transform = 'none';
content.style.margin = '0';
}
}
if (modal.classList.contains('email-snap-left') || modal.classList.contains('modal-left-docked')) {
const rect = modal.querySelector('.modal-content')?.getBoundingClientRect?.();
_setEmailDocumentSplit(rect?.left || _emailSplitLeftEdge(), rect?.width || 420);
_scheduleEmailDocumentSplitMeasure(modal);
return false;
}
// If Email is fullscreen and there is room, park it left instead of
// minimizing so the document/compose pane can open beside it.
_snapEmailModalToLeftSidebar(modal);
return false;
}
function _wireUnreadTabClick() {
if (_emailUnreadChipClickWired) return;
_emailUnreadChipClickWired = true;
document.addEventListener('click', (e) => {
const chip = e.target?.closest?.('.minimized-dock-chip[data-modal-id="email-lib-modal"][data-email-unread-label]');
if (!chip || e.target?.classList?.contains('minimized-dock-x')) return;
setTimeout(_toggleUnreadEmails, 0);
});
}
async function _deleteEmailAndAdvance(em, card, opts = {}) {
if (!em || em.uid == null) return;
if (opts.confirm !== false) {
const subject = em.subject || '(no subject)';
const ok = await styledConfirm(`Delete "${subject}"?`, { confirmText: 'Delete', cancelText: 'Cancel', danger: true });
if (!ok) return;
}
const busy = _showEmailDeleteOverlay(card);
await busy?.ready;
const wasExpanded = !!card?.classList?.contains('doclib-card-expanded');
const sibling = wasExpanded
? (_findSiblingEmailCard(card, +1) || _findSiblingEmailCard(card, -1))
: null;
const nextUid = sibling ? sibling.dataset.uid : null;
try {
const response = await fetch(`${API_BASE}/api/email/delete/${encodeURIComponent(em.uid)}?${_emailMutationQuery(em)}`, { method: 'DELETE' });
await _requireSuccessfulEmailMutation(response, 'Failed to delete email');
} catch (err) {
console.error('Failed to delete email:', err);
busy?.remove?.();
showToast('Failed to delete email');
return;
}
busy?.remove?.();
await _animateEmailCardRemoval([em.uid]);
state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid));
state._selectedUids.delete(em.uid);
_updateBulkBar();
_renderGrid();
_libCacheWriteBack();
showToast('Moved to Trash');
if (!wasExpanded || !nextUid) return;
const grid = document.getElementById('email-lib-grid');
const nextCard = grid?.querySelector(`.doclib-card[data-uid="${CSS.escape(String(nextUid))}"]`);
const nextEm = state._libEmails.find(e => String(e.uid) === String(nextUid));
if (nextCard && nextEm) {
await _toggleCardPreview(nextCard, nextEm);
nextCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} else {
document.getElementById('email-lib-modal')?.classList.remove('email-reading');
}
}
export function _showEmailDeleteOverlay(target) {
if (!target) return null;
const wp = spinnerModule.createWhirlpool(18);
const overlay = document.createElement('div');
overlay.className = 'email-delete-overlay';
overlay.appendChild(wp.element);
const prevPos = target.style.position;
const prevPointerEvents = target.style.pointerEvents;
if (getComputedStyle(target).position === 'static') target.style.position = 'relative';
target.style.pointerEvents = 'none';
target.classList.add('email-delete-busy');
target.appendChild(overlay);
const ready = new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
return {
ready,
remove() {
try { wp.destroy?.(); } catch (_) {}
overlay.remove();
target.classList.remove('email-delete-busy');
target.style.pointerEvents = prevPointerEvents;
target.style.position = prevPos;
}
};
}
export function _animateEmailCardRemoval(uids, opts = {}) {
const uidSet = new Set((uids || []).map(uid => String(uid)));
if (!uidSet.size) return Promise.resolve();
const grid = document.getElementById('email-lib-grid');
if (!grid) return Promise.resolve();
const cards = Array.from(grid.querySelectorAll('.doclib-card[data-uid]'))
.filter(card => uidSet.has(String(card.dataset.uid)));
if (!cards.length) return Promise.resolve();
const duration = Number(opts.duration || 230);
for (const card of cards) {
const rect = card.getBoundingClientRect();
card.style.setProperty('--email-remove-h', `${Math.max(rect.height, card.scrollHeight)}px`);
card.style.maxHeight = 'var(--email-remove-h)';
card.style.overflow = 'hidden';
card.classList.add('email-card-removing');
}
return new Promise(resolve => {
window.setTimeout(resolve, duration + 35);
});
}
function _selectedEmailExportPayload() {
const selected = new Set(Array.from(state._selectedUids || []).map(uid => String(uid)));
return (state._libEmails || [])
.filter(em => selected.has(String(em.uid)))
.map(em => ({
uid: String(em.uid || ''),
folder: String(em.folder || state._libFolder || 'INBOX'),
account_id: String(em.account_id || state._libAccountId || ''),
subject: String(em.subject || ''),
date: (() => {
const parsed = em.date_epoch ? new Date(Number(em.date_epoch) * 1000) : new Date(em.date || '');
return Number.isFinite(parsed.getTime()) ? parsed.toISOString().slice(0, 10) : '';
})(),
}))
.filter(em => em.uid);
}
function _downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || 'selected-email-attachments.zip';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1500);
}
function _filenameFromContentDisposition(header) {
const value = String(header || '');
const utf = value.match(/filename\*=UTF-8''([^;]+)/i);
if (utf) {
try { return decodeURIComponent(utf[1]); } catch (_) {}
}
const quoted = value.match(/filename="([^"]+)"/i);
if (quoted) return quoted[1];
const bare = value.match(/filename=([^;]+)/i);
return bare ? bare[1].trim() : '';
}
export async function _exportSelectedAttachments() {
const messages = _selectedEmailExportPayload();
if (!messages.length) {
showToast('Select emails first');
return;
}
const actionsBtn = document.getElementById('email-lib-bulk-actions');
const deleteBtn = document.getElementById('email-lib-bulk-delete');
const cancelBtn = document.getElementById('email-lib-bulk-cancel');
const selectAll = document.getElementById('email-lib-select-all');
const countEl = document.getElementById('email-lib-selected-count');
const originalActionsHtml = actionsBtn?.innerHTML || '';
const originalCountText = countEl?.textContent || '';
let busySpinner = null;
try {
if (actionsBtn) {
actionsBtn.disabled = true;
actionsBtn.classList.add('email-bulk-loading');
actionsBtn.innerHTML = 'Exporting';
busySpinner = spinnerModule.create('', 'clean', 'whirlpool');
const spEl = busySpinner.createElement();
spEl.classList.add('email-bulk-whirlpool');
actionsBtn.appendChild(spEl);
busySpinner.start();
}
if (deleteBtn) deleteBtn.disabled = true;
if (cancelBtn) cancelBtn.disabled = true;
if (selectAll) selectAll.disabled = true;
if (countEl) countEl.textContent = `Exporting attachments from ${messages.length}…`;
const res = await fetch(`${API_BASE}/api/email/attachments-download-bulk`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages,
category: String(state._libFilter || '').replace(/^tag:/, ''),
}),
});
if (!res.ok) {
let detail = '';
try {
const data = await res.json();
detail = data?.detail || data?.error || '';
} catch (_) {}
throw new Error(detail || `HTTP ${res.status}`);
}
const blob = await res.blob();
const filename = _filenameFromContentDisposition(res.headers.get('Content-Disposition')) || 'selected-email-attachments.zip';
_downloadBlob(blob, filename);
showToast(`Exported attachments from ${messages.length} selected email${messages.length === 1 ? '' : 's'}`);
} catch (err) {
console.error('Failed to export selected email attachments:', err);
showToast(err?.message || 'Failed to export attachments');
} finally {
if (busySpinner) busySpinner.destroy();
if (actionsBtn) {
actionsBtn.disabled = false;
actionsBtn.classList.remove('email-bulk-loading');
actionsBtn.innerHTML = originalActionsHtml || actionsBtn.innerHTML;
}
if (deleteBtn) deleteBtn.disabled = false;
if (cancelBtn) cancelBtn.disabled = false;
if (selectAll) selectAll.disabled = false;
if (countEl) countEl.textContent = originalCountText;
_updateBulkBar();
}
}
// URL-suffix helper — appends &account_id=... when an account is actively selected.
// Every email route call in this file goes through here so switching accounts
// is a single-variable flip.
// Open the Settings modal and activate a specific tab. Used by empty-state
// "Set up at: Settings › X" links across email/calendar/etc.
function _openSettingsTab(tab) {
if (tab === 'integrations' && window.adminModule && typeof window.adminModule.open === 'function') {
window.adminModule.open('integrations');
return;
}
if (settingsModule && typeof settingsModule.open === 'function') {
settingsModule.open(tab || 'services');
return;
}
const modal = document.getElementById('settings-modal');
if (!modal) return;
modal.classList.remove('hidden');
const tabBtn = modal.querySelector(`[data-settings-tab="${tab || 'services'}"]`);
if (tabBtn) tabBtn.click();
}
function _openGlobalEmailSettings(focus) {
const host = document.getElementById('settings-email-preferences');
if (host && focus) host.dataset.emailSettingsFocus = focus;
_openSettingsTab('email');
setTimeout(() => {
const section = focus === 'show-tags'
? document.querySelector('#settings-email-preferences .email-settings-display-section')
: document.querySelector(`#settings-email-preferences .email-settings-${focus || 'style'}-section`);
if (section) {
section.scrollIntoView?.({ block: 'nearest' });
}
}, 0);
}
function _emailSetupHintHtml() {
return '';
}
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');
});
});
}
export function _acct() {
return state._libAccountId ? `&account_id=${encodeURIComponent(state._libAccountId)}` : '';
}
export function _emailMutationQuery(em, fallbackFolder = state._libFolder) {
const folder = String(em?.folder || fallbackFolder || 'INBOX');
const accountId = em?.account_id || state._libAccountId || '';
const params = new URLSearchParams({ folder });
if (accountId) params.set('account_id', String(accountId));
if (em?.message_id) params.set('message_id', String(em.message_id));
return params.toString();
}
export async function _requireSuccessfulEmailMutation(response, fallback = 'Email operation failed') {
const data = await response.json().catch(() => null);
if (!response.ok || data?.success !== true) {
throw new Error(data?.error || `${fallback} (${response.status})`);
}
return data;
}
function _rememberedEmailAccountId() {
try {
return String(localStorage.getItem(_LIB_LAST_ACCOUNT_KEY) || '').trim();
} catch (_) {
return '';
}
}
// Per-(account, folder, filter, attachments) cache of the most recent
// first-page list response. Lets open-after-refresh paint the previous
// list instantly while the network refresh runs behind it. Search results
// and __scheduled__ are deliberately not cached.
const _libListCache = new Map();
const _LIB_CACHE_MAX = 24;
const _LIB_INITIAL_PAGE_SIZE = 100;
const _LIB_SESSION_CACHE_PREFIX = 'odysseus.email.list.';
const _LIB_SESSION_CACHE_TTL_MS = 10 * 60 * 1000;
const _LIB_PERSIST_CACHE_PREFIX = 'odysseus.email.list.v2.';
const _LIB_PERSIST_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
const _LIB_PERSIST_CACHE_MAX = 12;
const _LIB_LAST_ACCOUNT_KEY = 'odysseus.email.lastAccountId';
const _LIB_PREWARM_COOLDOWN_MS = 5 * 60 * 1000;
let _libPrewarmDelayTimer = null;
let _libPrewarmIdleHandle = null;
let _libPrewarmPromise = null;
let _libPrewarmResolve = null;
let _libPrewarmAbortController = null;
let _libPrewarmDetachPriorityListeners = null;
let _libPrewarmGeneration = 0;
let _libLastPrewarmAt = 0;
let _libUnreadPrewarmKey = '';
let _libUnreadPrewarmAt = 0;
let _libRenderedViewKey = '';
let _libSyncStatus = {
updatedAt: '',
source: '',
warming: false,
loading: false,
};
let _libSyncTicker = null;
function _libSyncDateFrom(value) {
if (!value) return null;
const d = new Date(value);
return Number.isFinite(d.getTime()) ? d : null;
}
function _libRelativeTime(value) {
const d = _libSyncDateFrom(value);
if (!d) return '';
const seconds = Math.max(0, Math.floor((Date.now() - d.getTime()) / 1000));
if (seconds < 45) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function _renderEmailSyncStatus() {
const el = document.getElementById('email-lib-sync-status');
if (!el) return;
const parts = [];
const rel = _libRelativeTime(_libSyncStatus.updatedAt);
if (rel) parts.push(`Last: ${rel}`);
if (_libSyncStatus.loading) {
if (state._libEmails.length) parts.push('Updating...');
el.textContent = parts.join(' · ');
el.style.visibility = parts.length ? 'visible' : 'hidden';
return;
}
el.textContent = parts.join(' · ');
el.style.visibility = parts.length ? 'visible' : 'hidden';
}
function _setEmailSyncStatus(next = {}) {
if (Object.prototype.hasOwnProperty.call(next, 'updatedAt')) {
const updatedAt = next.updatedAt || '';
if (!updatedAt) {
_libSyncStatus.updatedAt = _libSyncStatus.updatedAt || '';
} else if (_libSyncDateFrom(updatedAt)) {
_libSyncStatus.updatedAt = updatedAt;
}
}
if (Object.prototype.hasOwnProperty.call(next, 'source')) {
_libSyncStatus.source = next.source || '';
}
if (Object.prototype.hasOwnProperty.call(next, 'warming')) {
_libSyncStatus.warming = Boolean(next.warming);
}
if (Object.prototype.hasOwnProperty.call(next, 'loading')) {
_libSyncStatus.loading = Boolean(next.loading);
}
_renderEmailSyncStatus();
}
function _libCacheKeyFor(accountId, folder, filter, hasAttachments) {
return [
accountId || '',
folder || '',
filter || '',
hasAttachments ? 1 : 0,
state._libDateFrom || '',
state._libDateTo || '',
].join('|');
}
function _libCacheKey() {
return _libCacheKeyFor(
state._libAccountId || '',
state._libFolder || '',
state._libFilter || '',
state._libHasAttachments
);
}
function _libSessionCacheKey(key) {
return _LIB_SESSION_CACHE_PREFIX + encodeURIComponent(String(key || ''));
}
function _libPersistCacheKey(key) {
return _LIB_PERSIST_CACHE_PREFIX + encodeURIComponent(String(key || ''));
}
function _libSessionCacheGet(key) {
try {
const raw = sessionStorage.getItem(_libSessionCacheKey(key));
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!parsed || !parsed.savedAt || (Date.now() - parsed.savedAt) > _LIB_SESSION_CACHE_TTL_MS) {
sessionStorage.removeItem(_libSessionCacheKey(key));
return null;
}
return parsed.value || null;
} catch (_) {
return null;
}
}
function _libPersistCacheGet(key) {
try {
const storageKey = _libPersistCacheKey(key);
const raw = localStorage.getItem(storageKey);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!parsed || !parsed.savedAt || (Date.now() - parsed.savedAt) > _LIB_PERSIST_CACHE_TTL_MS) {
localStorage.removeItem(storageKey);
return null;
}
return parsed.value || null;
} catch (_) {
return null;
}
}
function _libSessionCachePut(key, value) {
try {
sessionStorage.setItem(_libSessionCacheKey(key), JSON.stringify({ savedAt: Date.now(), value }));
} catch (_) {}
}
function _libPrunePersistCache() {
try {
const entries = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key || !key.startsWith(_LIB_PERSIST_CACHE_PREFIX)) continue;
let savedAt = 0;
try {
savedAt = Number(JSON.parse(localStorage.getItem(key) || '{}')?.savedAt || 0);
} catch (_) {}
entries.push({ key, savedAt });
}
entries.sort((a, b) => b.savedAt - a.savedAt);
for (const stale of entries.slice(_LIB_PERSIST_CACHE_MAX)) {
localStorage.removeItem(stale.key);
}
} catch (_) {}
}
function _libPersistCachePut(key, value) {
try {
localStorage.setItem(_libPersistCacheKey(key), JSON.stringify({ savedAt: Date.now(), value }));
_libPrunePersistCache();
} catch (_) {
try { localStorage.removeItem(_libPersistCacheKey(key)); } catch {}
}
}
function _looksLikeFixtureEmailRow(row) {
if (!row || typeof row !== 'object') return false;
const account = String(row.account || row._account || row.account_id || row._account_id || '').toLowerCase();
const from = `${row.from || ''} ${row.from_name || ''} ${row.from_address || ''}`.toLowerCase();
const subject = String(row.subject || '').toLowerCase();
const messageId = String(row.message_id || '').toLowerCase();
return account.includes('fixture')
|| from.includes('fixture@example')
|| from.includes('older fixture')
|| subject === 'older inbox message'
|| messageId.includes('fixture-email-');
}
function _libCacheHasFixtureRows(value) {
return Boolean(value && Array.isArray(value.emails) && value.emails.some(_looksLikeFixtureEmailRow));
}
function _libDropCacheKey(key) {
_libListCache.delete(key);
try { sessionStorage.removeItem(_libSessionCacheKey(key)); } catch (_) {}
try { localStorage.removeItem(_libPersistCacheKey(key)); } catch (_) {}
}
function _libCacheGet(key) {
const memory = _libListCache.get(key);
if (memory) {
if (_libCacheHasFixtureRows(memory)) {
_libDropCacheKey(key);
return null;
}
return memory;
}
const stored = _libSessionCacheGet(key) || _libPersistCacheGet(key);
if (stored) {
if (_libCacheHasFixtureRows(stored)) {
_libDropCacheKey(key);
return null;
}
_libListCache.set(key, stored);
return stored;
}
return null;
}
function _libCachePut(key, value) {
if (_libCacheHasFixtureRows(value)) {
_libDropCacheKey(key);
return;
}
// Re-insert to bump LRU recency.
_libListCache.delete(key);
_libListCache.set(key, value);
_libSessionCachePut(key, value);
_libPersistCachePut(key, value);
if (_libListCache.size > _LIB_CACHE_MAX) {
const oldest = _libListCache.keys().next().value;
_libListCache.delete(oldest);
}
}
function _resetBulkSelectionForContextChange({ rerender = false } = {}) {
const hadSelection = !!(state._selectedUids && state._selectedUids.size);
const wasSelectMode = !!state._selectMode;
if (state._selectedUids) state._selectedUids.clear();
state._selectMode = false;
if (hadSelection || wasSelectMode) {
_updateBulkBar();
if (rerender) _renderGrid();
}
}
export function _resetEmailListForFreshLoad({ useCache = true, refreshAfterCache = false } = {}) {
_exitEmailReaderModeForList();
_resetBulkSelectionForContextChange();
state._libOffset = 0;
_emailMailboxGeneration += 1;
_libLoadSeq += 1;
const ck = _libCacheKey();
const cached = useCache ? _libCacheGet(ck) : null;
if (cached && Array.isArray(cached.emails) && cached.emails.length) {
state._libEmails = cached.emails.slice();
state._libTotal = cached.total || state._libEmails.length;
state._libJustOpened = false;
_renderGrid();
const stats = document.getElementById('email-lib-stats');
if (stats) stats.textContent = `${state._libTotal} emails`;
_setEmailSyncStatus({ updatedAt: cached.sync?.updated_at || '', source: cached.sync?.source || 'client_cache', loading: refreshAfterCache });
_libRenderedViewKey = ck;
return;
}
if (state._libEmails.length && _libRenderedViewKey === ck) {
_renderGrid();
_setEmailSyncStatus({ loading: false });
return;
}
state._libEmails = [];
state._libTotal = 0;
_libRenderedViewKey = ck;
const grid = document.getElementById('email-lib-grid');
if (grid) _renderEmailLoading(grid);
const stats = document.getElementById('email-lib-stats');
if (stats) stats.textContent = 'Loading...';
_setEmailSyncStatus({ loading: true });
}
function _exitEmailReaderModeForList() {
const modal = document.getElementById('email-lib-modal');
modal?.classList.remove('email-reading');
modal?.style.removeProperty('--email-reading-modal-min-h');
const grid = document.getElementById('email-lib-grid');
grid?.querySelectorAll('.email-card-expanded, .doclib-card-expanded').forEach(card => {
unbindExpandedCardDismiss(card);
card.classList.remove('email-card-expanded');
card.classList.remove('doclib-card-expanded');
card.style.minHeight = '';
card.querySelector('.email-card-reader')?.remove();
});
}
export function _loadEmailsFresh({ force = true, useCache = true, refreshAfterCache = true, showRefreshSpinner = false } = {}) {
const refreshBtn = showRefreshSpinner ? document.getElementById('email-lib-refresh-btn') : null;
refreshBtn?.classList.add('email-lib-refreshing');
const resolvedFolder = _resolveEmailFolderAlias(state._libFolder);
if (resolvedFolder && resolvedFolder !== state._libFolder) {
state._libFolder = resolvedFolder;
const folderSel = document.getElementById('email-lib-folder');
if (folderSel) folderSel.value = resolvedFolder;
_renderFolderPicker();
}
_resetEmailListForFreshLoad({ useCache, refreshAfterCache });
return _loadEmails({ force, useCache }).finally(() => {
refreshBtn?.classList.remove('email-lib-refreshing');
});
}
async function _refreshEmailLibraryFromUi(btn = null) {
btn?.classList.add('email-lib-refreshing');
state._libOffset = 0;
// Don't wipe state._libEmails — _loadEmails will paint the current
// 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);
}
}
}
// Refresh the currently visible mailbox after another surface sends a message.
// Keep the user's folder, filters, and scroll context intact.
export async function refreshEmailLibrary() {
if (!state._libOpen) return false;
await _loadEmailsFresh({
force: true,
useCache: false,
refreshAfterCache: false,
showRefreshSpinner: true,
});
return true;
}
function _initMobileEmailPullRefresh() {
const grid = document.getElementById('email-lib-grid');
const modal = document.getElementById('email-lib-modal');
const host = modal?.querySelector('.admin-card');
if (!grid || !host || grid.dataset.pullRefreshBound === '1') return;
if (!('ontouchstart' in window || navigator.maxTouchPoints > 0)) return;
grid.dataset.pullRefreshBound = '1';
const THRESHOLD = 72;
const MAX_PULL = 104;
let startY = 0;
let pullY = 0;
let tracking = false;
let refreshing = false;
const indicator = document.createElement('div');
indicator.className = 'chat-pull-refresh email-pull-refresh';
indicator.setAttribute('aria-hidden', 'true');
indicator.innerHTML = '';
host.prepend(indicator);
const spinnerMount = indicator.querySelector('.chat-pull-refresh-spinner');
try {
const spinner = spinnerModule.createWhirlpool(18);
spinnerMount.replaceChildren(spinner.element);
} catch (_) {}
function setPull(px, active = false) {
pullY = Math.max(0, Math.min(MAX_PULL, px));
const pct = Math.min(1, pullY / THRESHOLD);
indicator.style.setProperty('--pull-refresh-y', `${pullY}px`);
indicator.style.setProperty('--pull-refresh-progress', `${pct}`);
indicator.classList.toggle('is-visible', active || refreshing || pullY > 2);
indicator.classList.toggle('is-ready', pct >= 1 && !refreshing);
indicator.classList.toggle('is-refreshing', refreshing);
}
async function runRefresh() {
if (refreshing) return;
refreshing = true;
setPull(THRESHOLD, true);
try {
await _refreshEmailLibraryFromUi(document.getElementById('email-lib-refresh-btn'));
} catch (err) {
console.warn('email pull refresh failed:', err);
} finally {
refreshing = false;
setPull(0, false);
}
}
grid.addEventListener('touchstart', (e) => {
if (refreshing || window.innerWidth > 768) return;
if (grid.scrollTop > 0) return;
if (e.target?.closest?.('button, input, textarea, select, a, .email-card-reader, .doclib-card-expanded')) return;
tracking = true;
startY = e.touches[0].clientY;
setPull(0, false);
}, { passive: true });
grid.addEventListener('touchmove', (e) => {
if (!tracking || refreshing) return;
const dy = e.touches[0].clientY - startY;
if (dy <= 0) {
setPull(0, false);
return;
}
if (grid.scrollTop <= 0) {
e.preventDefault();
setPull(dy * 0.62, true);
}
}, { passive: false });
grid.addEventListener('touchend', () => {
if (!tracking) return;
tracking = false;
if (pullY >= THRESHOLD) runRefresh();
else setPull(0, false);
}, { passive: true });
grid.addEventListener('touchcancel', () => {
tracking = false;
if (!refreshing) setPull(0, false);
}, { passive: true });
}
function _isChatInteractionBusy() {
try {
if (window.__odysseusChatBusy) return true;
const until = Number(window.__odysseusChatBusyUntil || 0);
return until > Date.now();
} catch (_) {
return false;
}
}
function _canRunEmailPrewarm() {
if (state._libOpen || state._libLoading || _libSearchInFlight) return false;
if (document.visibilityState && document.visibilityState !== 'visible') return false;
return !_isChatInteractionBusy();
}
function _isEmailPrewarmTemporarilyBlocked() {
if (state._libOpen || state._libLoading || _libSearchInFlight) return false;
if (document.visibilityState && document.visibilityState !== 'visible') return false;
return _isChatInteractionBusy();
}
function _isEmailPrewarmCurrent(generation, signal) {
return generation === _libPrewarmGeneration
&& !signal?.aborted
&& _canRunEmailPrewarm();
}
function _settleEmailPrewarm(generation, value = false) {
if (generation !== _libPrewarmGeneration) return;
const resolve = _libPrewarmResolve;
const detachPriorityListeners = _libPrewarmDetachPriorityListeners;
_libPrewarmDelayTimer = null;
_libPrewarmIdleHandle = null;
_libPrewarmPromise = null;
_libPrewarmResolve = null;
_libPrewarmAbortController = null;
_libPrewarmDetachPriorityListeners = null;
detachPriorityListeners?.();
resolve?.(value);
}
function _cancelEmailPrewarm() {
const resolve = _libPrewarmResolve;
const detachPriorityListeners = _libPrewarmDetachPriorityListeners;
_libPrewarmGeneration += 1;
if (_libPrewarmDelayTimer !== null) {
clearTimeout(_libPrewarmDelayTimer);
}
if (_libPrewarmIdleHandle !== null && typeof window.cancelIdleCallback === 'function') {
try { window.cancelIdleCallback(_libPrewarmIdleHandle); } catch (_) {}
}
try { _libPrewarmAbortController?.abort(); } catch (_) {}
_libPrewarmDelayTimer = null;
_libPrewarmIdleHandle = null;
_libPrewarmPromise = null;
_libPrewarmResolve = null;
_libPrewarmAbortController = null;
_libPrewarmDetachPriorityListeners = null;
detachPriorityListeners?.();
resolve?.(false);
}
function _scheduleEmailPrewarm(task, { delay = 0 } = {}) {
if (_libPrewarmPromise) return _libPrewarmPromise;
// Do not disguise a timer as idle work. Browsers without the genuine idle
// callback simply skip this optional optimization and load on demand.
if (typeof window.requestIdleCallback !== 'function') return Promise.resolve(false);
const generation = ++_libPrewarmGeneration;
_libPrewarmPromise = new Promise(resolve => { _libPrewarmResolve = resolve; });
const promise = _libPrewarmPromise;
let attemptPending = false;
let retryRequested = false;
function clearScheduledAttempt() {
if (_libPrewarmDelayTimer !== null) clearTimeout(_libPrewarmDelayTimer);
if (_libPrewarmIdleHandle !== null && typeof window.cancelIdleCallback === 'function') {
try { window.cancelIdleCallback(_libPrewarmIdleHandle); } catch (_) {}
}
_libPrewarmDelayTimer = null;
_libPrewarmIdleHandle = null;
}
function scheduleIdleRetry(delay = 500) {
if (generation !== _libPrewarmGeneration) return;
retryRequested = true;
if (attemptPending || _libPrewarmDelayTimer !== null || _libPrewarmIdleHandle !== null) return;
if (document.visibilityState && document.visibilityState !== 'visible') return;
_libPrewarmDelayTimer = setTimeout(requestIdle, Math.max(50, Number(delay) || 500));
}
function handlePriorityChange() {
if (generation !== _libPrewarmGeneration) return;
if (_canRunEmailPrewarm()) {
scheduleIdleRetry(50);
return;
}
const priorityBlocked = _isChatInteractionBusy()
|| (document.visibilityState && document.visibilityState !== 'visible');
if (!priorityBlocked) return;
retryRequested = true;
clearScheduledAttempt();
const controller = _libPrewarmAbortController;
_libPrewarmAbortController = null;
try { controller?.abort(); } catch (_) {}
// A hidden page waits for visibilitychange. Chat priority also retains the
// timer fallback for busy-until windows whose final transition has no event.
if (!document.visibilityState || document.visibilityState === 'visible') {
scheduleIdleRetry();
}
}
window.addEventListener('odysseus:chat-busy-change', handlePriorityChange);
document.addEventListener('visibilitychange', handlePriorityChange);
_libPrewarmDetachPriorityListeners = () => {
window.removeEventListener('odysseus:chat-busy-change', handlePriorityChange);
document.removeEventListener('visibilitychange', handlePriorityChange);
};
function requestIdle() {
if (generation !== _libPrewarmGeneration) return;
_libPrewarmDelayTimer = null;
try {
_libPrewarmIdleHandle = window.requestIdleCallback((deadline) => {
if (generation !== _libPrewarmGeneration) return;
_libPrewarmIdleHandle = null;
const hasIdleBudget = Boolean(
deadline
&& !deadline.didTimeout
&& typeof deadline.timeRemaining === 'function'
&& deadline.timeRemaining() > 0
);
if (!_canRunEmailPrewarm()) {
if (_isEmailPrewarmTemporarilyBlocked()) {
scheduleIdleRetry();
} else {
_settleEmailPrewarm(generation, false);
}
return;
}
if (!hasIdleBudget) {
scheduleIdleRetry();
return;
}
if (generation !== _libPrewarmGeneration) {
_settleEmailPrewarm(generation, false);
return;
}
const controller = new AbortController();
_libPrewarmAbortController = controller;
attemptPending = true;
retryRequested = false;
Promise.resolve()
.then(() => task({ signal: controller.signal, generation }))
.then(value => {
if (controller !== _libPrewarmAbortController || controller.signal.aborted) return;
_settleEmailPrewarm(generation, Boolean(value));
})
.catch(() => {
if (controller !== _libPrewarmAbortController || controller.signal.aborted) return;
_settleEmailPrewarm(generation, false);
})
.finally(() => {
attemptPending = false;
if (generation !== _libPrewarmGeneration) return;
if (retryRequested) scheduleIdleRetry();
});
});
} catch (_) {
_settleEmailPrewarm(generation, false);
}
}
const wait = Math.max(0, Number(delay) || 0);
if (wait > 0) _libPrewarmDelayTimer = setTimeout(requestIdle, wait);
else requestIdle();
return promise;
}
export function prewarmEmailLibrary({ delay = 2500 } = {}) {
if (_libPrewarmPromise) return _libPrewarmPromise;
const elapsed = Date.now() - _libLastPrewarmAt;
if (elapsed >= 0 && elapsed < _LIB_PREWARM_COOLDOWN_MS) return Promise.resolve(false);
return _scheduleEmailPrewarm(_prewarmEmailViews, { delay });
}
function _chooseEmailPrewarmAccountId(accounts) {
const enabled = Array.isArray(accounts) ? accounts.filter(a => a && a.enabled !== false) : [];
const remembered = _rememberedEmailAccountId();
const current = String(state._libAccountId || '').trim();
const chosen = enabled.find(a => String(a.id || '') === remembered)
|| enabled.find(a => String(a.id || '') === current)
|| enabled.find(a => a.is_default)
|| enabled[0]
|| null;
return String(chosen?.id || '').trim();
}
async function _ensureEmailAccountsForPrewarm({ signal, generation } = {}) {
if (!_isEmailPrewarmCurrent(generation, signal)) return null;
const accountsFresh = _libAccountsLoadedAt && (Date.now() - _libAccountsLoadedAt) < _LIB_ACCOUNTS_TTL_MS;
if (!(Array.isArray(state._libAccounts) && state._libAccounts.length && accountsFresh)) {
try {
const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, {
credentials: 'same-origin',
signal,
});
if (!_isEmailPrewarmCurrent(generation, signal)) return null;
if (accountsRes.ok) {
const accountsData = await accountsRes.json().catch(() => ({}));
if (!_isEmailPrewarmCurrent(generation, signal)) return null;
if (Array.isArray(accountsData.accounts)) {
state._libAccounts = accountsData.accounts;
_libAccountsLoadedAt = Date.now();
}
}
} catch (err) {
if (err?.name === 'AbortError') return null;
}
}
const accountId = _chooseEmailPrewarmAccountId(state._libAccounts);
if (!_isEmailPrewarmCurrent(generation, signal)) return null;
if (!accountId) return null;
return accountId;
}
export function prewarmUnreadEmails({ limit = 8, maxUid = 0 } = {}) {
return _scheduleEmailPrewarm(
context => _prewarmUnreadEmailsNow({ limit, maxUid }, context),
{ delay: 0 }
);
}
async function _prewarmUnreadEmailsNow({ limit = 8, maxUid = 0 } = {}, { signal, generation } = {}) {
if (!_isEmailPrewarmCurrent(generation, signal)) return false;
const accountId = await _ensureEmailAccountsForPrewarm({ signal, generation });
if (accountId === null || !_isEmailPrewarmCurrent(generation, signal)) return false;
const n = Math.max(1, Math.min(20, Number(limit) || 8));
const key = `${accountId}|${maxUid || 0}|${n}`;
if (_libUnreadPrewarmKey === key && (Date.now() - _libUnreadPrewarmAt) < 60 * 1000) return true;
try {
const folder = 'INBOX';
const res = await fetch(emailApiUrl('/api/email/list', {
folder,
limit: n,
offset: 0,
filter: 'unread',
account_id: accountId || undefined,
}), {
credentials: 'same-origin',
signal,
});
if (!_isEmailPrewarmCurrent(generation, signal) || !res.ok) return false;
const data = await res.json().catch(() => null);
if (!_isEmailPrewarmCurrent(generation, signal)) return false;
if (!data || data.error || !Array.isArray(data.emails) || !data.emails.length) return false;
const sync = data.sync || {};
_libCachePut(_libCacheKeyFor(accountId, folder, 'unread', false), {
emails: data.emails,
total: data.total || data.emails.length,
sync,
});
_libUnreadPrewarmKey = key;
_libUnreadPrewarmAt = Date.now();
return true;
} catch (_) {
return false;
}
}
async function _prewarmEmailViews({ signal, generation } = {}) {
if (!_isEmailPrewarmCurrent(generation, signal)) return false;
_setEmailSyncStatus({ warming: true });
const folder = 'INBOX';
const filter = 'all';
try {
const accountId = await _ensureEmailAccountsForPrewarm({ signal, generation });
if (accountId === null || !_isEmailPrewarmCurrent(generation, signal)) return false;
const ck = _libCacheKeyFor(accountId, folder, filter, false);
if (_libCacheGet(ck)) {
_libLastPrewarmAt = Date.now();
return true;
}
// One optional first-page request only. Folder metadata, unread state, and
// other accounts remain demand-driven so startup cannot fan out into IMAP.
const res = await fetch(emailApiUrl('/api/email/list', {
folder,
limit: _LIB_INITIAL_PAGE_SIZE,
offset: 0,
filter,
account_id: accountId || undefined,
}), {
credentials: 'same-origin',
signal,
});
if (!_isEmailPrewarmCurrent(generation, signal) || !res.ok) return false;
const data = await res.json().catch(() => null);
if (!_isEmailPrewarmCurrent(generation, signal)) return false;
if (!data || data.error || !Array.isArray(data.emails)) return false;
const sync = data.sync || {};
_libCachePut(ck, {
emails: data.emails,
total: data.total || 0,
sync,
});
_libLastPrewarmAt = Date.now();
_setEmailSyncStatus({
updatedAt: sync.updated_at || new Date().toISOString(),
source: sync.source || '',
warming: true,
});
return true;
} catch (_) {
return false;
} finally {
_setEmailSyncStatus({ warming: false });
}
}
export 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,
sync: {
updated_at: _libSyncStatus.updatedAt || new Date().toISOString(),
source: _libSyncStatus.source || 'local',
},
});
}
}
// 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.
export function _publishActiveAccount() {
try { window.__odysseusActiveEmailAccount = state._libAccountId || null; } catch (_) {}
try {
if (state._libAccountId) localStorage.setItem(_LIB_LAST_ACCOUNT_KEY, state._libAccountId);
} 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;
const onEmailClick = config.onEmailClick;
state._onEmailClick = typeof onEmailClick === 'function' ? (options = {}) => {
const accountId = String(state._libAccountId || '');
const libraryFolder = String(state._libFolder || 'INBOX');
const messageFolder = String(options.email?.folder || libraryFolder);
const mailboxGeneration = _emailMailboxGeneration;
const mailboxContext = Object.freeze({
accountId,
libraryFolder,
messageFolder,
mailboxGeneration,
isCurrent: () => (
String(state._libAccountId || '') === accountId &&
String(state._libFolder || 'INBOX') === libraryFolder &&
_emailMailboxGeneration === mailboxGeneration
),
});
return onEmailClick({ ...options, mailboxContext });
} : null;
}
export function isOpen() { return state._libOpen; }
export function openEmailLibrary(opts = {}) {
// Foreground email always wins: cancel a delayed/idle callback and abort the
// one optional request if it has already started. Generation checks make a
// non-abortable response harmless if it races this transition.
_cancelEmailPrewarm();
// Preserve a valid inbox snapshot while rebuilding the modal. Reader
// failures and mobile draft transitions can reopen this window before the
// next list request has completed; clearing the snapshot here made that
// normal recovery path look like an empty inbox.
const previousLibraryWasOpen = state._libOpen;
const previousAccountId = state._libAccountId || '';
const previousFolder = state._libFolder || 'INBOX';
const previousFilter = state._libFilter || 'all';
const previousSearch = state._libSearch || '';
const previousEmails = Array.isArray(state._libEmails) ? state._libEmails.slice() : [];
// Force-clean any stale DOM 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;
}
if (state._libInnerEscHandler) {
window.removeEventListener('keydown', state._libInnerEscHandler, true);
state._libInnerEscHandler = null;
}
_emailMailboxGeneration += 1;
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._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();
} else if (!state._libAccountId) {
const rememberedAccount = _rememberedEmailAccountId();
if (rememberedAccount) {
state._libAccountId = rememberedAccount;
_publishActiveAccount();
}
}
state._libViewInlineImages = _readEmailInlineImagesPreference();
if (opts.folder) state._libFolder = opts.folder;
state._libPendingExpandUid = opts.uid || null;
const sameMailbox = previousLibraryWasOpen
&& previousAccountId === (state._libAccountId || '')
&& previousFolder === (state._libFolder || 'INBOX')
&& previousFilter === 'all'
&& !previousSearch
&& previousEmails.length > 0;
state._libEmails = sameMailbox ? previousEmails : [];
state._libTotal = sameMailbox ? Math.max(state._libTotal || 0, previousEmails.length) : 0;
const modal = document.createElement('div');
modal.className = 'modal';
modal.id = 'email-lib-modal';
modal.innerHTML = `
`;
document.body.appendChild(modal);
modal.style.display = 'block';
_syncEmailAutoReplyTitle(!!state._libAutoReplyActive);
_refreshUnreadBadge({ syncAutoReplyTitle: true }).catch(() => {});
document.getElementById('email-lib-grid')?.scrollTo(0, 0);
modal.querySelector('.email-settings-body')?.scrollTo(0, 0);
_renderEmailSyncStatus();
if (_libSyncTicker) clearInterval(_libSyncTicker);
_libSyncTicker = setInterval(_renderEmailSyncStatus, 30000);
// 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();
if (unreadBadge.dataset.mode === 'away') {
_openGlobalEmailSettings('auto-reply');
return;
}
_toggleUnreadEmails();
});
unreadBadge?.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
if (unreadBadge.dataset.mode === 'away') {
_openGlobalEmailSettings('auto-reply');
return;
}
_toggleUnreadEmails();
});
const autoReplyBadge = document.getElementById('email-lib-auto-reply-badge');
const openAutoReplySettings = (e) => {
e.stopPropagation();
_openGlobalEmailSettings('auto-reply');
};
autoReplyBadge?.addEventListener('click', openAutoReplySettings);
autoReplyBadge?.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
openAutoReplySettings(e);
});
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);
document.getElementById('email-settings-header-back')?.addEventListener('click', _hideEmailSettingsPage);
// 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 = _resolveEmailFolderAlias(e.target.value);
if (e.target.value !== state._libFolder) e.target.value = state._libFolder;
_renderFolderPicker();
_renderSearchPills();
_loadEmailsFresh();
});
document.getElementById('email-lib-filter').addEventListener('change', (e) => {
state._libFilter = e.target.value;
_syncUnreadWindowGlow();
_syncReminderClearButton();
_renderSearchPills();
_loadEmailsFresh();
_syncSearchOptionsMenu();
// Mirror the picker label/icon.
_renderFilterPickerCurrent();
});
_initFilterPicker();
_initFolderPicker();
const searchOptionsBtn = document.getElementById('email-search-options-btn');
const searchOptionsMenu = document.getElementById('email-search-options-menu');
searchOptionsBtn?.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
const shouldOpen = !!searchOptionsMenu?.hidden;
if (searchOptionsMenu) searchOptionsMenu.hidden = !shouldOpen;
searchOptionsBtn.setAttribute('aria-expanded', String(shouldOpen));
_syncSearchOptionsMenu();
});
searchOptionsMenu?.addEventListener('click', (ev) => {
const item = ev.target.closest('[data-email-search-option]');
if (!item) return;
ev.preventDefault();
ev.stopPropagation();
const opt = item.dataset.emailSearchOption;
if (opt === 'attachments') _toggleEmailAttachmentFilter();
else if (opt === 'undone' || opt === 'reminders') _setEmailListFilter(opt);
else if (opt === 'date') {
const panel = searchOptionsMenu.querySelector('.email-search-date-panel');
panel.hidden = !panel.hidden;
const from = panel.querySelector('#email-search-date-from');
const to = panel.querySelector('#email-search-date-to');
if (from) from.value = state._libDateFrom || '';
if (to) to.value = state._libDateTo || '';
}
searchOptionsMenu.hidden = false;
searchOptionsBtn?.setAttribute('aria-expanded', 'true');
});
searchOptionsMenu?.querySelector('[data-email-date-apply]')?.addEventListener('click', (ev) => {
ev.preventDefault(); ev.stopPropagation();
const nextFrom = searchOptionsMenu.querySelector('#email-search-date-from')?.value || '';
const nextTo = searchOptionsMenu.querySelector('#email-search-date-to')?.value || '';
if (nextFrom && nextTo && nextFrom > nextTo) {
showToast('From date must be before To date');
return;
}
state._libDateFrom = nextFrom;
state._libDateTo = nextTo;
_syncSearchOptionsMenu();
_renderSearchPills();
_loadEmailsFresh({ useCache: false });
searchOptionsMenu.querySelector('.email-search-date-panel').hidden = true;
});
searchOptionsMenu?.querySelector('[data-email-date-clear]')?.addEventListener('click', (ev) => {
ev.preventDefault(); ev.stopPropagation();
state._libDateFrom = '';
state._libDateTo = '';
_syncSearchOptionsMenu();
_renderSearchPills();
_loadEmailsFresh({ useCache: false });
searchOptionsMenu.querySelector('.email-search-date-panel').hidden = true;
});
if (state._emailSearchOptionsDismiss) {
document.removeEventListener('click', state._emailSearchOptionsDismiss);
}
state._emailSearchOptionsDismiss = (ev) => {
if (!searchOptionsMenu || searchOptionsMenu.hidden) return;
if (ev.target.closest('#email-search-options-menu, #email-search-options-btn')) return;
searchOptionsMenu.hidden = true;
searchOptionsBtn?.setAttribute('aria-expanded', 'false');
};
document.addEventListener('click', state._emailSearchOptionsDismiss);
_syncSearchOptionsMenu();
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';
_renderFilterPickerCurrent();
_syncSearchOptionsMenu();
_syncReminderClearButton();
_renderSearchPills();
_loadEmailsFresh();
} catch (err) {
console.error(err);
showToast('Failed to clear reminder emails');
}
});
// 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 () => {
await _refreshEmailLibraryFromUi(document.getElementById('email-lib-refresh-btn'));
});
_initMobileEmailPullRefresh();
document.getElementById('email-lib-settings-btn')?.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
_openGlobalEmailSettings('show-tags');
});
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 (modal.classList.contains('email-settings-mode')) {
_hideEmailSettingsPage();
return;
}
if (state._selectMode) {
state._selectMode = false;
state._selectedUids.clear();
_setSelectBtnState(false);
_updateBulkBar();
_renderGrid();
return;
}
const expanded = modal.querySelector('.doclib-card.doclib-card-expanded');
if (expanded) {
_exitEmailReaderModeForList();
expanded.focus?.({ preventScroll: true });
return;
}
if (modal.classList.contains('email-reading')) {
_exitEmailReaderModeForList();
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);
// The global UI Escape arbiter is a document-level capture listener and can
// close the hovered modal before this library handler sees the event. Give
// email's inner layers a window-level first pass so Escape closes exactly
// one inner state before the library itself is dismissed.
state._libInnerEscHandler = (e) => {
if (e.key !== 'Escape') return;
const modal = document.getElementById('email-lib-modal');
if (!modal || modal.classList.contains('hidden')) return;
if (dismissTopMenu()) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation?.();
return;
}
if (modal.classList.contains('email-settings-mode')) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation?.();
_hideEmailSettingsPage();
return;
}
if (state._selectMode) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation?.();
state._selectMode = false;
state._selectedUids.clear();
_setSelectBtnState(false);
_updateBulkBar();
_renderGrid();
return;
}
const expanded = modal.querySelector('.doclib-card.doclib-card-expanded');
if (expanded || modal.classList.contains('email-reading')) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation?.();
_exitEmailReaderModeForList();
}
};
window.addEventListener('keydown', state._libInnerEscHandler, true);
const grid = document.getElementById('email-lib-grid');
if (grid && !grid.children.length) _renderEmailLoading(grid);
if (Array.isArray(state._libAccounts) && state._libAccounts.length) {
_renderAccountsStrip();
} else {
_renderAccountsLoading();
}
const fastAccountAtOpen = state._libAccountId || '';
if (fastAccountAtOpen) {
_loadEmails({ useCache: true });
}
// If we already know the previous/default account, paint that inbox first
// from the durable index and validate accounts in parallel. Cold refreshes
// otherwise waited on `/accounts` before even trying the cheap indexed list.
(async () => {
await _loadAccounts();
_refreshUnreadBadge().catch(() => {});
_loadFolders();
_loadEmailReminderBellVisibility();
if (!fastAccountAtOpen || fastAccountAtOpen !== (state._libAccountId || '')) {
_loadEmails({ useCache: true });
}
})();
}
export async function _loadAccounts({ force = false } = {}) {
const hasCachedAccounts = Array.isArray(state._libAccounts) && state._libAccounts.length;
const accountsFresh = _libAccountsLoadedAt && (Date.now() - _libAccountsLoadedAt) < _LIB_ACCOUNTS_TTL_MS;
if (!force && hasCachedAccounts && accountsFresh) {
if (!state._libAccountId) {
const def = state._libAccounts.find(a => a.is_default) || state._libAccounts[0];
state._libAccountId = def?.id || null;
_publishActiveAccount();
}
state._libViewInlineImages = _readEmailInlineImagesPreference();
_renderAccountsStrip();
return;
}
try {
const r = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' });
if (!r.ok) return;
const d = await r.json();
state._libAccounts = d.accounts || [];
_libAccountsLoadedAt = Date.now();
} catch (_) {
if (!hasCachedAccounts) 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 && !state._libAccounts.some(a => a && a.id === state._libAccountId)) {
state._libAccountId = null;
}
if (!state._libAccountId && state._libAccounts.length) {
const def = state._libAccounts.find(a => a.is_default) || state._libAccounts[0];
state._libAccountId = def.id;
_publishActiveAccount();
}
state._libViewInlineImages = _readEmailInlineImagesPreference();
_renderAccountsStrip();
_refreshAccountUnreadHighlights().catch(() => {});
}
export function _renderAccountsStrip() {
const strip = document.getElementById('email-lib-accounts');
if (!strip) return;
strip.style.display = 'flex';
const esc = s => String(s || '').replace(/&/g, '&').replace(/ Number(!!b.is_default) - Number(!!a.is_default));
const nameCounts = new Map();
accounts.forEach(a => {
const name = String(a.name || '').trim();
if (name) nameCounts.set(name, (nameCounts.get(name) || 0) + 1);
});
for (const a of accounts) {
const active = state._libAccountId === a.id ? ' active' : '';
const accountAddress = a.from_address || a.imap_user || '';
const accountName = String(a.name || '').trim();
const accountLabel = accountName && nameCounts.get(accountName) > 1 && accountAddress
? `${accountName} · ${accountAddress}`
: (accountName || accountAddress || 'account');
const unread = _accountUnreadState.get(String(a.id || '')) || {};
const unreadCount = Number(unread.unreadCount || 0);
const unreadClass = unreadCount > 0 ? ' email-account-has-unread' : '';
const unreadTitle = unreadCount > 0 ? ` · ${unreadCount > 999 ? '999+' : unreadCount} unread` : '';
const away = state._libAutoReplyActive && state._libAccountId === a.id;
const label = away ? (accountAddress || accountLabel) : accountLabel;
const awayLabel = away ? '(AWAY)' : '';
const unreadDot = unreadCount > 0
? ``
: '';
html += ``
+ ``
+ ``;
}
strip.innerHTML = html;
strip.querySelectorAll('button[data-acc-id]').forEach(btn => {
btn.addEventListener('click', async () => {
const nextAccountId = btn.dataset.accId || null;
if ((state._libAccountId || null) === nextAccountId) return;
// Tag filters belong to the current mailbox account. Keeping one while
// switching accounts can show an empty or misleading result set.
if (String(state._libFilter || '').startsWith('tag:')) {
state._libFilter = 'all';
const filterEl = document.getElementById('email-lib-filter');
if (filterEl) filterEl.value = 'all';
}
state._libSearchPills = (state._libSearchPills || []).filter((pill) => {
const value = String(pill?.value || pill?.text || '');
return pill?.type !== 'filter' || !value.includes('tag:');
});
state._libAccountId = nextAccountId;
state._libViewInlineImages = _readEmailInlineImagesPreference(nextAccountId);
_publishActiveAccount();
_renderAccountsStrip();
_renderFilterPickerCurrent();
_syncSearchOptionsMenu();
_renderSearchPills();
_loadEmailsFresh({ force: true, useCache: true, refreshAfterCache: true, showRefreshSpinner: true });
_loadFolders({ resetMissing: true }).catch(() => {});
_refreshUnreadBadge({ syncAutoReplyTitle: true }).catch(() => {});
_refreshAccountUnreadHighlights().catch(() => {});
});
});
// 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();
}
async function _refreshAccountUnreadHighlights() {
const accounts = Array.isArray(state._libAccounts) ? state._libAccounts.slice() : [];
if (!accounts.length) return;
const seq = ++_accountUnreadSeq;
const next = new Map();
await Promise.all(accounts.map(async (a) => {
const id = String(a && a.id || '');
if (!id) return;
try {
const res = await fetch(emailApiUrl('/api/email/unread-state', {
folder: 'INBOX',
account_id: id,
}), { credentials: 'same-origin' });
if (!res.ok) return;
const data = await res.json().catch(() => ({}));
next.set(id, {
unreadCount: Number(data.unread_count || 0),
maxUid: Number(data.max_uid || 0),
});
} catch (_) {}
}));
if (seq !== _accountUnreadSeq) return;
_accountUnreadState = next;
_renderAccountsStrip();
}
export async function mountEmailSettings(host) {
if (!host) return;
host.classList.add('email-settings-page');
const head = host.querySelector('.email-settings-page-head');
const body = host.querySelector('.email-settings-body');
const defaultCard = document.getElementById('settings-email-default-card');
const defaultHost = document.getElementById('settings-email-default-host');
if (!head || !body) return;
const render = async () => {
body.innerHTML = _emailSettingsLoadingHtml();
try {
await _loadAccounts().catch(() => {});
const multipleAccounts = (state._libAccounts || []).filter(a => a && a.enabled !== false).length > 1;
if (defaultCard) defaultCard.hidden = !multipleAccounts;
if (defaultHost) defaultHost.innerHTML = multipleAccounts ? _emailSettingsAccountSelectHtml() : '';
head.innerHTML = '';
const [cfg, writingStyle] = await Promise.all([
_fetchEmailSettingsConfig(),
_fetchEmailWritingStyle().catch(() => ''),
]);
body.innerHTML = _emailWritingStyleHtml(writingStyle)
+ _emailDisplaySettingsHtml(cfg)
+ _emailCleanupSettingsHtml()
+ _emailSettingsFormHtml(cfg);
state._libAutoReplyActive = _isAutoReplyActiveForCurrentAccount(cfg);
state._libAutoReplyDraftActive = null;
_syncEmailAutoReplyTitle(state._libAutoReplyActive);
_syncAutoReplyCalendarEvent(cfg).catch((err) => console.warn('Failed to reconcile auto-reply calendar event:', err));
const focus = host.dataset.emailSettingsFocus;
if (focus) {
const section = focus === 'show-tags'
? body.querySelector('.email-settings-display-section')
: body.querySelector(`.email-settings-${focus}-section`);
if (section) {
section.scrollIntoView?.({ block: 'nearest' });
}
delete host.dataset.emailSettingsFocus;
}
_bindEmailSettingsPageControls(host, defaultHost);
defaultHost?.querySelector('.email-settings-default')?.addEventListener('click', async (ev) => {
ev.stopPropagation();
const acctId = ev.currentTarget.dataset.emailDefaultId;
const account = (state._libAccounts || []).find(a => a && a.id === acctId);
if (!acctId || account?.is_default) return;
try {
const response = await fetch(`${API_BASE}/api/email/accounts/${encodeURIComponent(acctId)}/set-default`, {
method: 'POST', credentials: 'same-origin',
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
for (const a of state._libAccounts) a.is_default = a.id === acctId;
_syncEmailSettingsAccountPicker(defaultHost);
_renderAccountsStrip();
} catch (err) {
console.error('Set default account failed:', err);
}
});
defaultHost?.querySelector('.email-settings-default')?.addEventListener('keydown', (ev) => {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
ev.preventDefault();
ev.currentTarget.click();
});
defaultHost?.querySelector('#email-settings-account-select')?.addEventListener('change', async (ev) => {
state._libAccountId = ev.currentTarget.value || null;
_publishActiveAccount();
_renderAccountsStrip();
await render();
});
} catch (_) {
body.innerHTML = 'Could not load settings.
';
}
};
await render();
}
export async function openEmailLibrarySettings() {
openEmailLibrary();
await _showEmailSettingsPage();
}
export function closeEmailLibrary() {
_cancelEmailPrewarm();
const modal = document.getElementById('email-lib-modal');
if (modal) modal.remove();
if (_libSyncTicker) {
clearInterval(_libSyncTicker);
_libSyncTicker = null;
}
_clearEmailDocumentSplit();
if (state._libEscHandler) {
document.removeEventListener('keydown', state._libEscHandler, true);
state._libEscHandler = null;
}
if (state._libInnerEscHandler) {
window.removeEventListener('keydown', state._libInnerEscHandler, true);
state._libInnerEscHandler = null;
}
if (state._emailSearchOptionsDismiss) {
document.removeEventListener('click', state._emailSearchOptionsDismiss);
state._emailSearchOptionsDismiss = 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.
export 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.
export 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;
}
export async function _loadFolders({ resetMissing = false, live = false } = {}) {
const seq = ++_libFolderSeq;
const accountAtStart = state._libAccountId || '';
try {
const res = await fetch(emailApiUrl('/api/email/folders', {
account_id: accountAtStart || undefined,
cached_only: live ? undefined : 1,
}));
let 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;
const resolvedFolder = _resolveEmailFolderAlias(state._libFolder);
if (resolvedFolder !== state._libFolder && data.folders.includes(resolvedFolder)) {
state._libFolder = resolvedFolder;
}
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 folderHeader = document.createElement('option');
folderHeader.disabled = true;
folderHeader.textContent = '─────────';
sel.appendChild(folderHeader);
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);
}
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);
}
if (!data.folders.some(f => /trash|bin|deleted/i.test(String(f)))) {
const trashOpt = document.createElement('option');
trashOpt.value = 'Trash';
trashOpt.textContent = 'Trash';
if (String(state._libFolder).toLowerCase() === 'trash') trashOpt.selected = true;
sel.appendChild(trashOpt);
}
// Some providers omit Drafts from the folder discovery response even
// though IMAP APPEND can still write to the standard Drafts mailbox.
// Keep it available beside the provider-discovered folders.
const hasDraftsFolder = data.folders.some(f => String(f).toLowerCase().includes('draft'));
if (!hasDraftsFolder) {
const draftOpt = document.createElement('option');
draftOpt.value = 'Drafts';
draftOpt.textContent = 'Drafts';
if (String(state._libFolder).toLowerCase() === 'drafts') draftOpt.selected = true;
sel.appendChild(draftOpt);
}
// Scheduled (special virtual folder)
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;
_renderFolderPicker();
} 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)));
}
function _findEmailFolder(patterns, fallback) {
const available = Array.isArray(state._libFolders) ? state._libFolders.filter(Boolean) : [];
const lower = new Map(available.map(f => [String(f).toLowerCase(), f]));
for (const p of patterns) {
const direct = lower.get(String(p).toLowerCase());
if (direct) return direct;
}
return available.find(f => patterns.some(p => String(f).toLowerCase().includes(String(p).toLowerCase()))) || fallback;
}
function _resolveEmailFolderAlias(folder) {
const raw = String(folder || '').trim();
const key = raw.toLowerCase();
if (!raw || key === 'inbox' || raw === '__scheduled__') return raw || 'INBOX';
if (key === 'sent' || key === 'sent mail' || key === 'sent items') {
return _findEmailFolder(['[Gmail]/Sent Mail', '[Google Mail]/Sent Mail', 'Sent Mail', 'Sent Items', 'INBOX.Sent', 'Sent'], raw);
}
if (key === 'archive' || key === 'archives' || key === 'all mail' || key === 'archive / all mail') {
return _findEmailFolder(['[Gmail]/All Mail', '[Google Mail]/All Mail', 'All Mail', 'Archive', 'Archives'], raw);
}
if (key === 'starred' || key === 'favorites' || key === 'flagged') {
return _findEmailFolder(['[Gmail]/Starred', '[Google Mail]/Starred', 'Starred', 'Flagged'], raw);
}
if (key === 'junk' || key === 'spam') {
return _findEmailFolder(['[Gmail]/Spam', '[Google Mail]/Spam', 'Spam', 'Junk'], raw);
}
if (key === 'trash' || key === 'bin' || key === 'deleted') {
return _findEmailFolder(['[Gmail]/Trash', '[Google Mail]/Trash', '[Gmail]/Bin', 'Trash', 'Bin', 'Deleted Messages', 'Deleted Items'], raw);
}
if (key === 'draft' || key === 'drafts') {
return _findEmailFolder(['[Gmail]/Drafts', '[Google Mail]/Drafts', 'Drafts', 'Draft', 'INBOX.Drafts'], raw);
}
return raw;
}
function _sentFolderName() {
return _findEmailFolder(['[Gmail]/Sent Mail', 'Sent Mail', 'Sent Items', 'INBOX.Sent', 'Sent'], 'Sent');
}
function _deriveSearchScope(rawQuery) {
const original = String(rawQuery || '').trim();
const tokens = original.split(/\s+/).filter(Boolean);
let scope = 'all';
const kept = [];
let forced = '';
for (const token of tokens) {
const t = token.toLowerCase().replace(/^#+/, '').replace(/:$/, '');
if (['sent', 'sentmail', 'sent-mail', 'outbox'].includes(t)) {
forced = 'sent';
continue;
}
if (['inbox'].includes(t)) {
forced = 'inbox';
continue;
}
kept.push(token);
}
if (forced) scope = forced;
let folder = 'INBOX';
let serverScope = 'all';
if (scope === 'sent') {
folder = _sentFolderName();
serverScope = 'folder';
} else if (scope === 'inbox') {
folder = 'INBOX';
serverScope = 'folder';
} else if (scope === 'current') {
folder = state._libFolder || 'INBOX';
serverScope = 'folder';
}
return {
scope,
folder,
serverScope,
q: forced ? kept.join(' ').trim() : original,
forced,
};
}
// 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;
let _libServerSearchEmails = null;
let _libServerSearchTotal = 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 _formatEmailSuggestionDate(em) {
let d = null;
if (em && em.date) {
const parsed = new Date(em.date);
if (Number.isFinite(parsed.getTime())) d = parsed;
}
if (!d && em && em.date_epoch) {
const parsed = new Date(Number(em.date_epoch) * 1000);
if (Number.isFinite(parsed.getTime())) d = parsed;
}
if (!d) return '';
const now = new Date();
const opts = d.getFullYear() === now.getFullYear()
? { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }
: { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' };
return d.toLocaleDateString(undefined, opts);
}
// 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:bills', label: 'Bills', keywords: ['bill', 'bills', 'billing'] },
{ value: 'filter:tag:receipt', label: 'Receipt', keywords: ['receipt', 'receipts', 'purchase'] },
{ value: 'filter:tag:travel', label: 'Travel', keywords: ['travel', 'trip', 'booking'] },
{ value: 'filter:tag:spam', label: 'Spam', keywords: ['spam', 'junk'] },
];
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 '';
if (v === 'date-range') 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 || '',
date_label: _formatEmailSuggestionDate(em),
},
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 _exactTypedFilterSuggestion(value) {
const needle = String(value || '').trim().toLowerCase();
if (!needle) return null;
// Keep automatic conversion deliberately exact. A query such as
// "attachment invoice" should remain a text search, while the common
// one-word shortcut should become the existing filter pill.
const option = _LIB_FILTER_OPTIONS.find(opt => (
opt.value === 'filter:has-attachments' && opt.keywords.includes(needle)
));
return option
? { kind: 'filter', value: option.value, label: option.label, icon: _libFilterIconFor(option.value) }
: null;
}
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();
const dateNeedle = _formatEmailSuggestionDate(em).toLowerCase();
const dateOnlyNeedle = dateNeedle.replace(/\s+\d{1,2}:\d{2}\s*(am|pm)?$/i, '');
const rawDate = String(em.date || em.date_display || '').toLowerCase();
return (
String(em.subject || '').toLowerCase().includes(needle) ||
String(em.from_name || '').toLowerCase().includes(needle) ||
String(em.from_address || '').toLowerCase().includes(needle) ||
String(em.to || '').toLowerCase().includes(needle) ||
String(em.cc || '').toLowerCase().includes(needle) ||
String(em.snippet || em.preview || '').toLowerCase().includes(needle) ||
dateNeedle.includes(needle) ||
dateOnlyNeedle.includes(needle) ||
rawDate.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() {
_exitEmailReaderModeForList();
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 = _libServerSearchEmails || _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 hasRefinementBase = !!(_libServerSearchEmails && pills.length > 1);
const serverHandledDraft = !hasRefinementBase && !!(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 && !hasRefinementBase)
? 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 _emailFilterLabelFor(value) {
const v = String(value || '');
if (v === 'has-attachments') return 'Attachments';
const sel = document.getElementById('email-lib-filter');
const opt = sel?.querySelector?.(`option[value="${CSS.escape(v)}"]`);
if (opt) return opt.textContent || v;
const match = _LIB_FILTER_OPTIONS.find(o => o.value === `filter:${v}`);
return match?.label || v;
}
function _activeEmailFilterPills() {
const active = [];
if (state._libHasAttachments) {
active.push({ value: 'has-attachments', label: _emailFilterLabelFor('has-attachments') });
}
const filter = String(state._libFilter || 'all');
if (filter && filter !== 'all') {
active.push({ value: filter, label: _emailFilterLabelFor(filter) });
}
if (state._libDateFrom || state._libDateTo) {
const range = state._libDateFrom && state._libDateTo
? `${state._libDateFrom} – ${state._libDateTo}`
: state._libDateFrom ? `From ${state._libDateFrom}` : `Through ${state._libDateTo}`;
active.push({ value: 'date-range', label: range });
}
return active;
}
function _clearActiveEmailFilterPill(value) {
_resetBulkSelectionForContextChange({ rerender: true });
const v = String(value || '');
if (v === 'has-attachments') {
state._libHasAttachments = false;
} else if (v === 'date-range') {
state._libDateFrom = '';
state._libDateTo = '';
} else if (state._libFilter === v) {
state._libFilter = 'all';
const sel = document.getElementById('email-lib-filter');
if (sel) sel.value = 'all';
}
_syncUnreadWindowGlow();
_syncReminderClearButton();
_renderFilterPickerCurrent();
_syncSearchOptionsMenu();
_renderSearchPills();
_loadEmailsFresh();
}
function _renderSearchPills() {
const wrap = document.getElementById('email-lib-pills');
if (!wrap) return;
const pills = (state._libSearchPills || []).filter(p => p.type !== 'filter');
state._libSearchPills = pills;
const folderSelect = document.getElementById('email-lib-folder');
const folder = String(folderSelect?.value || state._libFolder || 'INBOX');
const showingFolderPill = folder && folder !== 'INBOX' && folder !== '__scheduled__';
const filterPills = _activeEmailFilterPills();
const chipBar = document.getElementById('email-lib-chip-bar');
const input = document.getElementById('email-lib-search');
const visiblePillCount = pills.length + filterPills.length + (showingFolderPill ? 1 : 0);
if (chipBar) chipBar.classList.toggle('has-email-lib-pills', visiblePillCount > 0);
if (input) input.placeholder = visiblePillCount > 0 ? '' : 'Search by name or text';
const esc = s => String(s || '').replace(/&/g, '&').replace(/
${esc(folderLabel)}
`
: '';
const filterPillHtml = filterPills.map(p => {
const titleAttr = esc(p.label);
return `
${_libFilterIconFor(`filter:${p.value}`)}
${esc(p.label)}
`;
}).join('');
wrap.innerHTML = folderPill + filterPillHtml + pills.map((p, i) => {
const label = p.type === 'contact' ? (p.name || p.email || '?') : (p.text || '');
return `
${esc(label)}
`;
}).join('');
requestAnimationFrame(() => {
if (wrap.scrollWidth <= wrap.clientWidth) return;
for (const value of ['has-attachments', 'reminders']) {
const pill = wrap.querySelector(`.email-lib-filter-pill[data-email-filter-pill="${value}"]`);
if (!pill) continue;
pill.classList.add('email-lib-filter-pill-icon-only');
if (wrap.scrollWidth <= wrap.clientWidth) break;
}
});
wrap.querySelectorAll('.email-lib-filter-pill[data-email-filter-pill="date-range"]').forEach(pill => {
pill.style.cursor = 'pointer';
pill.addEventListener('click', (ev) => {
if (ev.target.closest('.email-lib-filter-pill-x')) return;
const menu = document.getElementById('email-search-options-menu');
const trigger = document.getElementById('email-search-options-btn');
const panel = menu?.querySelector('.email-search-date-panel');
if (!menu || !panel) return;
menu.hidden = false;
panel.hidden = false;
trigger?.setAttribute('aria-expanded', 'true');
const from = panel.querySelector('#email-search-date-from');
const to = panel.querySelector('#email-search-date-to');
if (from) from.value = state._libDateFrom || '';
if (to) to.value = state._libDateTo || '';
from?.focus();
});
});
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);
});
});
wrap.querySelectorAll('.email-lib-filter-pill-x').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
_clearActiveEmailFilterPill(btn.dataset.emailFilterPill || '');
});
});
wrap.querySelectorAll('.email-lib-folder-pill-x').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
_resetBulkSelectionForContextChange({ rerender: true });
const folders = Array.isArray(state._libFolders) ? state._libFolders : [];
state._libFolder = folders.includes('INBOX') ? 'INBOX' : (folders.find(f => !/sent/i.test(String(f || ''))) || 'INBOX');
const folderSel = document.getElementById('email-lib-folder');
if (folderSel) folderSel.value = state._libFolder;
_renderFolderPicker();
_renderSearchPills();
_loadEmailsFresh();
});
});
}
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');
if (pill.value === 'filter:has-attachments') {
if (!state._libHasAttachments) {
state._libHasAttachments = true;
_syncSearchOptionsMenu();
}
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;
_syncSearchOptionsMenu();
}
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');
if (state._libHasAttachments) {
state._libHasAttachments = false;
_syncSearchOptionsMenu();
}
if (sel && sel.value !== 'all') {
sel.value = 'all'; sel.dispatchEvent(new Event('change'));
}
}
function _addSearchPill(pill) {
if (!pill) return;
_resetBulkSelectionForContextChange({ rerender: true });
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') {
// Filter state renders as a synthetic pill from state._libFilter /
// state._libHasAttachments. Do not store a duplicate search pill.
state._libSearchPills = state._libSearchPills.filter(p => p.type !== 'filter');
_applyFilterPillSideEffect(pill);
_renderSearchPills();
return;
}
state._libSearchPills.push(pill);
_renderSearchPills();
_applyPillFilter();
}
function _searchQueryFromPills() {
const parts = [];
for (const p of state._libSearchPills || []) {
if (p.type === 'text' && p.text) parts.push(String(p.text).trim());
else if (p.type === 'contact' && (p.email || p.name)) parts.push(String(p.email || p.name).trim());
}
return parts.filter(Boolean).join(' ').trim();
}
function _removeSearchPillAt(idx) {
if (!Array.isArray(state._libSearchPills)) return;
_resetBulkSelectionForContextChange({ rerender: true });
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;
_libServerSearchEmails = null;
_libServerSearchTotal = 0;
state._libSearch = '';
state._libOffset = 0;
const _searchInput = document.getElementById('email-lib-search');
if (_searchInput) _searchInput.value = '';
_loadEmails({ useCache: true });
return;
}
const remainingQuery = _searchQueryFromPills();
if (remainingQuery.length >= 2) {
state._libSearch = remainingQuery;
const _searchInput = document.getElementById('email-lib-search');
if (_searchInput) _searchInput.value = '';
state._libSearchDraft = '';
_doSearch();
return;
}
if ((state._libSearchPills || []).length && _libSearchHadResults) {
_libSearchHadResults = false;
_libPreSearchEmails = null;
_libPreSearchTotal = 0;
_libServerSearchEmails = null;
_libServerSearchTotal = 0;
state._libSearch = '';
state._libOffset = 0;
_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 `
${s.icon}
${esc(s.label)}
`;
}
if (s.kind === 'email') {
return `
${esc(s.subject)}
${s.from_name ? `
— ${esc(s.from_name)}` : ''}
${s.date_label ? `
${esc(s.date_label)}` : ''}
`;
}
return `
${esc(s.name || s.email)}
${s.name ? `${esc(s.email)}` : ''}
`;
}).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 () => {
_resetBulkSelectionForContextChange({ rerender: true });
state._libSearchDraft = input.value;
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;
_doSearch();
}
}, 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('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) {
const typedFilter = _exactTypedFilterSuggestion(v);
if (typedFilter) {
_acceptSuggestion(typedFilter);
return;
}
_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')) {
if (state._selectMode) return;
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() {
_exitEmailReaderModeForList();
_resetBulkSelectionForContextChange({ rerender: true });
const seq = ++_libSearchSeq;
const derived = _deriveSearchScope(state._libSearch);
const q = derived.q;
if (q.length < 2 && !derived.forced) {
// 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 = derived.folder || state._libFolder || 'INBOX';
const serverScopeAtStart = derived.serverScope || 'all';
// 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;
_setEmailSyncStatus({ loading: 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 stillCurrent = () => (
seq === _libSearchSeq &&
q === _deriveSearchScope(state._libSearch).q &&
accountAtStart === (state._libAccountId || '') &&
folderAtStart === (_deriveSearchScope(state._libSearch).folder || state._libFolder || 'INBOX') &&
// A local/index result can be opened while the slower provider search is
// still running. Do not let that second response re-render the grid and
// destroy the reader the user just opened.
!document.querySelector('#email-lib-grid .email-card-expanded')
);
const searchUrl = (localOnly = false) => {
const params = new URLSearchParams({
folder: folderAtStart,
q,
limit: '100',
scope: serverScopeAtStart,
});
if (accountAtStart) params.set('account_id', accountAtStart);
if (localOnly) params.set('local_only', '1');
return `${API_BASE}/api/email/search?${params.toString()}`;
};
const folderListUrl = () => {
const params = new URLSearchParams({
folder: folderAtStart,
limit: '100',
offset: '0',
filter: state._libFilter || 'all',
});
if (accountAtStart) params.set('account_id', accountAtStart);
return `${API_BASE}/api/email/list?${params.toString()}`;
};
const mergeSearchResults = (painted, incoming) => {
const byKey = new Map();
const out = [];
const add = (em) => {
if (!em) return;
const key = `${em.account_id || accountAtStart || ''}:${em.folder || folderAtStart || ''}:${em.uid || em.message_id || JSON.stringify(em)}`;
if (byKey.has(key)) return;
byKey.set(key, em);
out.push(em);
};
(painted || []).forEach(add);
const additions = [];
const addIncoming = (em) => {
if (!em) return;
const key = `${em.account_id || accountAtStart || ''}:${em.folder || folderAtStart || ''}:${em.uid || em.message_id || JSON.stringify(em)}`;
if (byKey.has(key)) return;
byKey.set(key, em);
additions.push(em);
};
(incoming || []).forEach(addIncoming);
additions.sort((a, b) => {
const ad = Number(a?.date_epoch || 0);
const bd = Number(b?.date_epoch || 0);
if (bd !== ad) return bd - ad;
return String(b?.date || '').localeCompare(String(a?.date || ''));
});
return out.concat(additions);
};
let paintedInterimResults = false;
const paintSearchData = (data, interim = false) => {
if (!stillCurrent()) return false;
if (data.error) throw new Error(data.error);
let results = data.emails || [];
if (!interim && paintedInterimResults) {
results = mergeSearchResults(state._libEmails || [], results);
}
if (!interim && paintedInterimResults && results.length === 0) {
if (stats) {
const count = state._libTotal || (state._libEmails || []).length;
stats.textContent = `${count} cached match${count === 1 ? '' : 'es'}`;
}
_setEmailSyncStatus({
updatedAt: data.sync?.updated_at || '',
source: data.sync?.source || data.source || '',
loading: false,
});
return true;
}
_libSearchHadResults = true;
const pills = state._libSearchPills || [];
const preservingBase = !!(_libServerSearchEmails && pills.length > 1);
if (!preservingBase) {
_libServerSearchEmails = results.slice();
_libServerSearchTotal = Math.max(Number(data.total || 0), results.length);
_libPreSearchEmails = results.slice();
_libPreSearchTotal = _libServerSearchTotal;
state._libEmails = results;
state._libTotal = _libServerSearchTotal;
} else {
state._libEmails = _libServerSearchEmails.slice();
state._libTotal = _libServerSearchTotal;
}
if (pills.length) {
_applyPillFilter();
if (!(state._libEmails || []).length && !preservingBase) state._libEmails = results;
}
_renderGrid();
const count = Math.max(Number(data.total || 0), results.length);
if (stats) {
if (interim) {
stats.textContent = `${count} cached match${count === 1 ? '' : 'es'} · searching…`;
} else {
const source = data.source === 'index' ? ' cached' : '';
stats.textContent = `${count}${source} match${count === 1 ? '' : 'es'}`;
}
}
_setEmailSyncStatus({
updatedAt: interim ? '' : (data.sync?.updated_at || ''),
source: data.sync?.source || data.source || '',
loading: interim,
});
if (interim && results.length) paintedInterimResults = true;
return true;
};
try {
if (q.length < 2 && derived.forced) {
const res = await fetch(folderListUrl());
const data = await res.json();
if (!stillCurrent()) return;
paintSearchData({
emails: (data.emails || []).map(em => ({ ...em, folder: folderAtStart })),
total: data.total || (data.emails || []).length,
source: 'folder',
sync: { source: 'folder' },
}, false);
return;
}
const fullSearchPromise = fetch(searchUrl(false)).then(res => res.json());
const localSearchPromise = fetch(searchUrl(true)).then(res => res.json());
try {
const localData = await localSearchPromise;
if (!stillCurrent()) return;
if (!localData.error && (localData.emails || []).length) {
paintSearchData(localData, true);
}
} catch (_) {
if (!stillCurrent()) return;
}
const data = await fullSearchPromise;
if (!stillCurrent()) return;
paintSearchData(data, false);
} catch (e) {
if (stats) stats.textContent = originalStatsText || 'Search failed';
try { console.error('[email-search] fetch failed:', e); } catch {}
} finally {
_libSearchInFlight = false;
_setEmailSyncStatus({ loading: false });
// If the full search was intentionally ignored because its result landed
// after the user opened a reader, finish the progress label without
// touching the grid or collapsing that reader.
const openReader = document.querySelector('#email-lib-grid .email-card-expanded');
if (openReader && stats && /searching/i.test(stats.textContent || '')) {
const count = state._libTotal || (state._libEmails || []).length;
stats.textContent = `${count} cached match${count === 1 ? '' : 'es'}`;
}
}
}
// Custom dropdown for the email filter (All/Unread/Favorites/...). Replaces
// the native