From ab5a08a6f98cb3f876473be4bb0d617fd448aa11 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?L=C3=A9o?=
Date: Wed, 30 Sep 2026 09:08:21 +0200
Subject: [PATCH 1/3] refactor(email): move emailLibrary.js into
static/js/emailLibrary/
The email library was 11,375 lines in one file, the second-largest JS
module in the repo. `static/js/emailLibrary/` already held four extracted
helpers, so the package existed; the bulk of the code just was not in it.
The implementation moves to `emailLibrary/index.js` and the old path
becomes a re-export wrapper. Five call sites import that path, four of
them dynamically with a `?v=` string, and `sw.js` caches URLs verbatim,
so a wrapper is what makes the move need no coordinated edit to any of
them.
The test side is the part worth reviewing. 58 tests read
`static/js/emailLibrary.js` as text. Pointing them at
`emailLibrary/index.js` would buy one move and break again on the next
one, which is exactly what happened to the stylesheet tests (they now go
through `tests/helpers/stylesheets.py`). So the same shape:
`tests/helpers/js_modules.py` reads the whole package, and assertions
stop caring which module a function sits in.
`tests/test_email_library_module_graph_js.py` is new. This frontend has
no module-graph validation, and a package fails in ways a single file
cannot: a wrapper that drops an export is `undefined` at call time rather
than an error at load time, and a module that reads a `const` across an
import cycle throws only when that module is entered first. It pins the
wrapper's surface against the entry module's, evaluates every module on
its own in a browser, and requires both import paths to hand out one
instance.
It also caught a live gap while being written:
`emailLibrary/replyRecipients.js` is imported by `emailInbox.js`, an app
shell module, and was never in the `sw.js` precache.
---
static/js/emailLibrary.js | 11397 +---------------
static/js/emailLibrary/index.js | 11378 +++++++++++++++
static/sw.js | 5 +-
tests/helpers/js_modules.py | 50 +
tests/test_action_menu_order.py | 51 +-
...est_active_document_visibility_contract.py | 3 +-
tests/test_calendar_tool_card_static.py | 3 +-
tests/test_calendar_visual_static.py | 12 +-
tests/test_card_menu_style_contract.py | 26 +-
...est_email_ai_reply_context_draft_static.py | 5 +-
tests/test_email_attachment_search.py | 11 +-
tests/test_email_folder_chip_static.py | 3 +-
tests/test_email_library_bulk_actions.py | 14 +-
tests/test_email_library_module_graph_js.py | 187 +
tests/test_email_library_prewarm.py | 5 +-
tests/test_email_open_dedup_js.py | 8 +-
tests/test_email_picker_exclusivity_static.py | 3 +-
tests/test_email_ui_async_identity.py | 3 +-
tests/test_external_context_tool_gate.py | 4 +-
...st_notes_mobile_checklist_toggle_static.py | 3 +-
tests/test_security_regressions.py | 4 +-
21 files changed, 11729 insertions(+), 11446 deletions(-)
create mode 100644 static/js/emailLibrary/index.js
create mode 100644 tests/helpers/js_modules.py
create mode 100644 tests/test_email_library_module_graph_js.py
diff --git a/static/js/emailLibrary.js b/static/js/emailLibrary.js
index 385c39ce8..b87e25e6c 100644
--- a/static/js/emailLibrary.js
+++ b/static/js/emailLibrary.js
@@ -1,11375 +1,26 @@
-/**
- * emailLibrary.js — Email library popup modal.
- * Similar pattern to documentLibrary.js. Shows emails in a grid with search/filter.
- */
-
-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 { topPortalZ } from './toolWindowZOrder.js';
-import { makeWindowDraggable } from './windowDrag.js';
-import { orderActionMenuItems, actionMenuRank, SELECT_MENU_ICON } from './actionMenuOrder.js';
-import {
- _esc, _escLinkify, _extractName, _parseTurnMeta,
- _formatBubbleDate, _formatRecipients, _senderColor, _initials,
- _sanitizeHtml, _renderEmailSummaryError,
- _TALON_WROTE, _TALON_FROM, _TALON_SENT, _TALON_SUBJ, _TALON_TO,
- _TALON_ORIG_RE, _SIG_BLOAT_MIN_CHARS,
-} from './emailLibrary/utils.js';
-import {
- _looksLikeSignature, _harvestAttribution, _extractTurnMetaFromBlockquote,
- _foldSummary, _extractQuoteMeta, _peelSigNameLine, _isBloatedSig,
- _tryFoldHintSig, _foldSignature, _SIG_ICON, _QUOTE_ICON,
-} from './emailLibrary/signatureFold.js';
-import { state } from './emailLibrary/state.js';
-import { getSettings } from './appConfig.js';
-import { collapseSidebarToRail } from './modalSnap.js';
-import { emailApiUrl } from './emailShared.js';
-import {
- bindMenuDismiss,
- dismissOrRemove,
- dismissTopMenu,
- bindExpandedCardDismiss,
- unbindExpandedCardDismiss,
-} from './escMenuStack.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 }
-let _autoReplyRefreshSeq = 0;
-
-function _hasActiveEmailSearchResults() {
- if (!_libSearchHadResults) return false;
- if (String(state._libSearch || '').trim()) return true;
- return (state._libSearchPills || []).some(p => p?.type === 'text' || p?.type === 'contact');
-}
-
-function _emailInlineImagesStorageKey(accountId = state._libAccountId) {
- return `odysseus.email.viewInlineImages.${String(accountId || 'default')}`;
-}
-
-function _readEmailInlineImagesPreference(accountId = state._libAccountId) {
- try {
- const stored = localStorage.getItem(_emailInlineImagesStorageKey(accountId));
- return stored === null ? true : stored !== '0';
- } catch (_) {
- return true;
- }
-}
-
-function _writeEmailInlineImagesPreference(enabled, accountId = state._libAccountId) {
- try {
- localStorage.setItem(_emailInlineImagesStorageKey(accountId), enabled ? '1' : '0');
- } catch (_) {}
-}
-
-async function _disableInlineImages(accountId = state._libAccountId) {
- state._libViewInlineImages = false;
- _writeEmailInlineImagesPreference(false, accountId);
- document.querySelectorAll('#email-settings-view-inline-images').forEach(toggle => {
- toggle.checked = true;
- toggle.dispatchEvent(new Event('change', { bubbles: true }));
- });
- try {
- const res = await fetch(emailApiUrl('/api/email/config', {
- account_id: accountId || undefined,
- }), {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
- body: JSON.stringify({ email_view_inline_images: false }),
- });
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- } catch (err) {
- console.error('Failed to disable inline images:', err);
- }
-}
-
-const _EMAIL_SETTINGS_ICON = ``;
-const _EMAIL_SAVE_ICON = '';
-const _EMAIL_SAVED_ICON = '';
-
-function _setEmailSaveIcon(button, saved) {
- if (!button) return;
- const icon = button.querySelector('svg');
- if (icon) icon.outerHTML = saved ? _EMAIL_SAVED_ICON : _EMAIL_SAVE_ICON;
- button.classList.toggle('is-saved', !!saved);
-}
-const _DEFAULT_AUTO_REPLY_SUBJECT = '(Away) {subject}';
-const _DEFAULT_AUTO_REPLY_MESSAGE = "Thanks for your email. I'm away and may be slower to reply.";
-
-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 _normalizeAutoReplyConfig(cfg = {}) {
- const normalized = Object.prototype.hasOwnProperty.call(cfg, 'enabled');
- return {
- enabled: normalized ? !!cfg.enabled : !!cfg.email_auto_reply,
- start: String(normalized ? (cfg.start || '') : (cfg.email_auto_reply_start || '')),
- end: String(normalized ? (cfg.end || '') : (cfg.email_auto_reply_end || '')),
- subject: String(normalized ? (cfg.subject || _DEFAULT_AUTO_REPLY_SUBJECT) : (cfg.email_auto_reply_subject || _DEFAULT_AUTO_REPLY_SUBJECT)),
- message: String(normalized ? (cfg.message || '') : (cfg.email_auto_reply_message || '')),
- cooldown: String(normalized ? (cfg.cooldown || 'period') : (cfg.email_auto_reply_cooldown || 'period')),
- scope: String(normalized ? (cfg.scope || 'all') : (cfg.email_auto_reply_scope || 'all')),
- accountId: String(normalized ? (cfg.accountId || '') : (cfg.email_auto_reply_account_id || '')),
- excludeAutomated: normalized ? cfg.excludeAutomated !== false : cfg.email_auto_reply_exclude_automated !== false,
- pauseNotifications: normalized ? !!cfg.pauseNotifications : !!cfg.email_auto_reply_pause_notifications,
- viewInlineImages: normalized ? cfg.viewInlineImages !== false : cfg.email_view_inline_images !== false,
- };
-}
-
-function _autoReplyDateValue(value) {
- const s = String(value || '').trim();
- const m = s.match(/^(\d{4}-\d{2}-\d{2})/);
- return m ? m[1] : '';
-}
-
-function _todayDateInputValue() {
- const now = new Date();
- const pad = value => String(value).padStart(2, '0');
- return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
-}
-
-const _AUTO_REPLY_CALENDAR_KEY_PREFIX = 'odysseus.email.autoReplyCalendarEvent.';
-const _autoReplyCalendarSyncs = new Map();
-
-function _autoReplyCalendarKey(accountId) {
- return `${_AUTO_REPLY_CALENDAR_KEY_PREFIX}${String(accountId || '').trim()}`;
-}
-
-function _readAutoReplyCalendarUid(accountId) {
- if (!accountId) return '';
- try { return String(localStorage.getItem(_autoReplyCalendarKey(accountId)) || ''); } catch (_) { return ''; }
-}
-
-function _writeAutoReplyCalendarUid(accountId, uid) {
- if (!accountId) return;
- try {
- if (uid) localStorage.setItem(_autoReplyCalendarKey(accountId), uid);
- else localStorage.removeItem(_autoReplyCalendarKey(accountId));
- } catch (_) {}
-}
-
-function _addLocalCalendarDays(value, days) {
- const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})$/);
- if (!match) return '';
- const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]) + days);
- const pad = n => String(n).padStart(2, '0');
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
-}
-
-function _autoReplyCalendarPayload(cfg, accountId = state._libAccountId) {
- const start = _autoReplyDateValue(cfg?.start) || _todayDateInputValue();
- let end = _autoReplyDateValue(cfg?.end) || start;
- if (end < start) end = start;
- return {
- summary: 'Email Auto Reply (away)',
- dtstart: start,
- dtend: _addLocalCalendarDays(end, 1),
- all_day: true,
- description: `Odysseus email auto reply - account:${String(accountId || '')}`,
- };
-}
-
-async function _findAutoReplyCalendarEventUids(cfg, accountId) {
- const start = _autoReplyDateValue(cfg?.start) || _todayDateInputValue();
- const end = _autoReplyDateValue(cfg?.end) || start;
- const startYear = Number(start.slice(0, 4)) || new Date().getFullYear();
- const endYear = Math.max(startYear, Number(end.slice(0, 4)) || startYear);
- try {
- const response = await fetch(`${API_BASE}/api/calendar/events?start=${startYear - 1}-01-01&end=${endYear + 2}-01-01`, {
- credentials: 'same-origin',
- });
- if (!response.ok) return [];
- const data = await response.json().catch(() => ({}));
- const marker = `Odysseus email auto reply - account:${String(accountId || '')}`;
- return (data.events || [])
- .filter(ev => String(ev?.description || '').startsWith(marker))
- .map(ev => String(ev?.uid || '').trim())
- .filter(Boolean);
- } catch (_) {
- return [];
- }
-}
-
-async function _syncAutoReplyCalendarEventNow(cfg, accountId) {
- accountId = String(accountId || '').trim();
- if (!accountId) return false;
- let uid = _readAutoReplyCalendarUid(accountId);
- const headers = { 'Content-Type': 'application/json' };
- const request = (url, options = {}) => fetch(url, {
- credentials: 'same-origin',
- ...options,
- headers: { ...headers, ...(options.headers || {}) },
- });
-
- if (!cfg?.enabled) {
- const knownUids = new Set(uid ? [uid] : []);
- (await _findAutoReplyCalendarEventUids(cfg, accountId)).forEach(foundUid => knownUids.add(foundUid));
- for (const eventUid of knownUids) {
- const response = await request(`${API_BASE}/api/calendar/events/${encodeURIComponent(eventUid)}`, { method: 'DELETE' });
- if (!response.ok && response.status !== 404) throw new Error(`Calendar HTTP ${response.status}`);
- }
- _writeAutoReplyCalendarUid(accountId, '');
- return true;
- }
-
- const payload = _autoReplyCalendarPayload(cfg, accountId);
- if (!uid) {
- uid = (await _findAutoReplyCalendarEventUids(cfg, accountId))[0] || '';
- if (uid) _writeAutoReplyCalendarUid(accountId, uid);
- }
- if (uid) {
- const response = await request(`${API_BASE}/api/calendar/events/${encodeURIComponent(uid)}`, {
- method: 'PUT', body: JSON.stringify(payload),
- });
- if (response.ok) return true;
- if (response.status !== 404) throw new Error(`Calendar HTTP ${response.status}`);
- _writeAutoReplyCalendarUid(accountId, '');
- }
-
- const response = await request(`${API_BASE}/api/calendar/events`, {
- method: 'POST', body: JSON.stringify(payload),
- });
- const data = await response.json().catch(() => ({}));
- if (!response.ok || !data.uid) throw new Error(`Calendar HTTP ${response.status}`);
- _writeAutoReplyCalendarUid(accountId, data.uid);
- return true;
-}
-
-function _syncAutoReplyCalendarEvent(cfg) {
- const accountId = String(state._libAccountId || '').trim();
- if (!accountId) return Promise.resolve(false);
- const previous = _autoReplyCalendarSyncs.get(accountId) || Promise.resolve();
- const next = previous.catch(() => {}).then(() => _syncAutoReplyCalendarEventNow(cfg, accountId));
- _autoReplyCalendarSyncs.set(accountId, next);
- return next.finally(() => {
- if (_autoReplyCalendarSyncs.get(accountId) === next) _autoReplyCalendarSyncs.delete(accountId);
- });
-}
-
-function _isAutoReplyActiveForCurrentAccount(cfg) {
- cfg = _normalizeAutoReplyConfig(cfg || {});
- if (!cfg.enabled) return false;
- if (cfg.scope === 'account' && cfg.accountId && cfg.accountId !== String(state._libAccountId || '')) return false;
- const today = new Date().toISOString().slice(0, 10);
- const start = _autoReplyDateValue(cfg.start);
- const end = _autoReplyDateValue(cfg.end);
- if (start && today < start) return false;
- if (end && today > end) return false;
- return true;
-}
-
-function _syncEmailAutoReplyTitle(active) {
- const titleText = document.getElementById('email-lib-title-text');
- const statusDot = document.getElementById('email-lib-auto-reply-status-dot');
- const badge = document.getElementById('email-lib-auto-reply-badge');
- if (titleText) titleText.textContent = active ? 'Auto Reply - Active' : 'Email';
- if (statusDot) statusDot.style.display = active ? 'inline-block' : 'none';
- if (badge) {
- badge.style.display = 'none';
- badge.textContent = 'Auto Reply';
- }
-}
-
-async function _fetchEmailSettingsConfig() {
- const res = await fetch(emailApiUrl('/api/email/config', {
- account_id: state._libAccountId || undefined,
- }), { credentials: 'include' });
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- const normalized = _normalizeAutoReplyConfig(await res.json());
- state._libViewInlineImages = normalized.viewInlineImages;
- _writeEmailInlineImagesPreference(state._libViewInlineImages);
- return normalized;
-}
-
-async function _fetchEmailWritingStyle() {
- const res = await fetch(emailApiUrl('/api/email/style', {
- account_id: state._libAccountId || undefined,
- }), { credentials: 'include' });
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- const data = await res.json().catch(() => ({}));
- return String(data.style || '');
-}
-
-function _emailSettingsLoadingHtml(label = 'Loading') {
- const wp = spinnerModule.createWhirlpool(16);
- const host = document.createElement('span');
- host.className = 'email-settings-loading-spinner';
- host.appendChild(wp.element);
- const wrap = document.createElement('div');
- wrap.className = 'email-settings-loading';
- wrap.appendChild(host);
- const text = document.createElement('span');
- text.textContent = label;
- wrap.appendChild(text);
- return wrap.outerHTML;
-}
-
-function _emailSettingsFormHtml(cfg) {
- const activeAccount = state._libAccounts?.find(a => a && a.id === state._libAccountId);
- const accountAddress = activeAccount
- ? String(activeAccount.from_address || activeAccount.imap_user || activeAccount.email || '').trim()
- : '';
- const accountScope = accountAddress
- ? ` (${_esc(accountAddress)})`
- : '';
- return `
-
-
-
-
-
Auto Reply${accountScope}
-
-
-
-
-
-
Holiday or away replies for incoming mail.
-
-
-
-
- `;
-}
-
-function _emailCleanupSettingsHtml() {
- const activeAccount = state._libAccounts?.find(a => a && a.id === state._libAccountId);
- const accountAddress = activeAccount
- ? String(activeAccount.from_address || activeAccount.imap_user || activeAccount.email || '').trim()
- : '';
- const accountScope = accountAddress
- ? ` (${_esc(accountAddress)})`
- : '';
- return `
-
-
-
-
Newsletter Unsubscribe${accountScope}
-
Find newsletter and ad emails with unsubscribe options.
-
-
-
-
-
-
-
-
-
-
- `;
-}
-
-function _emailDisplaySettingsHtml(cfg = {}) {
- return `
-
-
-
-
-
Choose which labels appear on email cards. Turn off tagging in
-
-
-
-
-
-
-
-
-
Keep images embedded in email bodies hidden until you load them.
-
-
-
-
- `;
-}
-
-function _emailSettingsAccountSelectHtml() {
- const accounts = Array.isArray(state._libAccounts)
- ? state._libAccounts
- .filter(a => a && a.enabled !== false)
- .slice()
- .sort((a, b) => Number(!!b.is_default) - Number(!!a.is_default))
- : [];
- if (!accounts.length) return '';
- const nameCounts = new Map();
- accounts.forEach(a => {
- const name = String(a.name || '').trim();
- if (name) nameCounts.set(name, (nameCounts.get(name) || 0) + 1);
- });
- const opts = accounts.map(a => {
- const name = String(a.name || '').trim();
- const address = String(a.from_address || a.imap_user || '').trim();
- const label = name && nameCounts.get(name) > 1 && address
- ? `${name} · ${address}`
- : (name || address || 'account');
- const suffix = a.is_default ? ' · (default)' : '';
- return ``;
- }).join('');
- const selected = accounts.find(a => a.id === state._libAccountId) || accounts[0];
- const active = !!selected?.is_default;
- const icon = active
- ? ''
- : '';
- const defaultTitle = active ? 'Default account' : 'Make this the default account';
- return ``
- + `${icon}default`
- + ``
- + `
`;
-}
-
-function _syncEmailSettingsAccountPicker(page) {
- const select = page?.querySelector('#email-settings-account-select');
- const selected = (state._libAccounts || []).find(a => a && a.id === state._libAccountId);
- if (select && selected) {
- [...select.options]
- .sort((a, b) => {
- const aAccount = (state._libAccounts || []).find(account => account && account.id === a.value);
- const bAccount = (state._libAccounts || []).find(account => account && account.id === b.value);
- return Number(!!bAccount?.is_default) - Number(!!aAccount?.is_default);
- })
- .forEach(option => select.appendChild(option));
- select.value = selected.id;
- [...select.options].forEach(option => {
- const account = (state._libAccounts || []).find(a => a && a.id === option.value);
- if (!account) return;
- const label = account.name || account.from_address || account.imap_user || 'account';
- option.textContent = label + (account.is_default ? ' · (default)' : '');
- });
- }
- const toggle = page?.querySelector('.email-settings-default');
- if (!toggle || !selected) return;
- const active = !!selected.is_default;
- toggle.classList.toggle('active', active);
- toggle.setAttribute('aria-pressed', active ? 'true' : 'false');
- toggle.dataset.emailDefaultId = selected.id || '';
- toggle.title = active ? 'Default account' : 'Make this the default account';
- toggle.querySelector('.cookbook-srv-default-icon').innerHTML = active
- ? ''
- : '';
-}
-
-function _emailWritingStyleHtml(style) {
- return `
-
-
-
-
Email Reply Writing Style
-
Used when Odysseus drafts replies for you.
-
-
-
-
-
-
-
- `;
-}
-
-async function _showEmailSettingsPage() {
- const modal = document.getElementById('email-lib-modal');
- const page = document.getElementById('email-lib-settings-page');
- const btn = document.getElementById('email-lib-settings-btn');
- if (!modal || !page) return;
- modal.classList.add('email-settings-mode');
- page.hidden = false;
- page.scrollTop = 0;
- page.querySelector('.email-settings-body')?.scrollTo(0, 0);
- const headerBackBtn = document.getElementById('email-settings-header-back');
- if (headerBackBtn) headerBackBtn.style.display = 'inline-flex';
- btn?.classList.add('active');
- btn?.setAttribute('aria-expanded', 'true');
- page.innerHTML = `
-
-
${_EMAIL_SETTINGS_ICON}Email Settings
-
-
- ${_emailSettingsLoadingHtml()}
-
- `;
- let cfg;
- let writingStyle = '';
- try {
- await _loadAccounts().catch(() => {});
- const head = page.querySelector('.email-settings-page-head');
- const accountSelectHtml = _emailSettingsAccountSelectHtml();
- const oldPicker = page.querySelector('.email-settings-account-picker');
- if (oldPicker) oldPicker.remove();
- const title = head?.querySelector('.email-settings-title');
- if (title && accountSelectHtml) title.insertAdjacentHTML('beforeend', accountSelectHtml);
- [cfg, writingStyle] = await Promise.all([
- _fetchEmailSettingsConfig(),
- _fetchEmailWritingStyle().catch(() => ''),
- ]);
- } catch (err) {
- page.querySelector('.email-settings-body').innerHTML = `Could not load settings.
`;
- return;
- }
- page.querySelector('.email-settings-body').innerHTML = _emailWritingStyleHtml(writingStyle) + _emailDisplaySettingsHtml(cfg) + _emailCleanupSettingsHtml() + _emailSettingsFormHtml(cfg);
- page.querySelector('.email-settings-body')?.scrollTo(0, 0);
- state._libAutoReplyActive = _isAutoReplyActiveForCurrentAccount(cfg);
- state._libAutoReplyDraftActive = null;
- _syncEmailAutoReplyTitle(state._libAutoReplyActive);
- _renderAccountsStrip();
- _syncAutoReplyCalendarEvent(cfg).catch((err) => console.warn('Failed to reconcile auto-reply calendar event:', err));
- page.querySelector('#email-settings-account-select')?.addEventListener('change', async (ev) => {
- state._libAccountId = ev.currentTarget.value || null;
- state._libAutoReplyDraftActive = null;
- _publishActiveAccount();
- _syncEmailSettingsAccountPicker(page);
- _renderAccountsStrip();
- _resetEmailListForFreshLoad();
- _loadEmails({ useCache: true });
- _loadFolders({ resetMissing: true }).catch(() => {});
- page.querySelector('.email-unsubscribe-inline-panel')?.remove();
- const body = page.querySelector('.email-settings-body');
- if (body) body.innerHTML = _emailSettingsLoadingHtml();
- try {
- const [nextCfg, nextStyle] = await Promise.all([
- _fetchEmailSettingsConfig(),
- _fetchEmailWritingStyle().catch(() => ''),
- ]);
- if (body) body.innerHTML = _emailWritingStyleHtml(nextStyle) + _emailDisplaySettingsHtml(nextCfg) + _emailCleanupSettingsHtml() + _emailSettingsFormHtml(nextCfg);
- state._libAutoReplyActive = _isAutoReplyActiveForCurrentAccount(nextCfg);
- state._libAutoReplyDraftActive = null;
- _syncEmailAutoReplyTitle(state._libAutoReplyActive);
- _renderAccountsStrip();
- _syncAutoReplyCalendarEvent(nextCfg).catch((err) => console.warn('Failed to reconcile auto-reply calendar event:', err));
- _bindEmailSettingsPageControls(page);
- } catch (_) {
- if (body) body.innerHTML = `Could not load settings.
`;
- }
- });
- page.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(page);
- _renderAccountsStrip();
- } catch (err) {
- console.error('Set default account failed:', err);
- }
- });
- page.querySelector('.email-settings-default')?.addEventListener('keydown', (ev) => {
- if (ev.key !== 'Enter' && ev.key !== ' ') return;
- ev.preventDefault();
- ev.currentTarget.click();
- });
- _bindEmailSettingsPageControls(page);
-}
-
-function _bindEmailSettingsPageControls(page) {
- page.querySelector('.email-settings-clean-btn')?.addEventListener('click', (ev) => {
- // Clean is an explicit fresh scan action; do not reopen stale cached
- // candidates when the user asks to clean again.
- _openUnsubscribeReviewModal(ev.currentTarget, { forceRescan: true });
- });
- const showTagsToggle = page.querySelector('#email-settings-show-tags');
- const syncDisplayTagsState = () => {
- const section = page.querySelector('.email-settings-display-section');
- const stateLabel = page.querySelector('.email-settings-display-enabled-state');
- const enabled = !!showTagsToggle?.checked;
- section?.classList.toggle('is-disabled', !enabled);
- section?.classList.toggle('is-enabled', enabled);
- if (stateLabel) stateLabel.textContent = enabled ? 'Show' : 'Hide';
- };
- showTagsToggle?.addEventListener('change', (ev) => {
- state._libShowTags = !!ev.target.checked;
- localStorage.setItem('odysseus.email.showTags', state._libShowTags ? '1' : '0');
- _renderGrid();
- document.dispatchEvent(new CustomEvent('odysseus:email-tags-toggle', { detail: { show: state._libShowTags } }));
- if (state._libShowTags && !_loadedEmailsHaveVisibleTags()) _notifyNoLoadedEmailTags();
- syncDisplayTagsState();
- });
- syncDisplayTagsState();
- const inlineImagesToggle = page.querySelector('#email-settings-view-inline-images');
- const syncInlineImagesState = () => {
- const section = page.querySelector('.email-settings-inline-images-section');
- const stateLabel = page.querySelector('.email-settings-inline-images-enabled-state');
- const hidden = !!inlineImagesToggle?.checked;
- section?.classList.toggle('is-disabled', !hidden);
- section?.classList.toggle('is-enabled', hidden);
- if (stateLabel) stateLabel.textContent = hidden ? 'Hide' : 'Show';
- };
- inlineImagesToggle?.addEventListener('change', async (ev) => {
- const hidden = !!ev.target.checked;
- const enabled = !hidden;
- const previous = state._libViewInlineImages;
- state._libViewInlineImages = enabled;
- _writeEmailInlineImagesPreference(enabled);
- syncInlineImagesState();
- try {
- const res = await fetch(emailApiUrl('/api/email/config', {
- account_id: state._libAccountId || undefined,
- }), {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
- body: JSON.stringify({ email_view_inline_images: enabled }),
- });
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- document.querySelectorAll('.email-card-reader').forEach(reader => _wireEmailInlineImages(reader));
- } catch (err) {
- state._libViewInlineImages = previous;
- _writeEmailInlineImagesPreference(previous);
- ev.target.checked = previous === false;
- syncInlineImagesState();
- showToast?.('Failed to save inline image setting');
- console.error('Failed to save inline image setting:', err);
- }
- });
- syncInlineImagesState();
- page.querySelector('[data-open-email-tasks]')?.addEventListener('click', _openTasksForEmailTags);
- const autoReplyStart = page.querySelector('#email-auto-reply-start');
- const seedAutoReplyStartDate = () => {
- if (autoReplyStart && !autoReplyStart.value) autoReplyStart.value = _todayDateInputValue();
- };
- // Empty native date inputs can open on an arbitrary browser baseline date.
- // Seed only the draft field when the picker is opened, leaving saved settings
- // unchanged until the user explicitly presses Save.
- autoReplyStart?.addEventListener('pointerdown', seedAutoReplyStartDate);
- autoReplyStart?.addEventListener('focus', seedAutoReplyStartDate);
- const enabledToggle = page.querySelector('#email-auto-reply-enabled');
- const syncEnabledState = () => {
- const section = page.querySelector('.email-settings-auto-reply-section');
- const stateLabel = section?.querySelector('.email-settings-enabled-state');
- const enabled = !!enabledToggle?.checked;
- section?.classList.toggle('is-disabled', !enabled);
- section?.classList.toggle('is-enabled', enabled);
- if (stateLabel) stateLabel.textContent = enabled ? 'Enabled' : 'Disabled';
- };
- const saveAutoReplySettings = async ({ quiet = false } = {}) => {
- const saveBtn = page.querySelector('.email-settings-save');
- const status = page.querySelector('.email-settings-status');
- if (saveBtn && !quiet) saveBtn.disabled = true;
- _setEmailSaveIcon(saveBtn, false);
- if (status) {
- status.classList.remove('is-success', 'is-error');
- status.textContent = 'Saving…';
- }
- const payload = {
- email_auto_reply: !!page.querySelector('#email-auto-reply-enabled')?.checked,
- email_auto_reply_start: page.querySelector('#email-auto-reply-start')?.value || '',
- email_auto_reply_end: page.querySelector('#email-auto-reply-end')?.value || '',
- email_auto_reply_subject: page.querySelector('#email-auto-reply-subject')?.value || '',
- email_auto_reply_message: page.querySelector('#email-auto-reply-message')?.value || '',
- email_auto_reply_cooldown: page.querySelector('#email-auto-reply-cooldown')?.value || 'period',
- email_auto_reply_scope: 'account',
- email_auto_reply_account_id: state._libAccountId || '',
- email_auto_reply_pause_notifications: !!page.querySelector('#email-auto-reply-pause')?.checked,
- };
- try {
- const res = await fetch(emailApiUrl('/api/email/config', {
- account_id: state._libAccountId || undefined,
- }), {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
- body: JSON.stringify(payload),
- });
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- const savedCfg = {
- enabled: !!payload.email_auto_reply,
- start: payload.email_auto_reply_start,
- end: payload.email_auto_reply_end,
- };
- let calendarSynced = true;
- try {
- await _syncAutoReplyCalendarEvent(savedCfg);
- } catch (calendarErr) {
- calendarSynced = false;
- console.warn('Failed to sync auto-reply calendar event:', calendarErr);
- }
- if (status) {
- status.classList.add('is-success');
- status.textContent = 'Saved';
- }
- _setEmailSaveIcon(saveBtn, true);
- if (!quiet) showToast?.('Email settings saved');
- if (!calendarSynced) showToast?.('Saved, but the calendar event could not be updated');
- state._libAutoReplyActive = _isAutoReplyActiveForCurrentAccount(savedCfg);
- state._libAutoReplyDraftActive = null;
- _syncEmailAutoReplyTitle(state._libAutoReplyActive);
- _renderAccountsStrip();
- _refreshUnreadBadge({ preserveAutoReplyTitle: true }).catch(() => {});
- setTimeout(() => { _setEmailSaveIcon(saveBtn, false); if (status) status.textContent = ''; }, quiet ? 900 : 1400);
- return true;
- } catch (err) {
- if (status) {
- status.classList.add('is-error');
- status.textContent = 'Save failed';
- }
- showToast?.('Failed to save email settings');
- return false;
- } finally {
- if (saveBtn) saveBtn.disabled = false;
- }
- };
- enabledToggle?.addEventListener('change', () => {
- syncEnabledState();
- // Invalidate any config read that started before this toggle. Its stale
- // result must not overwrite the title we are about to render.
- _autoReplyRefreshSeq += 1;
- const active = _isAutoReplyActiveForCurrentAccount({
- email_auto_reply: !!enabledToggle.checked,
- email_auto_reply_start: autoReplyStart?.value || '',
- email_auto_reply_end: page.querySelector('#email-auto-reply-end')?.value || '',
- email_auto_reply_scope: 'account',
- email_auto_reply_account_id: state._libAccountId || '',
- });
- state._libAutoReplyActive = active;
- state._libAutoReplyDraftActive = active;
- _syncEmailAutoReplyTitle(active);
- _renderAccountsStrip();
- saveAutoReplySettings({ quiet: true });
- });
- syncEnabledState();
- page.querySelector('.email-settings-save')?.addEventListener('click', async () => {
- await saveAutoReplySettings();
- });
- page.querySelector('.email-style-settings-save')?.addEventListener('click', async () => {
- const saveBtn = page.querySelector('.email-style-settings-save');
- const status = page.querySelector('.email-style-settings-status');
- saveBtn.disabled = true;
- _setEmailSaveIcon(saveBtn, false);
- if (status) {
- status.classList.remove('is-success', 'is-error');
- status.textContent = 'Saving…';
- }
- try {
- const res = await fetch(emailApiUrl('/api/email/style', {
- account_id: state._libAccountId || undefined,
- }), {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
- body: JSON.stringify({ style: page.querySelector('#email-writing-style-text')?.value || '' }),
- });
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- if (status) {
- status.classList.add('is-success');
- status.textContent = 'Saved';
- }
- _setEmailSaveIcon(saveBtn, true);
- showToast?.('Email writing style saved');
- setTimeout(() => { _setEmailSaveIcon(saveBtn, false); if (status) status.textContent = ''; }, 1400);
- } catch (err) {
- if (status) {
- status.classList.add('is-error');
- status.textContent = 'Save failed';
- }
- showToast?.('Failed to save email writing style');
- } finally {
- saveBtn.disabled = false;
- }
- });
- page.querySelector('.email-style-settings-extract')?.addEventListener('click', async () => {
- const extractBtn = page.querySelector('.email-style-settings-extract');
- const saveBtn = page.querySelector('.email-style-settings-save');
- const status = page.querySelector('.email-style-settings-status');
- const styleEl = page.querySelector('#email-writing-style-text');
- extractBtn.disabled = true;
- if (saveBtn) saveBtn.disabled = true;
- let wp = null;
- if (status) {
- status.innerHTML = '';
- try {
- wp = spinnerModule.createWhirlpool(14);
- wp.element.style.cssText = 'display:inline-block;vertical-align:-3px;margin-right:6px;';
- status.appendChild(wp.element);
- status.appendChild(document.createTextNode('Analyzing sent emails…'));
- } catch (_) {
- status.textContent = 'Analyzing sent emails…';
- }
- }
- try {
- const res = await fetch(emailApiUrl('/api/email/extract-style', {
- account_id: state._libAccountId || undefined,
- }), {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
- body: JSON.stringify({ sample_count: 15 }),
- });
- const data = await res.json().catch(() => ({}));
- if (!res.ok || !data.success || !data.style) throw new Error(data.error || `HTTP ${res.status}`);
- if (styleEl) styleEl.value = data.style;
- if (status) status.textContent = 'Extracted';
- showToast?.('Email writing style extracted');
- setTimeout(() => { if (status) status.textContent = ''; }, 1800);
- } catch (err) {
- if (status) status.textContent = err?.message || 'Extract failed';
- showToast?.('Failed to extract email writing style');
- } finally {
- if (wp && wp.destroy) { try { wp.destroy(); } catch (_) {} }
- extractBtn.disabled = false;
- if (saveBtn) saveBtn.disabled = false;
- }
- });
-}
-
-function _hideEmailSettingsPage() {
- const modal = document.getElementById('email-lib-modal');
- const page = document.getElementById('email-lib-settings-page');
- const btn = document.getElementById('email-lib-settings-btn');
- const headerBackBtn = document.getElementById('email-settings-header-back');
- modal?.classList.remove('email-settings-mode');
- if (page) page.hidden = true;
- if (headerBackBtn) headerBackBtn.style.display = 'none';
- btn?.classList.remove('active');
- btn?.setAttribute('aria-expanded', 'false');
-}
-
-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;
-}
-
-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')}
-
-
`;
-}
-
-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);
-}
-
-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, '-')));
-}
-
-function _clearDoneResponseTagsLocal(em) {
- if (!em || !Array.isArray(em.tags)) return;
- em.tags = em.tags.filter(t => !_DONE_RESPONSE_TAGS.has(String(t || '').trim().toLowerCase().replace(/_/g, '-')));
-}
-
-function _loadedEmailsHaveVisibleTags() {
- return (state._libEmails || []).some(em =>
- _visibleEmailTagsForRender(em).length > 0 || !!em?.is_spam_verdict
- );
-}
-
-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();
-}
-
-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.
-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;
-}
-
-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;
- }
-}
-
-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);
- });
-}
-
-function _recipientMetaToggleHtml(data = {}) {
- const ccCount = _splitRecipientList(data.cc || '').length;
- if (ccCount) {
- return ``;
- }
- if (!data.to) return '';
- return ``;
-}
-
-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;
-}
-
-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;
-}
-
-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);
- });
-}
-
-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);
-}
-
-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');
- }
-}
-
-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;
- }
- };
-}
-
-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() : '';
-}
-
-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');
- });
- });
-}
-
-function _acct() {
- return state._libAccountId ? `&account_id=${encodeURIComponent(state._libAccountId)}` : '';
-}
-
-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();
-}
-
-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 _unsubscribeMethodLabel(method) {
- if (!method) return 'No unsubscribe method';
- if (method.kind === 'mailto') return 'Request Unsubscribe';
- if (method.kind === 'url') return 'Link Unsubscribe';
- return method.target || method.kind || 'Unsubscribe';
-}
-
-function _unsubscribeFolderLabel(folder) {
- const value = String(folder || 'INBOX').trim();
- return value.toUpperCase() === 'INBOX' ? 'Inbox' : value;
-}
-
-function _setUnsubButtonBusy(btn, label) {
- if (!btn) return null;
- const previous = btn.innerHTML;
- const previousDisplay = btn.style.display;
- const previousAlignItems = btn.style.alignItems;
- const previousJustifyContent = btn.style.justifyContent;
- const previousGap = btn.style.gap;
- const previousWhiteSpace = btn.style.whiteSpace;
- btn.disabled = true;
- btn.style.display = 'inline-flex';
- btn.style.alignItems = 'center';
- btn.style.justifyContent = 'center';
- btn.style.gap = '5px';
- btn.style.whiteSpace = 'nowrap';
- btn.innerHTML = '';
- const sp = spinnerModule.createWhirlpool(14);
- sp.element.style.position = 'relative';
- sp.element.style.top = '-2px';
- sp.element.style.flexShrink = '0';
- sp.element.style.margin = '0';
- sp.element.style.display = 'inline-flex';
- btn.appendChild(sp.element);
- const text = document.createElement('span');
- text.textContent = label || 'Working';
- text.className = 'email-unsub-busy-label';
- text.style.whiteSpace = 'nowrap';
- text.style.display = 'inline-block';
- btn.appendChild(text);
- const restore = () => {
- btn.disabled = false;
- btn.innerHTML = previous;
- btn.style.display = previousDisplay;
- btn.style.alignItems = previousAlignItems;
- btn.style.justifyContent = previousJustifyContent;
- btn.style.gap = previousGap;
- btn.style.whiteSpace = previousWhiteSpace;
- };
- restore.setLabel = (next) => { text.textContent = next || label || 'Working'; };
- return restore;
-}
-
-const _UNSUB_EMAIL_ICON = '';
-const _UNSUB_CHECK_ICON = '';
-const _UNSUB_CLOSE_ICON = '';
-const _UNSUB_AGENT_ICON = '';
-const _UNSUB_TRASH_ICON = '';
-const _UNSUB_REFRESH_ICON = '';
-const _UNSUB_EXTERNAL_ICON = '';
-
-function _setUnsubStatusBusy(statusEl, label) {
- if (!statusEl) return null;
- statusEl.classList.remove('is-error');
- statusEl.classList.add('is-busy');
- statusEl.innerHTML = '';
- statusEl.style.display = 'inline-flex';
- statusEl.style.alignItems = 'center';
- statusEl.style.gap = '6px';
- statusEl.style.justifyContent = 'flex-end';
- statusEl.style.width = '100%';
- const text = document.createElement('span');
- text.textContent = label || 'Scanning…';
- statusEl.appendChild(text);
- const sp = spinnerModule.createWhirlpool(14);
- sp.element.style.position = 'relative';
- sp.element.style.top = '0';
- sp.element.style.flexShrink = '0';
- sp.element.style.margin = '0';
- sp.element.style.display = 'inline-flex';
- statusEl.appendChild(sp.element);
- return (next) => {
- statusEl.style.display = '';
- statusEl.style.alignItems = '';
- statusEl.style.gap = '';
- statusEl.style.justifyContent = '';
- statusEl.style.width = '';
- statusEl.classList.remove('is-busy');
- statusEl.textContent = next || '';
- };
-}
-
-function _askAgentToUnsubscribe(candidate) {
- const methods = Array.isArray(candidate?.methods) ? candidate.methods : [];
- const recommended = candidate?.recommended_method;
- const method = (recommended && typeof recommended === 'object')
- ? recommended
- : methods.find(m => m.kind === 'url') || methods[0] || null;
- const url = method?.kind === 'url' ? method.target : '';
- const methodIndex = Math.max(0, methods.findIndex(m => (
- m === method || (m?.kind === method?.kind && m?.target === method?.target)
- )));
- const uid = candidate?.uid || '';
- const reviewedUids = _unsubscribeCandidateUids(candidate);
- const folder = candidate?.folder || state._libFolder || 'INBOX';
- const account = state._libAccountId || '';
- const prompt = url
- ? `The user explicitly clicked Agent Unsubscribe for this reviewed email. Use the private_browser tool to open this exact unsubscribe URL and complete only the unsubscribe flow. Do not use web_search, web_fetch, bash, or scan_email_unsubscribes. After the page confirms a successful unsubscribe, use bulk_email action=delete on these exact reviewed UID(s) so they are not rediscovered. If the page is ambiguous or unsubscribe fails, stop and report that clearly so the user can choose Spam + delete; do not delete a different message.\n\nEmail UID(s): ${reviewedUids.join(', ') || uid}\nFolder: ${folder}\nAccount: ${account || '(default)'}\nExact unsubscribe URL: ${url}`
- : `The user explicitly clicked Agent Unsubscribe for this reviewed email. Use unsubscribe_email directly for the already-reviewed candidate below. Do not call scan_email_unsubscribes again.\n\nEmail UID: ${uid}\nFolder: ${folder}\nAccount: ${account || '(default)'}\nReviewed method_index: ${methodIndex}`;
- return _submitAgentUnsubscribePrompt(prompt);
-}
-
-function _submitAgentUnsubscribePrompt(prompt) {
- const input = document.getElementById('message') || document.getElementById('message-input');
- const form = document.getElementById('chat-form');
- if (!input || !form) {
- showToast?.('Chat composer not found');
- return false;
- }
- input.value = prompt;
- input.dispatchEvent(new Event('input', { bubbles: true }));
- input.focus();
- // Match the working slash-command path. The app-level submit guard can
- // still be settling when this is called from a modal button.
- setTimeout(() => {
- if (!document.body.contains(input) || !document.body.contains(form)) return;
- try {
- form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
- } catch (err) {
- console.error('agent unsubscribe submit failed', err);
- showToast?.('Could not start agent unsubscribe');
- }
- }, 350);
- return true;
-}
-
-function _agentUnsubscribeGroups(candidates) {
- const groups = new Map();
- (candidates || []).forEach(candidate => {
- const urlMethod = (candidate?.methods || []).find(method => method?.kind === 'url');
- const sender = String(candidate?.from_address || '').trim();
- const key = sender ? `sender:${sender.toLowerCase()}` : `uid:${_unsubscribeCandidateUids(candidate).join(',')}`;
- let group = groups.get(key);
- if (!group) {
- group = {
- subject: String(candidate?.subject || '(no subject)'),
- sender,
- uids: [],
- url: String(urlMethod?.target || ''),
- subjects: [],
- };
- groups.set(key, group);
- }
- _unsubscribeCandidateUids(candidate).forEach(uid => {
- if (!group.uids.includes(uid)) group.uids.push(uid);
- });
- const subject = String(candidate?.subject || '').trim();
- if (subject && !group.subjects.includes(subject)) group.subjects.push(subject);
- if (!group.url && urlMethod?.target) group.url = String(urlMethod.target);
- });
- return Array.from(groups.values());
-}
-
-function _askAgentToUnsubscribeAll(candidates) {
- const reviewed = _agentUnsubscribeGroups(candidates)
- .filter(item => item.uids.length || item.url);
- if (!reviewed.length) {
- showToast?.('No remaining unsubscribe candidates');
- return false;
- }
- const account = state._libAccountId || '';
- const lines = reviewed.map((item, index) => (
- `${index + 1}. ${item.sender || '(unknown sender)'} | ${item.subjects.slice(0, 3).join(' / ') || item.subject} | UID(s): ${item.uids.join(', ') || '(none)'} | URL: ${item.url || '(none)'}`
- )).join('\n');
- const prompt = `The user explicitly chose Agent Unsubscribe All for these already-reviewed unsubscribe candidates. They are grouped by sender: open at most one unsubscribe flow per sender and never repeat an unsubscribe attempt for the same sender. Use the private_browser tool for each exact unsubscribe URL. Do not use web_search, web_fetch, bash, or scan_email_unsubscribes. If a page is ambiguous or unsubscribe fails, skip that sender and report it. After a successful unsubscribe, use bulk_email action=delete only for that sender group's exact reviewed UID(s), so those messages are not rediscovered. Do not delete any other messages. Report successful, skipped, and failed senders clearly.\n\nFolder: ${state._libFolder || 'INBOX'}\nAccount: ${account || '(default)'}\n\nReviewed sender groups (${reviewed.length}):\n${lines}`;
- return _submitAgentUnsubscribePrompt(prompt);
-}
-
-function _unsubscribeCandidateUids(candidate) {
- const out = [];
- const seen = new Set();
- const add = (uid) => {
- const val = String(uid || '').trim();
- if (!val || seen.has(val)) return;
- seen.add(val);
- out.push(val);
- };
- add(candidate?.uid);
- (candidate?.duplicate_uids || []).forEach(add);
- return out;
-}
-
-function _unsubscribeHandledStorageKey() {
- return `odysseus.email.unsubscribeHandled.${String(state._libAccountId || 'default')}`;
-}
-
-function _unsubscribeScanStorageKey() {
- const account = String(state._libAccountId || 'default');
- const folder = String(state._libFolder || 'INBOX');
- return `odysseus.email.unsubscribeScan.v2.${account}.${folder}`;
-}
-
-function _readUnsubscribeScanCache() {
- try {
- const parsed = JSON.parse(localStorage.getItem(_unsubscribeScanStorageKey()) || 'null');
- return parsed && Array.isArray(parsed.candidates) ? parsed : null;
- } catch (_) {
- return null;
- }
-}
-
-function _writeUnsubscribeScanCache(data, scanLimit) {
- if (!data || !Array.isArray(data.candidates)) return;
- try {
- localStorage.setItem(_unsubscribeScanStorageKey(), JSON.stringify({
- candidates: data.candidates,
- scanned: Number(data.scanned || 0),
- raw_total: Number(data.raw_total || data.candidates.length || 0),
- scanLimit: scanLimit == null ? 180 : Number(scanLimit),
- scanMode: Number(scanLimit || 0) > 0 ? 'limited' : 'whole-folder',
- savedAt: Date.now(),
- }));
- } catch (_) {}
-}
-
-function _readHandledUnsubscribeState() {
- try {
- const parsed = JSON.parse(localStorage.getItem(_unsubscribeHandledStorageKey()) || '{}');
- return {
- uids: new Set(Array.isArray(parsed.uids) ? parsed.uids.map(String) : []),
- senders: new Set(Array.isArray(parsed.senders) ? parsed.senders.map(v => String(v).toLowerCase()) : []),
- ignoredUids: new Set(Array.isArray(parsed.ignoredUids) ? parsed.ignoredUids.map(String) : []),
- ignoredSenders: new Set(Array.isArray(parsed.ignoredSenders) ? parsed.ignoredSenders.map(v => String(v).toLowerCase()) : []),
- };
- } catch (_) {
- return { uids: new Set(), senders: new Set(), ignoredUids: new Set(), ignoredSenders: new Set() };
- }
-}
-
-function _rememberUnsubscribeHandled(candidates) {
- const current = _readHandledUnsubscribeState();
- (candidates || []).forEach(candidate => {
- _unsubscribeCandidateUids(candidate).forEach(uid => current.uids.add(String(uid)));
- const sender = String(candidate?.from_address || '').trim().toLowerCase();
- if (sender) current.senders.add(sender);
- });
- try {
- localStorage.setItem(_unsubscribeHandledStorageKey(), JSON.stringify({
- uids: Array.from(current.uids).slice(-2000),
- senders: Array.from(current.senders).slice(-500),
- ignoredUids: Array.from(current.ignoredUids).slice(-2000),
- ignoredSenders: Array.from(current.ignoredSenders).slice(-500),
- }));
- } catch (_) {}
-}
-
-function _rememberUnsubscribeIgnored(candidate) {
- const current = _readHandledUnsubscribeState();
- _unsubscribeCandidateUids(candidate).forEach(uid => current.ignoredUids.add(String(uid)));
- const sender = String(candidate?.from_address || '').trim().toLowerCase();
- if (sender) current.ignoredSenders.add(sender);
- try {
- localStorage.setItem(_unsubscribeHandledStorageKey(), JSON.stringify({
- uids: Array.from(current.uids).slice(-2000),
- senders: Array.from(current.senders).slice(-500),
- ignoredUids: Array.from(current.ignoredUids).slice(-2000),
- ignoredSenders: Array.from(current.ignoredSenders).slice(-500),
- }));
- } catch (_) {}
-}
-
-function _filterHandledUnsubscribeCandidates(candidates) {
- const handled = _readHandledUnsubscribeState();
- return (candidates || []).flatMap(candidate => {
- const sender = String(candidate?.from_address || '').trim().toLowerCase();
- if (sender && (handled.senders.has(sender) || handled.ignoredSenders.has(sender))) return [];
- const remainingUids = _unsubscribeCandidateUids(candidate)
- .filter(uid => !handled.uids.has(String(uid)) && !handled.ignoredUids.has(String(uid)));
- if (!remainingUids.length) return [];
- if (remainingUids.length === _unsubscribeCandidateUids(candidate).length) return [candidate];
- return [{
- ...candidate,
- uid: remainingUids[0],
- duplicate_uids: remainingUids.slice(1),
- duplicate_count: remainingUids.length,
- }];
- });
-}
-
-function _dedupeUnsubscribeCandidatesForDisplay(candidates) {
- const out = [];
- const seen = new Map();
- (candidates || []).forEach(c => {
- const method = c?.recommended_method || {};
- const urlMethod = (c?.methods || []).find(m => m?.kind === 'url');
- // One sender address should produce one review action, even when every
- // message carries a different tokenized unsubscribe URL.
- const sender = String(c?.from_address || '').trim().toLowerCase();
- const key = sender || String(c?.list_id || urlMethod?.target || method.target || c?.uid || '').trim().toLowerCase();
- if (!key || !seen.has(key)) {
- if (key) seen.set(key, c);
- out.push(c);
- return;
- }
- const existing = seen.get(key);
- existing.duplicate_count = Number(existing.duplicate_count || 1) + Number(c.duplicate_count || 1);
- const uidSet = new Set(_unsubscribeCandidateUids(existing));
- _unsubscribeCandidateUids(c).forEach(uid => uidSet.add(uid));
- existing.duplicate_uids = Array.from(uidSet);
- });
- return out;
-}
-
-function _markUnsubscribeCardDone(modal, idx, label = 'Unsubscribed') {
- const card = modal?.querySelector?.(`.email-unsub-card[data-idx="${idx}"]`);
- if (!card) return;
- card.classList.add('is-unsubscribed');
- if (!card.querySelector('.email-unsub-done-badge')) {
- const badgeHost = card.querySelector('.email-unsub-subject-line') || card.querySelector('.email-unsub-card-top');
- badgeHost?.insertAdjacentHTML('beforeend', `${_UNSUB_CHECK_ICON}${_esc(label)}`);
- }
- card.querySelectorAll('.email-unsub-link-btn, .email-unsub-agent-btn, .email-unsub-send-btn, .email-unsub-done-btn, .email-unsub-ignore-btn').forEach(el => {
- if (el.tagName === 'A') {
- el.setAttribute('aria-disabled', 'true');
- el.style.pointerEvents = 'none';
- } else {
- el.disabled = true;
- }
- el.style.opacity = '0.55';
- });
-}
-
-function _agentToolArgs(data) {
- const candidates = [data?.args, data?.tool_args, data?.command];
- for (const value of candidates) {
- if (value && typeof value === 'object' && !Array.isArray(value)) return value;
- if (typeof value !== 'string' || !value.trim()) continue;
- try {
- const parsed = JSON.parse(value);
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
- } catch (_) {
- // Some tool cards prefix the JSON with a display label. Recover the
- // object so email mutations still reconcile from the streamed event.
- const start = value.indexOf('{');
- const end = value.lastIndexOf('}');
- if (start >= 0 && end > start) {
- try {
- const parsed = JSON.parse(value.slice(start, end + 1));
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
- } catch (_) {}
- }
- }
- }
- return {};
-}
-
-function _agentBrowserUnsubscribeSucceeded(data) {
- const tool = String(data?.tool || '').toLowerCase();
- if (!tool.includes('private_browser')) return false;
- const output = String(data?.output || '');
- if (/\b(?:not|never|failed|unable|could not|cannot|can't)\b[^.!?\n]{0,50}\bunsubscrib/i.test(output)) {
- return false;
- }
- return /\b(?:already\s+unsubscribed|you(?:'re|\s+are)\s+(?:now\s+)?unsubscribed|successfully\s+unsubscribed|unsubscribed\s+(?:successfully|from)|unsubscription\s+(?:successful|complete)|removed\s+from\s+(?:the\s+)?(?:mailing|email)\s+list)\b/i.test(output);
-}
-
-function _agentBrowserCandidate(run, args) {
- const url = String(args?.url || '').trim();
- if (url && Array.isArray(run?.candidates)) {
- const matched = run.candidates.find(candidate => (
- (candidate?.methods || []).some(method => method?.kind === 'url' && String(method.target || '') === url)
- ));
- if (matched) return matched;
- }
- return run?.activeCandidate || null;
-}
-
-function _trackAgentBrowserUnsubscribe(data) {
- const modal = document.getElementById('email-unsubscribe-review-modal');
- const run = modal?._agentUnsubscribeRun;
- if (!run) return;
- const args = _agentToolArgs(data);
- const action = String(args.action || data?.action || '').toLowerCase();
- const candidate = _agentBrowserCandidate(run, args);
- if (action === 'open' && candidate) run.activeCandidate = candidate;
- if (!candidate || !_agentBrowserUnsubscribeSucceeded(data)) return;
- run.cleanupInFlight ||= new Set();
- if (run.cleanupInFlight.has(candidate) || run.completed.has(candidate)) return;
- run.cleanupInFlight.add(candidate);
- _deleteAfterUnsubscribe([candidate]).then(result => {
- if (Number(result?.failed || 0) > 0 || Number(result?.changed || 0) <= 0) {
- run.failed ||= new Set();
- run.failed.add(candidate);
- run.restoreBusy?.setLabel?.(`Unsubscribed, delete failed (${run.completed.size} / ${run.total})`);
- modal.querySelector('.email-unsub-status')?.replaceChildren(document.createTextNode(
- 'Unsubscribe succeeded, but the matching email could not be moved to Trash. Review it below.',
- ));
- return;
- }
- const idx = run.candidates.indexOf(candidate);
- run.completed.add(candidate);
- _markUnsubscribeCardDone(modal, idx, 'Unsubscribed');
- run.restoreBusy?.setLabel?.(`Unsubscribed ${run.completed.size} / ${run.total}`);
- }).catch(error => {
- run.failed ||= new Set();
- run.failed.add(candidate);
- run.restoreBusy?.setLabel?.(`Unsubscribed, delete failed (${run.completed.size} / ${run.total})`);
- modal.querySelector('.email-unsub-status')?.replaceChildren(document.createTextNode(
- `Unsubscribe succeeded, but delete failed: ${error?.message || 'mail server error'}`,
- ));
- });
-}
-
-function _agentDeletedEmailUids(data) {
- const tool = String(data?.tool || '').toLowerCase();
- const args = _agentToolArgs(data);
- const action = String(args.action || data.action || '').toLowerCase();
- const output = String(data?.output || '').toLowerCase();
- const failed = /\b(?:failed|error|could not|unable|not found)\b/.test(output);
- const isBulkDelete = tool.includes('bulk_email')
- && ['delete', 'trash', 'move_to_trash'].includes(action);
- const isSingleDelete = tool.endsWith('delete_email')
- && !tool.includes('bulk_email');
- const isSuccessfulMailtoUnsubscribe = tool.includes('unsubscribe_email')
- && !failed
- && /(?:source email moved to trash|deleted|unsubscribe email sent)/.test(output);
- if (!isBulkDelete && !isSingleDelete && !isSuccessfulMailtoUnsubscribe) return [];
- if ((isBulkDelete || isSingleDelete) && failed) return [];
- const values = [
- ...(Array.isArray(args.uids) ? args.uids : []),
- ...(Array.isArray(data.deleted_uids) ? data.deleted_uids : []),
- ...(Array.isArray(data.cleaned_uids) ? data.cleaned_uids : []),
- ];
- if (args.uid != null) values.push(args.uid);
- if (data.uid != null) values.push(data.uid);
- return Array.from(new Set(values.map(uid => String(uid || '').trim()).filter(Boolean)));
-}
-
-function _handleAgentEmailToolOutput(event) {
- const data = event?.detail || {};
- _trackAgentBrowserUnsubscribe(data);
- const ok = data.exit_code == null ? data.success !== false : data.exit_code === 0;
- if (!ok) return;
- const deletedUids = new Set(_agentDeletedEmailUids(data));
- if (!deletedUids.size) return;
-
- state._libEmails = (state._libEmails || []).filter(email => !deletedUids.has(String(email?.uid || '')));
- try { _libCacheWriteBack(); } catch (_) {}
- try { _renderGrid(); } catch (_) {}
-
- const modal = document.getElementById('email-unsubscribe-review-modal');
- const completed = [];
- (modal?._unsubscribeCandidates || []).forEach((candidate, idx) => {
- const remaining = _unsubscribeCandidateUids(candidate)
- .filter(uid => !deletedUids.has(String(uid)));
- if (remaining.length === _unsubscribeCandidateUids(candidate).length) return;
- if (!remaining.length) {
- completed.push(candidate);
- _markUnsubscribeCardDone(modal, idx, 'Agent done');
- return;
- }
- candidate.uid = remaining[0];
- candidate.duplicate_uids = remaining.slice(1);
- candidate.duplicate_count = remaining.length;
- });
- const run = modal?._agentUnsubscribeRun;
- if (run && completed.length) {
- completed.forEach(candidate => run.completed.add(candidate));
- run.restoreBusy?.setLabel?.(`Unsubscribed ${run.completed.size} / ${run.total}`);
- }
- if (completed.length) _rememberUnsubscribeHandled(completed);
- const visible = (modal?._unsubscribeCandidates || []).filter(candidate => (
- _unsubscribeCandidateUids(candidate).some(uid => !deletedUids.has(String(uid)))
- ));
- if (modal && !visible.length) {
- if (run) {
- run.restoreBusy?.setLabel?.(`Unsubscribed ${run.total} / ${run.total}`);
- run.restoreBusy = null;
- modal._agentUnsubscribeRun = null;
- }
- modal.querySelector('.email-unsub-list')?.style.setProperty('display', 'none');
- modal.querySelector('.email-unsub-followup')?.remove();
- modal.querySelector('.email-unsub-status')?.replaceChildren(document.createTextNode('Agent unsubscribe cleanup completed.'));
- }
-}
-
-window.addEventListener('odysseus:agent-tool-output', _handleAgentEmailToolOutput);
-
-function _handleAgentUnsubscribeTerminal(event) {
- const modal = document.getElementById('email-unsubscribe-review-modal');
- const run = modal?._agentUnsubscribeRun;
- if (!run) return;
- const detail = event?.detail?.data || {};
- (Array.isArray(detail.tool_events) ? detail.tool_events : []).forEach(_trackAgentBrowserUnsubscribe);
- const completed = run.completed.size;
- const remaining = Math.max(0, run.total - completed);
- const failure = detail.failure || (detail.failed ? { message: 'The agent run failed before all unsubscribe flows completed.' } : null);
- run.restoreBusy?.();
- modal._agentUnsubscribeRun = null;
- modal.querySelector('.email-unsub-status')?.replaceChildren(document.createTextNode(
- failure
- ? `Agent unsubscribe failed: ${completed} / ${run.total} completed. ${remaining} still need review. ${failure.message || ''}`.trim()
- : remaining
- ? `Agent unsubscribe finished: ${completed} / ${run.total} completed. ${remaining} still need review.`
- : `Agent unsubscribe finished: ${completed} / ${run.total} completed.`,
- ));
- // A tool result can arrive just after the final browser step. Re-fetch once
- // the run is over so an old mailbox page or server-side list cache cannot
- // leave deleted messages visible until the next manual refresh.
- if (!failure && typeof refreshEmailLibrary === 'function') {
- setTimeout(() => refreshEmailLibrary().catch(() => {}), 0);
- }
-}
-
-window.addEventListener('odysseus:agent-terminal', _handleAgentUnsubscribeTerminal);
-
-async function _runUnsubscribeCleanup(modal, candidates, action, btn) {
- const uids = [];
- const seen = new Set();
- (candidates || []).forEach(c => {
- _unsubscribeCandidateUids(c).forEach(uid => {
- if (seen.has(uid)) return;
- seen.add(uid);
- uids.push(uid);
- });
- });
- if (!uids.length) {
- showToast?.('No emails to update');
- return;
- }
- const busyLabel = action === 'junk'
- ? 'Marking spam'
- : action === 'junk_delete' ? 'Spam + delete' : 'Deleting';
- const restoreBusy = _setUnsubButtonBusy(btn, busyLabel);
- try {
- const r = await fetch(emailApiUrl('/api/email/unsubscribe/cleanup', {
- account_id: state._libAccountId || undefined,
- }), {
- method: 'POST',
- credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- action,
- uids,
- folder: state._libFolder || 'INBOX',
- account_id: state._libAccountId || '',
- }),
- });
- const d = await r.json().catch(() => ({}));
- if (!d.success) throw new Error(d.error || 'Cleanup failed');
- const removed = new Set(uids.map(uid => String(uid)));
- state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid)));
- try { _libCacheWriteBack(); } catch (_) {}
- try { _renderGrid(); } catch (_) {}
- _rememberUnsubscribeHandled(candidates);
- modal.querySelector('.email-unsub-followup')?.remove();
- showToast?.(action === 'junk'
- ? `Marked ${d.changed || 0} email${Number(d.changed || 0) === 1 ? '' : 's'} as spam`
- : action === 'junk_delete'
- ? `Marked ${d.changed || 0} email${Number(d.changed || 0) === 1 ? '' : 's'} as spam and deleted ${d.changed || 0}`
- : `Deleted ${d.changed || 0} email${Number(d.changed || 0) === 1 ? '' : 's'}`);
- } catch (err) {
- console.error(err);
- showToast?.(err?.message || 'Cleanup failed');
- } finally {
- restoreBusy?.();
- }
-}
-
-async function _deleteAfterUnsubscribe(candidates) {
- const groups = new Map();
- (candidates || []).forEach(c => {
- const sender = String(c?.from_address || '').trim();
- const key = sender.toLowerCase() || `uids:${_unsubscribeCandidateUids(c).join(',')}`;
- if (!groups.has(key)) groups.set(key, { sender, uids: [] });
- const group = groups.get(key);
- const seen = new Set(group.uids);
- _unsubscribeCandidateUids(c).forEach(uid => {
- if (!seen.has(uid)) {
- seen.add(uid);
- group.uids.push(uid);
- }
- });
- });
- if (!groups.size) return { success: true, changed: 0, failed: 0, cleaned_uids: [] };
- const result = { success: true, changed: 0, failed: 0, cleaned_uids: [] };
- for (const group of groups.values()) {
- const controller = new AbortController();
- const timeout = setTimeout(() => controller.abort(), 30000);
- let r;
- try {
- r = await fetch(emailApiUrl('/api/email/unsubscribe/cleanup', {
- account_id: state._libAccountId || undefined,
- }), {
- method: 'POST',
- credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json' },
- signal: controller.signal,
- body: JSON.stringify({
- action: 'delete',
- scope: group.sender ? 'sender_unsubscribe' : '',
- sender: group.sender,
- uids: group.sender ? [] : group.uids,
- folder: state._libFolder || 'INBOX',
- account_id: state._libAccountId || '',
- }),
- });
- } catch (err) {
- if (err?.name === 'AbortError') throw new Error('Deleting timed out; the mail server did not respond');
- throw err;
- } finally {
- clearTimeout(timeout);
- }
- const d = await r.json().catch(() => ({}));
- if (!d.success) throw new Error(d.error || 'Could not move unsubscribed emails to Trash');
- result.changed += Number(d.changed || 0);
- result.failed += Number(d.failed || 0);
- result.cleaned_uids.push(...(d.cleaned_uids || []));
- }
- const removed = new Set(result.cleaned_uids.map(uid => String(uid)));
- state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid)));
- try { _libCacheWriteBack(); } catch (_) {}
- try { _renderGrid(); } catch (_) {}
- _rememberUnsubscribeHandled(candidates);
- return result;
-}
-
-function _showUnsubscribeCleanupPrompt(modal, candidates, options = {}) {
- if (!modal || !Array.isArray(candidates) || !candidates.length) return;
- modal.querySelector('.email-unsub-followup')?.remove();
- const count = candidates.reduce((sum, c) => sum + _unsubscribeCandidateUids(c).length, 0);
- const heading = options.heading || `Done. Mark all ${count} of these email${count === 1 ? '' : 's'} as spam?`;
- const rows = candidates.map(c => {
- const uids = _unsubscribeCandidateUids(c);
- return `
-
-
-
${_esc(c.subject || '(no subject)')}
-
${_esc(c.from_name || c.from_address || '')} · ${_esc(c.from_address || '')}
-
- ${uids.length > 1 ? `
x${uids.length}` : ''}
-
`;
- }).join('');
- const box = document.createElement('div');
- box.className = 'email-unsub-followup';
- box.style.cssText = 'border:1px solid color-mix(in srgb,var(--accent,var(--red)) 35%,var(--border));border-radius:8px;padding:10px;background:color-mix(in srgb,var(--accent,var(--red)) 8%,transparent);display:flex;flex-direction:column;gap:8px;';
- box.innerHTML = `
-
-
${_esc(heading)}
-
-
-
-
-
- ${options.scanFurther ? '' : ''}
-
-
- ${rows}
`;
- const body = modal.querySelector('.modal-body');
- const list = modal.querySelector('.email-unsub-list');
- body?.insertBefore(box, list || null);
- box.querySelector('.email-unsub-clean-spam')?.addEventListener('click', (e) => {
- _runUnsubscribeCleanup(modal, candidates, 'junk', e.currentTarget);
- });
- box.querySelector('.email-unsub-clean-junk-delete')?.addEventListener('click', async (e) => {
- const ok = await styledConfirm(`Mark ${count} reviewed email${count === 1 ? '' : 's'} as spam and delete ${count === 1 ? 'it' : 'them'}?`, {
- confirmText: 'Spam + delete',
- cancelText: 'Cancel',
- danger: true,
- });
- if (ok) _runUnsubscribeCleanup(modal, candidates, 'junk_delete', e.currentTarget);
- });
- box.querySelector('.email-unsub-clean-delete')?.addEventListener('click', async (e) => {
- const ok = await styledConfirm(`Delete ${count} reviewed email${count === 1 ? '' : 's'}?`, {
- confirmText: 'Delete',
- cancelText: 'Cancel',
- danger: true,
- });
- if (ok) _runUnsubscribeCleanup(modal, candidates, 'delete', e.currentTarget);
- });
- box.querySelector('.email-unsub-clean-keep')?.addEventListener('click', () => box.remove());
- box.querySelector('.email-unsub-scan-further')?.addEventListener('click', () => {
- const nextAnchor = options.scanAnchor;
- modal.remove();
- if (nextAnchor) setTimeout(() => _openUnsubscribeReviewModal(nextAnchor), 0);
- });
-}
-
-function _showUnsubscribeScanFurtherPrompt(modal, anchor, heading) {
- if (!modal || !anchor) return;
- modal.querySelector('.email-unsub-followup')?.remove();
- const box = document.createElement('div');
- box.className = 'email-unsub-followup';
- box.style.cssText = 'border:1px solid color-mix(in srgb,var(--accent,var(--red)) 35%,var(--border));border-radius:8px;padding:10px;background:color-mix(in srgb,var(--accent,var(--red)) 8%,transparent);display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;';
- box.innerHTML = `${_esc(heading)}`;
- const body = modal.querySelector('.modal-body');
- const list = modal.querySelector('.email-unsub-list');
- body?.insertBefore(box, list || null);
- box.querySelector('.email-unsub-scan-further')?.addEventListener('click', () => {
- modal.remove();
- setTimeout(() => _openUnsubscribeReviewModal(anchor), 0);
- });
-}
-
-function _showUnsubscribeRemainingPrompt(modal, candidates) {
- if (!modal || !Array.isArray(candidates) || !candidates.length) return;
- modal.querySelector('.email-unsub-followup')?.remove();
- const remaining = new Set(candidates);
- modal.querySelectorAll('.email-unsub-card').forEach(card => {
- const idx = Number(card.dataset.idx || -1);
- card.hidden = !remaining.has(modal._unsubscribeCandidates?.[idx]);
- });
- const box = document.createElement('div');
- box.className = 'email-unsub-followup email-unsub-remaining-followup';
- box.style.cssText = 'border:1px solid color-mix(in srgb,var(--accent,var(--red)) 35%,var(--border));border-radius:8px;padding:10px;background:color-mix(in srgb,var(--accent,var(--red)) 8%,transparent);display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;';
- box.innerHTML = `
- ${candidates.length} unsubscribe${candidates.length === 1 ? '' : 's'} still need review
-
-
-
- `;
- const body = modal.querySelector('.modal-body');
- const list = modal.querySelector('.email-unsub-list');
- body?.insertBefore(box, list || null);
- box.querySelector('.email-unsub-clean-remaining-btn')?.addEventListener('click', async (event) => {
- const count = candidates.reduce((sum, candidate) => sum + _unsubscribeCandidateUids(candidate).length, 0);
- const ok = await styledConfirm(
- `Mark ${count} remaining email${count === 1 ? '' : 's'} as spam and delete ${count === 1 ? 'it' : 'them'}?`,
- { confirmText: 'Spam + delete', cancelText: 'Cancel', danger: true },
- );
- if (ok) _runUnsubscribeCleanup(modal, candidates, 'junk_delete', event.currentTarget);
- });
- box.querySelector('.email-unsub-agent-all-btn')?.addEventListener('click', (event) => {
- const button = event.currentTarget;
- if (modal._agentUnsubscribeRun) return;
- const total = candidates.length;
- const restoreBusy = _setUnsubButtonBusy(button, `Completed 0 / ${total}`);
- modal._agentUnsubscribeRun = {
- total,
- candidates: candidates.slice(),
- completed: new Set(),
- cleanupInFlight: new Set(),
- restoreBusy,
- };
- if (!_askAgentToUnsubscribeAll(candidates)) {
- restoreBusy?.();
- modal._agentUnsubscribeRun = null;
- }
- });
-}
-
-async function _openUnsubscribeReviewModal(anchor, options = {}) {
- const existing = document.getElementById('email-unsubscribe-review-modal');
- if (existing) {
- existing.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' });
- existing.querySelector('.email-unsub-status')?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' });
- return;
- }
- const modal = document.createElement('div');
- const scanFolderLabel = _unsubscribeFolderLabel(state._libFolder);
- modal.className = 'modal';
- modal.id = 'email-unsubscribe-review-modal';
- const settingsPage = anchor?.closest?.('#email-lib-settings-page, .email-settings-page-host');
- const inlineHost = settingsPage?.querySelector?.('.email-settings-cleanup-section');
- const inlineMode = !!inlineHost;
- if (inlineMode) {
- modal.className = 'email-unsubscribe-inline-panel';
- modal.style.cssText = 'display:none;margin-top:10px;';
- modal.innerHTML = `
-
-
-
-
-
-
-
-
-
`;
- inlineHost.appendChild(modal);
- } else {
- modal.style.display = 'block';
- modal.innerHTML = `
-
-
-
-
Scanning ${_esc(scanFolderLabel)} headers…
-
-
-
-
-
-
-
-
-
`;
- document.body.appendChild(modal);
- }
- const close = () => modal.remove();
- if (!inlineMode) modal.addEventListener('click', (e) => { if (e.target === modal) close(); });
- const statusEl = inlineMode
- ? inlineHost.querySelector('.email-settings-clean-status')
- : modal.querySelector('.email-unsub-status');
- const panelStatusEl = modal.querySelector('.email-unsub-panel-status');
- const listEl = modal.querySelector('.email-unsub-list');
- const cachedScan = options.forceRescan ? null : _readUnsubscribeScanCache();
- const usingCachedScan = !!cachedScan;
- let finishScanStatus = _setUnsubStatusBusy(
- statusEl,
- usingCachedScan ? 'Loading last unsubscribe scan…' : `Scanning ${scanFolderLabel} headers…`,
- );
- const showFinalStatus = (message, isError = false) => {
- const actionsEl = modal.querySelector('.email-unsub-actions');
- if (actionsEl) actionsEl.style.display = 'flex';
- if (inlineMode && panelStatusEl) {
- finishScanStatus?.('');
- panelStatusEl.classList.toggle('is-error', isError);
- panelStatusEl.textContent = message || '';
- modal.style.display = 'block';
- return;
- }
- statusEl?.classList.toggle('is-error', isError);
- finishScanStatus?.(message);
- };
- const finishScanError = (message) => {
- showFinalStatus(message || 'Failed to scan email headers', true);
- };
- try {
- const scanUnsubscribeHeaders = (maxScan) => fetch(emailApiUrl('/api/email/unsubscribe/scan', {
- folder: state._libFolder || 'INBOX',
- limit: 500,
- max_scan: maxScan,
- account_id: state._libAccountId || undefined,
- }), { credentials: 'same-origin' });
- const readScanResponse = async (response) => {
- const payload = await response.json().catch(() => ({}));
- if (!response.ok) {
- throw new Error(
- payload?.error || payload?.detail || `Email scan failed (HTTP ${response.status})`,
- );
- }
- return payload;
- };
- // Keep the review request below the app's 45s hard deadline. The old
- // whole-folder request made Gmail scans time out before any cards loaded.
- let scanLimit = 500;
- let res;
- let data = cachedScan;
- if (!data) {
- res = await scanUnsubscribeHeaders(scanLimit);
- data = await readScanResponse(res);
- } else {
- scanLimit = Number(data.scanLimit || scanLimit);
- }
- if (!data || (!data.success && !usingCachedScan)) {
- finishScanError(data?.error || 'Failed to scan email headers');
- return;
- }
- let candidates = _filterHandledUnsubscribeCandidates(
- _dedupeUnsubscribeCandidatesForDisplay(data.candidates || []),
- );
- if (!usingCachedScan && !candidates.length && scanLimit > 0 && Number(data.scanned || 0) >= scanLimit) {
- const searchMore = await styledConfirm(
- `No unsubscribe candidates found in ${scanLimit} recent emails. Search more?`,
- { title: 'Search more?', confirmText: 'Search more', cancelText: 'No' },
- );
- if (searchMore) {
- scanLimit = 0;
- finishScanStatus = _setUnsubStatusBusy(statusEl, `Scanning ${scanLimit} recent ${scanFolderLabel} headers…`);
- res = await scanUnsubscribeHeaders(scanLimit);
- data = await readScanResponse(res);
- if (!data.success) {
- finishScanError(data.error || 'Failed to scan email headers');
- return;
- }
- candidates = _filterHandledUnsubscribeCandidates(
- _dedupeUnsubscribeCandidatesForDisplay(data.candidates || []),
- );
- }
- }
- if (!usingCachedScan) _writeUnsubscribeScanCache(data, scanLimit);
- const rawTotal = Number(data.raw_total || candidates.length || 0);
- const scanPrefix = usingCachedScan ? 'Last scan: ' : '';
- const scanSuffix = usingCachedScan ? ' Rescan for fresh results.' : '';
- const scanScope = scanLimit > 0 ? `${data.scanned || 0} recent emails` : `all ${data.scanned || 0} emails`;
- const finalStatus = candidates.length
- ? `${scanPrefix}Found ${candidates.length} unsubscribe target${candidates.length === 1 ? '' : 's'} from ${scanScope}${rawTotal > candidates.length ? `, collapsed from ${rawTotal} matching emails` : ''}.${scanSuffix} Review before sending unsubscribe.`
- : `${scanPrefix}No unsubscribe candidates found in ${scanScope}.${scanSuffix}`;
- showFinalStatus(finalStatus);
- modal.querySelector('.email-unsub-actions').style.display = 'flex';
- modal.querySelector('.email-unsub-delete-all-btn').style.display = candidates.length ? 'inline-flex' : 'none';
- modal.querySelector('.email-unsub-auto-safe-btn').style.display = candidates.length ? 'inline-flex' : 'none';
- listEl.style.display = candidates.length ? 'flex' : 'none';
- listEl.innerHTML = candidates.map((c, idx) => {
- const method = c.recommended_method || null;
- const reasons = (c.reasons || []).map(r => `${_esc(r)}`).join('');
- const urlMethod = (c.methods || []).find(m => m.kind === 'url');
- const duplicateCount = Number(c.duplicate_count || 1);
- const duplicateBadge = duplicateCount > 1 ? `x${duplicateCount}` : '';
- const deleteLabel = duplicateCount > 1 ? 'Delete all' : 'Delete';
- const deleteTitle = duplicateCount > 1
- ? 'Delete all unsubscribe emails from this sender'
- : 'Delete matching unsubscribe emails from this sender';
- return `
-
-
-
-
-
${_esc(c.subject || '(no subject)')}
-
-
${_esc(c.from_name || c.from_address || '')} · ${_esc(c.from_address || '')}
-
-
-
-
-
- score ${_esc(c.score || 0)}
- ${duplicateBadge}
-
-
-
-
-
${reasons}
-
- ${c.can_execute ? `
` : ''}
- ${urlMethod ? `
` : ''}
- ${urlMethod ? `
${_UNSUB_EXTERNAL_ICON}Link Unsubscribe` : ''}
-
-
-
`;
- }).join('');
- modal._unsubscribeCandidates = candidates;
- listEl.querySelectorAll('.email-unsub-card-toggle').forEach(btn => {
- btn.addEventListener('click', () => {
- const card = btn.closest('.email-unsub-card');
- const details = card?.querySelector('.email-unsub-card-details');
- if (!details) return;
- const isOpen = !details.hidden;
- details.hidden = isOpen;
- card.classList.toggle('is-expanded', !isOpen);
- btn.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
- btn.setAttribute('aria-label', isOpen ? 'Show unsubscribe details' : 'Hide unsubscribe details');
- btn.title = isOpen ? 'Show unsubscribe details' : 'Hide unsubscribe details';
- });
- });
- modal.querySelector('.email-unsub-rescan-btn')?.addEventListener('click', () => {
- modal.remove();
- setTimeout(() => _openUnsubscribeReviewModal(anchor, { forceRescan: true }), 0);
- });
- modal.querySelector('.email-unsub-delete-all-btn')?.addEventListener('click', async (event) => {
- const allCandidates = modal._unsubscribeCandidates || [];
- const count = allCandidates.reduce((sum, candidate) => sum + _unsubscribeCandidateUids(candidate).length, 0);
- if (!count) {
- finishScanStatus?.('No unsubscribe emails to delete.');
- return;
- }
- const ok = await styledConfirm(
- `Delete all ${count} reviewed unsubscribe email${count === 1 ? '' : 's'}?`,
- { confirmText: 'Delete all', cancelText: 'Cancel', danger: true },
- );
- if (!ok) return;
- const restoreBusy = _setUnsubButtonBusy(event.currentTarget, 'Deleting all');
- try {
- const result = await _deleteAfterUnsubscribe(allCandidates);
- if (Number(result?.failed || 0) > 0 || Number(result?.changed || 0) <= 0) {
- throw new Error('Could not move the reviewed emails to Trash');
- }
- allCandidates.forEach((candidate, idx) => _markUnsubscribeCardDone(modal, idx, 'Deleted'));
- modal.querySelector('.email-unsub-list')?.style.setProperty('display', 'none');
- finishScanStatus?.(`Deleted ${result.changed} reviewed email${result.changed === 1 ? '' : 's'}`);
- } catch (error) {
- finishScanStatus?.(error?.message || 'Delete all failed');
- } finally {
- restoreBusy?.();
- }
- });
- modal.querySelector('.email-unsub-auto-safe-btn')?.addEventListener('click', async () => {
- const safeCandidates = (modal._unsubscribeCandidates || [])
- .map((c, idx) => ({ c, idx }))
- .filter(item => item.c && item.c.can_execute);
- if (!safeCandidates.length) {
- finishScanStatus?.(`No automatic unsubscribe actions found. ${modal._unsubscribeCandidates?.length || 0} link${(modal._unsubscribeCandidates?.length || 0) === 1 ? '' : 's'} still need review.`);
- _showUnsubscribeRemainingPrompt(modal, modal._unsubscribeCandidates || []);
- return;
- }
- const ok = await styledConfirm(`Send unsubscribe emails for ${safeCandidates.length} target${safeCandidates.length === 1 ? '' : 's'}?`, {
- confirmText: 'Auto Unsubscribe All',
- cancelText: 'Cancel',
- });
- if (!ok) return;
- const btn = modal.querySelector('.email-unsub-auto-safe-btn');
- const restoreBusy = _setUnsubButtonBusy(btn, `Unsubscribing 0/${safeCandidates.length}`);
- let sent = 0;
- let failed = 0;
- const sentCandidates = [];
- const failedCandidates = [];
- try {
- for (const item of safeCandidates) {
- const c = item.c;
- try {
- restoreBusy?.setLabel?.(`Unsubscribing ${sent + failed + 1}/${safeCandidates.length}`);
- const r = await fetch(emailApiUrl('/api/email/unsubscribe/execute', {
- account_id: state._libAccountId || undefined,
- }), {
- method: 'POST',
- credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- uid: c.uid,
- folder: c.folder || state._libFolder || 'INBOX',
- account_id: state._libAccountId || '',
- method_index: 0,
- move_to_spam: false,
- }),
- });
- const d = await r.json().catch(() => ({}));
- if (!d.success) throw new Error(d.error || 'Unsubscribe failed');
- sent += 1;
- sentCandidates.push(c);
- const rowBtn = modal.querySelector(`.email-unsub-send-btn[data-idx="${item.idx}"]`);
- if (rowBtn) {
- rowBtn.disabled = true;
- rowBtn.innerHTML = `${_UNSUB_CHECK_ICON}Sent`;
- rowBtn.style.display = 'inline-flex';
- rowBtn.style.alignItems = 'center';
- rowBtn.style.gap = '4px';
- }
- _markUnsubscribeCardDone(modal, item.idx);
- } catch (err) {
- failed += 1;
- failedCandidates.push(c);
- console.error(err);
- }
- }
- } finally {
- restoreBusy?.();
- }
- let deleteFailed = false;
- let cleanupResult = { success: true, changed: 0, failed: 0, cleaned_uids: [] };
- if (sentCandidates.length) {
- try {
- cleanupResult = await _deleteAfterUnsubscribe(sentCandidates);
- deleteFailed = Number(cleanupResult.failed || 0) > 0;
- const cleaned = new Set((cleanupResult.cleaned_uids || []).map(uid => String(uid)));
- (modal._unsubscribeCandidates || []).forEach((candidate, candidateIdx) => {
- if (_unsubscribeCandidateUids(candidate).some(uid => cleaned.has(String(uid)))) {
- _markUnsubscribeCardDone(modal, candidateIdx);
- }
- });
- } catch (err) {
- deleteFailed = true;
- console.error(err);
- }
- }
- const handled = new Set();
- sentCandidates.forEach(c => _unsubscribeCandidateUids(c).forEach(uid => handled.add(String(uid))));
- (cleanupResult.cleaned_uids || []).forEach(uid => handled.add(String(uid)));
- const remainingCandidates = (modal._unsubscribeCandidates || []).filter(c => (
- !_unsubscribeCandidateUids(c).some(uid => handled.has(String(uid)))
- ));
- finishScanStatus?.(
- `Auto unsubscribe finished: ${sent} unsubscribed${failed ? `, ${failed} failed` : ''}${remainingCandidates.length ? `, ${remainingCandidates.length} still need review` : ''}.`,
- );
- if (sent && !failed && !deleteFailed) {
- showToast?.(`Unsubscribed and deleted ${sent} email${sent === 1 ? '' : 's'}`);
- } else if (sent || failed) {
- showToast?.(deleteFailed
- ? `Unsubscribed ${sent}, but some emails could not be deleted`
- : `Sent ${sent}, failed ${failed}`);
- }
- if (remainingCandidates.length) {
- _showUnsubscribeRemainingPrompt(modal, remainingCandidates);
- } else {
- _showUnsubscribeScanFurtherPrompt(
- modal,
- anchor,
- `Finished this batch: ${sent} unsubscribed. Search older emails?`,
- );
- }
- });
- listEl.querySelectorAll('.email-unsub-done-btn').forEach(btn => {
- btn.addEventListener('click', async () => {
- const idx = Number(btn.dataset.idx || 0);
- const c = modal._unsubscribeCandidates?.[idx];
- if (!c) return;
- const sender = c.from_name || c.from_address || 'this sender';
- const ok = await styledConfirm(
- `Delete matching unsubscribe emails from ${sender}?`,
- { confirmText: 'Delete', cancelText: 'Cancel', danger: true },
- );
- if (!ok) return;
- const restoreBusy = _setUnsubButtonBusy(btn, 'Deleting');
- try {
- await _deleteAfterUnsubscribe([c]);
- restoreBusy?.();
- _markUnsubscribeCardDone(modal, idx, 'Deleted');
- showToast?.('Matching emails deleted');
- } catch (err) {
- restoreBusy?.();
- showToast?.(err?.message || 'Could not delete matching emails');
- }
- });
- });
- listEl.querySelectorAll('.email-unsub-ignore-btn').forEach(btn => {
- btn.addEventListener('click', () => {
- const idx = Number(btn.dataset.idx || 0);
- const c = modal._unsubscribeCandidates?.[idx];
- if (!c) return;
- _rememberUnsubscribeIgnored(c);
- btn.closest('.email-unsub-card')?.remove();
- const remaining = listEl.querySelectorAll('.email-unsub-card:not([hidden])').length;
- if (!remaining) {
- listEl.style.display = 'none';
- modal.querySelector('.email-unsub-auto-safe-btn').style.display = 'none';
- finishScanStatus?.('No unsubscribe candidates left in this review.');
- } else {
- finishScanStatus?.(`${remaining} unsubscribe candidate${remaining === 1 ? '' : 's'} still need review.`);
- }
- });
- });
- listEl.querySelectorAll('.email-unsub-agent-btn').forEach(btn => {
- btn.addEventListener('click', () => {
- const idx = Number(btn.dataset.idx || 0);
- const c = modal._unsubscribeCandidates?.[idx];
- if (!c) return;
- if (modal._agentUnsubscribeRun) return;
- const restoreBusy = _setUnsubButtonBusy(btn, 'Starting');
- modal._agentUnsubscribeRun = {
- total: 1,
- candidates: [c],
- completed: new Set(),
- cleanupInFlight: new Set(),
- restoreBusy,
- };
- if (!_askAgentToUnsubscribe(c)) {
- restoreBusy?.();
- modal._agentUnsubscribeRun = null;
- }
- });
- });
- listEl.querySelectorAll('.email-unsub-send-btn').forEach(btn => {
- btn.addEventListener('click', async () => {
- const idx = Number(btn.dataset.idx || 0);
- const c = modal._unsubscribeCandidates?.[idx];
- if (!c) return;
- const ok = await styledConfirm(`Send unsubscribe email to ${_unsubscribeMethodLabel(c.recommended_method)}?`, {
- confirmText: 'Unsubscribe',
- cancelText: 'Cancel',
- });
- if (!ok) return;
- btn.disabled = true;
- btn.textContent = 'Sending…';
- try {
- const r = await fetch(emailApiUrl('/api/email/unsubscribe/execute', {
- account_id: state._libAccountId || undefined,
- }), {
- method: 'POST',
- credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- uid: c.uid,
- folder: c.folder || state._libFolder || 'INBOX',
- account_id: state._libAccountId || '',
- method_index: 0,
- move_to_spam: false,
- }),
- });
- const d = await r.json().catch(() => ({}));
- if (!d.success) throw new Error(d.error || 'Unsubscribe failed');
- btn.innerHTML = `${_UNSUB_CHECK_ICON}Sent`;
- btn.style.display = 'inline-flex';
- btn.style.alignItems = 'center';
- btn.style.gap = '4px';
- _markUnsubscribeCardDone(modal, idx);
- try {
- await _deleteAfterUnsubscribe([c]);
- showToast('Unsubscribed and deleted email');
- } catch (deleteErr) {
- console.error(deleteErr);
- showToast('Unsubscribed, but email could not be deleted');
- _showUnsubscribeCleanupPrompt(modal, [c], {
- heading: 'The unsubscribe request was sent. Mark this email as spam and delete it?',
- });
- }
- } catch (err) {
- console.error(err);
- btn.disabled = false;
- btn.innerHTML = `${_UNSUB_EMAIL_ICON}${_esc(_unsubscribeMethodLabel(c.recommended_method))}`;
- btn.style.display = 'inline-flex';
- btn.style.alignItems = 'center';
- btn.style.gap = '4px';
- showToast(err?.message || 'Unsubscribe failed');
- }
- });
- });
- } catch (err) {
- console.error(err);
- finishScanError('Failed to scan email headers');
- }
-}
-
-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();
- }
-}
-
-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();
- });
-}
-
-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 });
- }
-}
-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.
-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 = `
-
-
-
-
-
-
-
-
-
0 Selected
-
-
-
-
-
-
-
-
-
-
-
- `;
-
- 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 });
- }
- })();
-}
-
-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(() => {});
-}
-
-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.
-function _makeDraggable(content, modal, fsClass) {
- if (!content) return;
- const header = content.querySelector('.modal-header');
- if (!header) return;
- // Per-modal fullscreen behavior — caller supplies fsClass, we apply
- // the same inline-style fullscreen pattern email-lib + email-window
- // both use. exitFullscreen restores the default windowed size
- // (min(720px, 92vw) × 85vh) and centers around the cursor.
- const enterFullscreen = () => {
- if (!fsClass || modal.classList.contains(fsClass)) return;
- modal.classList.add(fsClass);
- content.style.position = 'fixed';
- content.style.left = '0';
- content.style.top = '0';
- content.style.right = '0';
- content.style.bottom = '0';
- content.style.width = '100vw';
- content.style.maxWidth = '100vw';
- content.style.height = '100vh';
- content.style.maxHeight = '100vh';
- content.style.borderRadius = '0';
- content.style.transform = 'none';
- };
- const exitFullscreen = (cx, cy) => {
- if (!fsClass || !modal.classList.contains(fsClass)) return;
- modal.classList.remove(fsClass);
- content.style.width = 'min(720px, 92vw)';
- content.style.maxWidth = '';
- content.style.height = '';
- content.style.maxHeight = '85vh';
- content.style.borderRadius = '';
- content.style.right = '';
- content.style.bottom = '';
- const w = Math.min(720, window.innerWidth * 0.92);
- content.style.left = Math.max(8, cx - w / 2) + 'px';
- content.style.top = Math.max(8, cy - 20) + 'px';
- };
- makeWindowDraggable(modal, {
- content,
- header,
- fsClass,
- skipSelector: '.close-btn, .modal-close',
- enableLeftDock: true, // park the email on the left while replying on the right
- onDragStart: ({ rect }) => {
- if (!modal.classList.contains('email-snap-left')) return;
- modal.classList.remove('email-snap-left');
- _clearEmailDocumentSplit();
- content.style.position = 'fixed';
- content.style.left = `${Math.round(rect.left)}px`;
- content.style.top = `${Math.round(rect.top)}px`;
- content.style.right = '';
- content.style.bottom = '';
- content.style.width = `${Math.max(420, Math.round(rect.width || 560))}px`;
- content.style.maxWidth = '';
- content.style.height = `${Math.max(320, Math.round(rect.height || 620))}px`;
- content.style.maxHeight = '85vh';
- content.style.borderRadius = '';
- content.style.transform = 'none';
- content.style.margin = '0';
- },
- onEnterFullscreen: fsClass ? enterFullscreen : null,
- onExitFullscreen: fsClass ? exitFullscreen : null,
- });
-}
-
-// When the user clicks Reply on a fullscreened email view, dock the email
-// modal to the left as a narrow sidebar so the doc panel (which opens on
-// the right side of the chat area) is visible side-by-side. Only triggers
-// when the viewport is wide enough to make a true split worthwhile. Returns
-// true if the snap was applied, false otherwise.
-function _snapEmailModalToLeftSidebar(modal) {
- if (!modal) return false;
- if (window.innerWidth < 900) return false;
- // "Open in new tab" reader modals (id="email-view-…") are explicitly
- // floating windows the user already positioned. Replying from one
- // shouldn't yank it to the left edge — leave it on top in its current
- // spot. Reply still opens the compose document; the user can drag the
- // reader away or close it themselves.
- if ((modal.id || '').startsWith('email-view-')) return false;
- const content = modal.querySelector('.modal-content');
- if (!content) return false;
- // Only dock if currently fullscreen — for a manually-sized window the
- // user already chose its layout; don't surprise them by snapping it.
- const wasLibFs = modal.classList.contains('email-lib-fullscreen');
- const wasWinFs = modal.classList.contains('email-window-fullscreen');
- if (!wasLibFs && !wasWinFs) return false;
- modal.classList.remove('email-lib-fullscreen');
- modal.classList.remove('email-window-fullscreen');
- modal.classList.add('email-snap-left');
- const W = Math.min(440, Math.max(360, Math.round(window.innerWidth * 0.30)));
- const left = _emailSplitLeftEdge();
- content.style.position = 'fixed';
- content.style.left = '0';
- content.style.top = '0';
- content.style.right = '';
- content.style.bottom = '0';
- content.style.width = W + 'px';
- content.style.maxWidth = W + 'px';
- content.style.height = '100vh';
- content.style.maxHeight = '100vh';
- content.style.borderRadius = '0';
- content.style.transform = 'none';
- content.style.margin = '0';
- _setEmailDocumentSplit(left, W);
- _scheduleEmailDocumentSplitMeasure(modal);
- return true;
-}
-
-async function _loadFolders({ resetMissing = false, 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 ||]*>|
]*>|\\n)\\s*((?:<[^>]+>\\s*)*${FROM}\\s*:\\s*[^<\\n]+(?:<[^>]+>\\s*|\\s)*${SENT}\\s*:[\\s\\S]+?${SUBJ}\\s*:[\\s\\S]+)$`,
- 'i'
- );
- const m = html.match(outlookRe);
- if (m) {
- const idx = html.lastIndexOf(m[0]);
- // Outlook fallback only ever produces ONE fold, so tag it as last.
- html = html.slice(0, idx) + m[1]
- + '
'
- + _foldSummary('Earlier thread', _QUOTE_ICON, _extractQuoteMeta(m[2]))
- + m[2] + ' ';
- }
- return html;
-}
-
-
-// Global preference: AI summary panels stay collapsed across every email
-// once the user folds one, and stay expanded once they unfold. Stored in
-// localStorage so the choice survives reloads.
-const _SUMMARY_COLLAPSED_KEY = 'odysseus.email.summaryCollapsed';
-function _summaryCollapsedPref() {
- try { return localStorage.getItem(_SUMMARY_COLLAPSED_KEY) === '1'; } catch { return false; }
-}
-function _setSummaryCollapsedPref(v) {
- try { localStorage.setItem(_SUMMARY_COLLAPSED_KEY, v ? '1' : '0'); } catch {}
-}
-
-function _showCachedSummary(reader, summary, btn) {
- const body = reader.querySelector('.email-reader-body');
- if (!body) return;
- if (body.querySelector('.email-summary-panel')) return;
- const panel = document.createElement('div');
- panel.className = 'email-summary-panel';
- if (_summaryCollapsedPref()) panel.classList.add('collapsed');
- panel.innerHTML =
- ''
- + '
';
- panel.querySelector('.email-summary-content').textContent = summary;
- body.insertBefore(panel, body.firstChild);
- const toggle = panel.querySelector('.email-summary-toggle');
- // Header click folds/unfolds. Persists so the next email opens in the
- // same state.
- const _flip = () => {
- panel.classList.toggle('collapsed');
- _setSummaryCollapsedPref(panel.classList.contains('collapsed'));
- };
- if (toggle) {
- toggle.addEventListener('click', (ev) => { ev.stopPropagation(); _flip(); });
- toggle.addEventListener('keydown', (ev) => {
- if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); _flip(); }
- });
- }
- if (btn) {
- btn.classList.add('active');
- const label = btn.querySelector('.btn-label');
- if (label) label.textContent = 'Summary';
- }
-}
-
-// Wire click handlers for attachment chips + "open in editor" sub-buttons
-// inside a reader. Safe to call multiple times — uses dataset.wired flag to
-// skip nodes that already have listeners.
-function _wireAttachmentHandlers(reader, folder) {
- const useFolder = folder || state._libFolder;
- // Detect mobile here so the attachment-chip handler doesn't blow up with
- // a ReferenceError when this fn is called from contexts that don't have
- // _isMobileUA in scope (e.g. _openEmailAsTab, _openEmailWindow).
- const _isMobileUA = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
- reader.querySelectorAll('.email-attachments-download-all').forEach(btn => {
- if (btn.dataset.wired === '1') return;
- btn.dataset.wired = '1';
- btn.addEventListener('click', async (ev) => {
- ev.stopPropagation();
- ev.preventDefault();
- if (btn.dataset.downloading === '1') return;
- const uid = btn.dataset.attUid;
- const sourceFolder = btn.dataset.attFolder || useFolder;
- const count = Number(btn.dataset.attCount || 0);
- if (!uid) return;
- const originalHtml = btn.innerHTML;
- const originalTitle = btn.title;
- btn.dataset.downloading = '1';
- btn.classList.add('is-loading');
- try {
- const sp = window.spinnerModule || (await import('./spinner.js')).default;
- const wp = sp.createWhirlpool(12);
- wp.element.style.margin = '0';
- btn.textContent = '';
- btn.appendChild(wp.element);
- const label = document.createElement('span');
- label.textContent = 'All';
- btn.appendChild(label);
- } catch (_) {
- btn.textContent = 'All...';
- }
- try {
- const url = `${API_BASE}/api/email/attachments-download/${encodeURIComponent(uid)}?folder=${encodeURIComponent(sourceFolder)}${_acct()}`;
- const res = await fetch(url, { credentials: 'same-origin' });
- if (!res.ok) {
- const msg = await res.text().catch(() => '');
- console.error('attachments zip download failed', res.status, msg);
- location.href = url;
- return;
- }
- const blob = await res.blob();
- const blobUrl = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = blobUrl;
- a.download = `email-${uid}-attachments.zip`;
- document.body.appendChild(a);
- a.click();
- a.remove();
- setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
- try { uiModule.showToast && uiModule.showToast(`Downloading ${count || 'all'} attachments`); } catch (_) {}
- } catch (e) {
- console.error('attachments zip download error', e);
- try { const { showError } = await import('./ui.js?v=20260916largetoolscroll1'); showError('Could not download attachments'); } catch (_) {}
- } finally {
- delete btn.dataset.downloading;
- btn.classList.remove('is-loading');
- btn.title = originalTitle;
- btn.innerHTML = originalHtml;
- }
- });
- });
- reader.querySelectorAll('.email-attachment-calendar-open').forEach(openBtn => {
- if (openBtn.dataset.wired === '1') return;
- openBtn.dataset.wired = '1';
- openBtn.addEventListener('click', async (ev) => {
- ev.stopPropagation();
- ev.preventDefault();
- if (openBtn.dataset.opening === '1') return;
- const uid = openBtn.dataset.openUid;
- const index = openBtn.dataset.openIndex;
- const name = openBtn.dataset.openName || `calendar-${index}`;
- const sourceFolder = openBtn.dataset.openFolder || useFolder;
- if (!uid || index == null) return;
- openBtn.dataset.opening = '1';
- openBtn.classList.add('is-loading');
- const originalHtml = openBtn.innerHTML;
- try {
- const wp = spinnerModule.createWhirlpool(12);
- wp.element.style.margin = '0';
- openBtn.textContent = '';
- openBtn.appendChild(wp.element);
- const folderQs = encodeURIComponent(sourceFolder);
- const attachmentUrl = `${API_BASE}/api/email/attachment/${encodeURIComponent(uid)}/${encodeURIComponent(index)}?folder=${folderQs}${_acct()}`;
- const attachmentRes = await fetch(attachmentUrl, { credentials: 'same-origin' });
- if (!attachmentRes.ok) throw new Error(`HTTP ${attachmentRes.status}`);
- const blob = await attachmentRes.blob();
- const fd = new FormData();
- fd.append('file', blob, name);
- const importRes = await fetch(`${API_BASE}/api/calendar/import`, {
- method: 'POST', body: fd, credentials: 'same-origin',
- });
- const result = await importRes.json().catch(() => ({}));
- if (!importRes.ok || !result.ok) {
- throw new Error(result.detail || result.error || `HTTP ${importRes.status}`);
- }
- const existingEventUid = Array.isArray(result.event_uids) ? String(result.event_uids[0] || '').trim() : '';
- if (existingEventUid && Number(result.imported || 0) === 0 && Number(result.skipped || 0) > 0) {
- _openCalendarEventFromEmail(existingEventUid);
- }
- try { uiModule.showToast && uiModule.showToast(`${result.imported || 0} event${result.imported === 1 ? '' : 's'} added to ${result.calendar || 'calendar'}`); } catch (_) {}
- window.dispatchEvent(new CustomEvent('calendar-refresh'));
- } catch (e) {
- console.error('calendar attachment import failed', e);
- try {
- const { showError } = await import('./ui.js?v=20260916largetoolscroll1');
- showError(`Couldn't add ${name} to calendar: ${e?.message || 'Import failed'}`);
- } catch (_) {}
- } finally {
- delete openBtn.dataset.opening;
- openBtn.classList.remove('is-loading');
- openBtn.innerHTML = originalHtml;
- }
- });
- });
- reader.querySelectorAll('.email-attachment-open').forEach(openBtn => {
- if (openBtn.dataset.wired === '1') return;
- openBtn.dataset.wired = '1';
- openBtn.addEventListener('click', async (ev) => {
- ev.stopPropagation();
- ev.preventDefault();
- if (openBtn.dataset.opening === '1') return;
- const uid = openBtn.dataset.openUid;
- const index = openBtn.dataset.openIndex;
- const name = openBtn.dataset.openName || `attachment-${index}`;
- const sourceFolder = openBtn.dataset.openFolder || useFolder;
- if (!uid || index == null) return;
- openBtn.dataset.opening = '1';
- openBtn.classList.add('is-loading');
- const origHtml = openBtn.innerHTML;
- const wp = spinnerModule.createWhirlpool(12);
- wp.element.style.margin = '0';
- openBtn.textContent = '';
- openBtn.appendChild(wp.element);
- try {
- const folderQs = encodeURIComponent(sourceFolder);
- const controller = new AbortController();
- const timeout = setTimeout(() => controller.abort(), 55000);
- let res;
- try {
- res = await fetch(
- `${API_BASE}/api/email/attachment-as-doc/${encodeURIComponent(uid)}/${encodeURIComponent(index)}?folder=${folderQs}${_acct()}`,
- { method: 'POST', credentials: 'same-origin', signal: controller.signal }
- );
- } finally {
- clearTimeout(timeout);
- }
- const json = await res.json().catch(() => ({}));
- if (!res.ok || !json.doc_id) {
- const msg = (json && json.error) || `HTTP ${res.status}`;
- try { const { showError } = await import('./ui.js?v=20260916largetoolscroll1'); showError(`Couldn't open ${name}: ${msg}`); } catch (_) { alert(`Couldn't open ${name}: ${msg}`); }
- return;
- }
- try {
- // Tab the email modal down only when the viewport cannot fit both
- // Email and the document pane. Desktop keeps a side-by-side layout
- // when there is room; mobile still gives the document the screen.
- const ownerModal = openBtn.closest('.modal');
- if (ownerModal && ownerModal.id && _prepareEmailWindowForDocument(ownerModal)) {
- try {
- const ok = Modals.minimize(ownerModal.id);
- if (!ok) ownerModal.classList.add('hidden');
- } catch (_) {
- ownerModal.classList.add('hidden');
- }
- }
- const docMod = await import('./document.js?v=20260916docctx2');
- const load = (docMod && docMod.loadDocument) || (docMod && docMod.default && docMod.default.loadDocument);
- if (typeof load === 'function') {
- await load(json.doc_id);
- } else {
- location.href = `/?doc=${encodeURIComponent(json.doc_id)}`;
- }
- } catch (e) {
- console.error('Open document failed:', e);
- try { const { showError } = await import('./ui.js?v=20260916largetoolscroll1'); showError('Document opened but panel could not mount'); } catch (_) {}
- }
- } catch (e) {
- console.error('attachment-as-doc error', e);
- const msg = e && e.name === 'AbortError'
- ? `Opening ${name} timed out. Try downloading it instead.`
- : `Couldn't open ${name}`;
- try { const { showError } = await import('./ui.js?v=20260916largetoolscroll1'); showError(msg); } catch (_) {}
- } finally {
- delete openBtn.dataset.opening;
- openBtn.classList.remove('is-loading');
- openBtn.innerHTML = origHtml;
- }
- });
- });
-
- reader.querySelectorAll('.email-attachment-download').forEach(downloadBtn => {
- if (downloadBtn.dataset.wired === '1') return;
- downloadBtn.dataset.wired = '1';
- downloadBtn.addEventListener('click', (ev) => {
- ev.stopPropagation();
- ev.preventDefault();
- const chip = downloadBtn.closest('.email-attachment-chip');
- if (!chip) return;
- // Reuse the established attachment download handler below. Marking the
- // chip expanded bypasses its old first-click reveal behavior.
- chip.classList.add('is-expanded');
- chip.dataset.downloadTrigger = 'button';
- chip.click();
- });
- downloadBtn.addEventListener('keydown', (ev) => {
- if (ev.key !== 'Enter' && ev.key !== ' ') return;
- ev.preventDefault();
- downloadBtn.click();
- });
- });
-
- reader.querySelectorAll('.email-attachment-chip').forEach(chip => {
- if (chip.dataset.wired === '1') return;
- chip.dataset.wired = '1';
- chip.addEventListener('click', async (ev) => {
- if (ev.target.closest('.email-attachment-open, .email-attachment-calendar-open, .email-attachment-download')) return;
- ev.stopPropagation();
- ev.preventDefault();
- const uid = chip.dataset.attUid;
- const index = chip.dataset.attIndex;
- const name = chip.dataset.attName || `attachment-${index}`;
- const sourceFolder = chip.dataset.attFolder || useFolder;
- if (!uid || index == null) return;
- if (!chip.classList.contains('is-expanded')) {
- reader.querySelectorAll('.email-attachment-chip.is-expanded').forEach(other => {
- if (other !== chip) other.classList.remove('is-expanded');
- });
- chip.classList.add('is-expanded');
- return;
- }
- const url = `${API_BASE}/api/email/attachment/${encodeURIComponent(uid)}/${encodeURIComponent(index)}?folder=${encodeURIComponent(sourceFolder)}${_acct()}`;
- if (_isMobileUA) {
- delete chip.dataset.downloadTrigger;
- window.open(url, '_blank');
- return;
- }
- // Swap the paperclip icon for a whirlpool spinner while the
- // download is in flight, so large attachments give a clear cue
- // they're loading. Restore on completion.
- const buttonTriggered = chip.dataset.downloadTrigger === 'button';
- const iconSvg = buttonTriggered
- ? chip.querySelector('.email-attachment-download > svg')
- : chip.querySelector(':scope > svg');
- const origIconHtml = iconSvg ? iconSvg.outerHTML : '';
- let _wp = null;
- let _spinnerHost = null;
- try {
- const sp = window.spinnerModule || (await import('./spinner.js')).default;
- _wp = sp.createWhirlpool(12);
- _spinnerHost = document.createElement('span');
- _spinnerHost.className = 'email-attachment-spinner';
- _spinnerHost.style.cssText = 'display:inline-flex;width:12px;height:12px;align-items:center;justify-content:center;flex-shrink:0;position:relative;top:-2px;';
- _spinnerHost.appendChild(_wp.element);
- if (iconSvg) iconSvg.replaceWith(_spinnerHost);
- } catch (_) {}
- const origOpacity = chip.style.opacity;
- chip.style.opacity = '0.85';
- try {
- const res = await fetch(url, { credentials: 'same-origin' });
- if (!res.ok) {
- console.error('attachment download failed', res.status, await res.text().catch(() => ''));
- location.href = url;
- return;
- }
- const blob = await res.blob();
- const blobUrl = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = blobUrl;
- a.download = name;
- document.body.appendChild(a);
- a.click();
- a.remove();
- setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
- } catch (e) {
- console.error('attachment download error', e);
- location.href = url;
- } finally {
- delete chip.dataset.downloadTrigger;
- chip.style.opacity = origOpacity;
- if (_spinnerHost && _spinnerHost.parentNode && origIconHtml) {
- const tmp = document.createElement('div');
- tmp.innerHTML = origIconHtml;
- const restored = tmp.firstChild;
- if (restored) _spinnerHost.replaceWith(restored);
- }
- if (_wp) { try { _wp.destroy(); } catch (_) {} }
- }
- });
- });
-}
-
-// Heuristic: skip "attachments" that are clearly inline images used by
-// signatures / quoted-reply headers (small image files, Outlook-style
-// image001.png placeholders, logo*.png, etc.). They aren't real user-
-// shared attachments and adding them to the chips makes every email look
-// like it has content the user needs to act on.
-function _isLikelySignatureImage(a) {
- if (!a || !a.filename) return false;
- const name = String(a.filename).toLowerCase();
- const isImage = /\.(png|jpe?g|gif|bmp|svg|webp)$/i.test(name);
- if (!isImage) return false;
- const size = Number(a.size) || 0;
- // Outlook / Gmail inline image placeholders always look like this.
- if (/^image\d{3,}\.(png|jpe?g|gif)$/i.test(name)) return true;
- if (/^(signature|logo|sig|footer|banner)[-_\d]*\.(png|jpe?g|gif|svg)$/i.test(name)) return true;
- // Most signature logos / inline thumbnails are < 30 KB. Real user-
- // shared images (screenshots, photos) are typically 50 KB+.
- if (size > 0 && size < 30 * 1024) return true;
- return false;
-}
-
-// Build the attachments header+chips HTML for an email read response. Pulled
-// out so both the initial-open and the swap-reader paths can render it.
-function _buildAttsHtmlFor(uid, data) {
- if (!data) return '';
- const _OPENABLE_RE = /\.(pdf|docx|txt|md|markdown|eml)$/i;
- const _CALENDAR_RE = /\.(calendar|ics|ical)$/i;
- const currentAttachments = Array.isArray(data.attachments) ? data.attachments : [];
- const relatedAttachments = Array.isArray(data.related_attachments) ? data.related_attachments : [];
- if (!currentAttachments.length && !relatedAttachments.length) return '';
- const visible = currentAttachments.filter(a => !_isLikelySignatureImage(a));
- const hidden = currentAttachments.filter(a => _isLikelySignatureImage(a));
- const related = relatedAttachments.filter(a => !_isLikelySignatureImage(a));
- if (!visible.length && !related.length && state._libViewInlineImages === false) return '';
- const renderChip = (a, extraClass = '') => {
- const calendarAttachment = _CALENDAR_RE.test(a.filename || '');
- const openable = _OPENABLE_RE.test(a.filename || '');
- const chipUid = a.source_uid || a.uid || uid;
- const chipFolder = a.source_folder || data.folder || state._libFolder || 'INBOX';
- const openBtn = calendarAttachment
- ? `
`
- : openable
- ? `
`
- : '';
- const downloadBtn = `
`;
- return ``;
- };
- const chips = visible.map(a => renderChip(a)).join('');
- const hiddenChips = hidden.map(a => renderChip(a, ' email-attachment-chip-muted')).join('');
- const relatedChips = related.map(a => renderChip(a, ' email-attachment-chip-related')).join('');
- const visibleSection = visible.length
- ? '
' + chips + '
'
- : '';
- const relatedSection = related.length
- ? '
From earlier in this thread
' + relatedChips + '
'
- : '';
- const hiddenSection = hidden.length && state._libViewInlineImages !== false
- ? '
Filtered inline images / signature files
' + hiddenChips + '
'
- : '';
- const label = visible.length
- ? `Attachments (${visible.length + related.length})`
- : related.length
- ? `Thread attachments (${related.length})`
- : state._libViewInlineImages === false
- ? 'Attachments'
- : `Hidden inline attachments (${hidden.length})`;
- const startCollapsed = !visible.length && !related.length;
- const downloadAllBtn = visible.length > 4
- ? `
`
- : '';
- return (
- `
`
- + ''
- + visibleSection
- + relatedSection
- + hiddenSection
- + '
'
- );
-}
-
-async function _ensureEmailAttachmentData(uid, folder, data, knownHasAttachments = false) {
- if (!data) return data;
- const current = Array.isArray(data.attachments) ? data.attachments : [];
- const related = Array.isArray(data.related_attachments) ? data.related_attachments : [];
- const shouldFetch = data.attachments_deferred || (knownHasAttachments && !current.length && !related.length);
- if (!shouldFetch) return data;
- try {
- const metaRes = await fetch(`${API_BASE}/api/email/attachments/${encodeURIComponent(uid)}?folder=${encodeURIComponent(folder || 'INBOX')}${_acct()}`);
- const meta = await metaRes.json().catch(() => ({}));
- if (metaRes.ok && Array.isArray(meta.attachments)) {
- return {
- ...data,
- attachments: meta.attachments,
- related_attachments: related,
- attachments_deferred: false,
- };
- }
- } catch (_) {}
- // Do not full-fetch the message during ordinary open. That can download and
- // parse large MIME bodies immediately after the reader renders, keeping IMAP
- // busy and making the UI feel stuck. Full attachment lookup is still available
- // from explicit attachment/download/forward actions.
- return data;
-}
-
-function _wireEmailAttachmentWrap(reader, folder) {
- if (!reader) return;
- const attsWrap = reader.querySelector('.email-reader-atts-wrap');
- if (attsWrap && !attsWrap.dataset.wired) {
- attsWrap.dataset.wired = '1';
- const attsToggle = attsWrap.querySelector('.email-reader-atts-header');
- if (attsToggle) {
- attsToggle.addEventListener('click', (ev) => {
- ev.stopPropagation();
- attsWrap.classList.toggle('collapsed');
- });
- attsToggle.addEventListener('keydown', (ev) => {
- if (ev.key === 'Enter' || ev.key === ' ') {
- ev.preventDefault();
- attsWrap.classList.toggle('collapsed');
- }
- });
- }
- }
- try { _wireAttachmentHandlers(reader, folder); } catch {}
-}
-
-function _loadDeferredAttachmentsIntoReader(reader, uid, folder, data, knownHasAttachments = false) {
- if (!reader || !uid || !data) return;
- const current = Array.isArray(data.attachments) ? data.attachments : [];
- const related = Array.isArray(data.related_attachments) ? data.related_attachments : [];
- if (!data.attachments_deferred && !(knownHasAttachments && !current.length && !related.length)) return;
- const body = reader.querySelector('.email-reader-body');
- if (!body) return;
- const loading = document.createElement('div');
- loading.className = 'email-reader-atts-wrap email-reader-atts-loading';
- loading.style.cssText = 'min-height:34px;display:flex;flex-direction:row;align-items:center;gap:7px;padding:6px 14px;color:var(--fg);font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.4px;opacity:.7;';
- const loadingSpinner = spinnerModule.createWhirlpool(12);
- loadingSpinner.element.style.margin = '0';
- loading.appendChild(loadingSpinner.element);
- loading.appendChild(document.createTextNode('Loading attachments…'));
- const existingWrap = reader.querySelector('.email-reader-atts-wrap');
- if (existingWrap) existingWrap.replaceWith(loading);
- else body.insertAdjacentElement('beforebegin', loading);
- _ensureEmailAttachmentData(uid, folder, data, knownHasAttachments).then(fullData => {
- if (!reader.isConnected || !fullData) return;
- loading.remove();
- try { loadingSpinner.destroy(); } catch (_) {}
- const visibleCurrent = (fullData.attachments || []).filter(a => !_isLikelySignatureImage(a));
- const matchingEmail = (state._libEmails || []).find(em => (
- String(em.uid) === String(uid)
- && String(em.account_id || state._libAccountId || '') === String(reader.closest('[data-email-account]')?.dataset.emailAccount || state._libAccountId || '')
- ));
- if (matchingEmail) matchingEmail.has_attachments = visibleCurrent.length > 0;
- if (!visibleCurrent.length) {
- reader.closest('.doclib-card')?.querySelector('.email-card-attachment')?.remove();
- }
- const attsHtml = _buildAttsHtmlFor(uid, fullData);
- if (!attsHtml) return;
- const oldWrap = reader.querySelector('.email-reader-atts-wrap');
- if (oldWrap) {
- const tmp = document.createElement('div');
- tmp.innerHTML = attsHtml;
- oldWrap.replaceWith(tmp.firstElementChild);
- } else {
- body.insertAdjacentHTML('beforebegin', attsHtml);
- }
- _wireEmailAttachmentWrap(reader, folder);
- }).catch(() => {
- loading.remove();
- try { loadingSpinner.destroy(); } catch (_) {}
- });
-}
-
-// "Open in new tab" — the email opens in the library (expanded inline)
-// AND a separate floating "email viewer" overlay modal is created. The
-// overlay starts minimized as a chip in the dock; tapping the chip
-// brings the viewer up over the library. Multiple tabs = multiple
-// overlay modals + chips, each independent.
-const _EMAIL_ICON_PATH = 'M2 4h20v16H2zM22 7l-9.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7';
-let _emailTabSeq = 0;
-// Persistent slot numbers per reader modalId. Once a reader is "tab 2"
-// it stays "tab 2" until it's closed — even if tab 1 closes first, the
-// remaining reader doesn't renumber down to 1. New tabs claim the
-// lowest unused slot.
-const _emailReaderSlots = new Map(); // modalId -> slot (1, 2, 3, ...)
-function _allocReaderSlot(modalId) {
- if (_emailReaderSlots.has(modalId)) return _emailReaderSlots.get(modalId);
- const used = new Set(_emailReaderSlots.values());
- let n = 1;
- while (used.has(n)) n++;
- _emailReaderSlots.set(modalId, n);
- return n;
-}
-function _freeReaderSlot(modalId) {
- _emailReaderSlots.delete(modalId);
-}
-
-// JS-driven gate: sets [data-email-tabs="N"] on so CSS can show
-// the per-chip number badge only when 2+ tabs exist.
-function _syncEmailTabsCount() {
- const tabs = document.querySelectorAll('.minimized-dock-chip[data-modal-id^="email-view-"]');
- document.body.dataset.emailTabs = String(tabs.length);
-}
-
-// Recompute the email menu chip's tab-count whenever the dock contents
-// change. Counts "email-view-*" chips both inside #minimized-dock and
-// at body level (free-positioned chips on mobile). Result is written to
-// the email-lib-modal chip's data-tab-count attribute; CSS reads it via
-// attr() to render the badge.
-function _syncEmailTabBadge() {
- const readers = document.querySelectorAll('.minimized-dock-chip[data-modal-id^="email-reader-"]');
- document.body.dataset.emailReaders = String(readers.length);
- // Stamp each chip with its persistent slot number. CSS reads
- // data-tab-num via attr() instead of using a counter so the number
- // stays stable when other tabs close.
- readers.forEach(chip => {
- const slot = _emailReaderSlots.get(chip.dataset.modalId);
- if (slot) chip.dataset.tabNum = String(slot);
- });
-}
-let _emailTabObserverWired = false;
-let _badgeSyncScheduled = false;
-function _ensureEmailTabObserver() {
- if (_emailTabObserverWired) return;
- _emailTabObserverWired = true;
- // Debounce so a burst of mutations (e.g. _renderDock rebuilding the
- // whole dock in one pass) collapses to a single sync per animation
- // frame. Without this the chip badge could flicker as the observer
- // fires repeatedly during dock rerenders.
- const handler = () => {
- if (_badgeSyncScheduled) return;
- _badgeSyncScheduled = true;
- requestAnimationFrame(() => {
- _badgeSyncScheduled = false;
- _syncEmailTabBadge();
- });
- };
- const tryWire = () => {
- const dock = document.getElementById('minimized-dock');
- if (!dock) { setTimeout(tryWire, 200); return; }
- // Only watch what we care about: chip add/remove in the dock.
- const obs = new MutationObserver(handler);
- obs.observe(dock, { childList: true });
- // Watch the library grid so toggling a card expanded/collapsed
- // updates the lib chip's "has-expanded" badge in real time.
- const wireGridObs = () => {
- const grid = document.getElementById('email-lib-grid');
- if (!grid) { setTimeout(wireGridObs, 500); return; }
- const gridObs = new MutationObserver(handler);
- gridObs.observe(grid, { subtree: true, attributes: true, attributeFilter: ['class'] });
- };
- wireGridObs();
- handler();
- };
- tryWire();
-}
-// Hybrid model:
-// - email-lib-modal (the inbox library) is unique. Its chip just
-// restores it.
-// - Each "Open in new tab" creates a separate per-email reader modal
-// (id "email-reader-{uid}-{seq}") with the SAME structure & classes
-// as the library's inline reader, so they look identical. Each
-// reader registers its own dock chip with a number badge.
-async function _openEmailAsTab(em, folder) {
- const useFolder = folder || state._libFolder || 'INBOX';
- _emailTabSeq += 1;
- const modalId = `email-reader-${em.uid}-${_emailTabSeq}`;
- _allocReaderSlot(modalId);
-
- // Build the modal shell. Uses the same doclib-modal-content sizing
- // as the email library so it feels like a sibling window. The reader
- // body inside uses the exact same email-card-reader / email-reader-*
- // classes the inline reader uses → identical styling.
- const modal = document.createElement('div');
- modal.className = 'modal email-reader-tab-modal';
- modal.id = modalId;
- modal.innerHTML = `
-
- `;
- document.body.appendChild(modal);
- // Inherit display from .modal (flex-center). z-index above the library
- // (which uses default .modal z-index 250) so the new tab sits on top.
- modal.style.zIndex = '270';
- // Opened last → email windows in front of any open doc (alternation flag).
- document.body.classList.add('email-front');
-
- Modals.register(modalId, {
- label: 'Email',
- icon: _EMAIL_ICON_PATH,
- closeFn: () => {
- modal.remove();
- _freeReaderSlot(modalId);
- Promise.resolve().then(_syncEmailTabBadge);
- },
- restoreFn: () => {
- // Reopened last → bring the email windows in front of any open doc.
- document.body.classList.add('email-front');
- // Mobile: only one email window visible at a time. Tapping this
- // chip chips down the library + any other reader, so the user
- // toggles between them via the dock instead of stacking.
- if (window.innerWidth <= 768) {
- try {
- if (Modals.isRegistered('email-lib-modal') && !Modals.isMinimized('email-lib-modal')) {
- Modals.minimize('email-lib-modal');
- }
- } catch {}
- document.querySelectorAll('.modal[id^="email-reader-"]').forEach(other => {
- if (other.id === modalId) return;
- try {
- if (Modals.isRegistered(other.id) && !Modals.isMinimized(other.id)) {
- Modals.minimize(other.id);
- }
- } catch {}
- });
- }
- },
- });
- // Wire the `_` minimize button via modalManager (it sees our .minimize-btn
- // already exists and just binds the click handler).
- try { Modals.injectMinimizeButton(modal, modalId); } catch {}
- // X button fully closes the tab (tears down and unregisters).
- modal.querySelector('.close-btn')?.addEventListener('click', (ev) => {
- ev.stopPropagation();
- Modals.close(modalId);
- });
-
- // Wire dragging on the header (desktop only). Matches the global pattern
- // in app.js initUIVisibility, but that runs once at boot and doesn't see
- // dynamically-created modals — so we replicate it here.
- const content = modal.querySelector('.modal-content');
- const mh = modal.querySelector('.modal-header');
- if (mh && content) {
- let dragX = 0, dragY = 0, startLeft = 0, startTop = 0, dragging = false;
- const startDrag = (clientX, clientY) => {
- dragging = true;
- const rect = content.getBoundingClientRect();
- dragX = clientX; dragY = clientY;
- startLeft = rect.left; startTop = rect.top;
- content.style.position = 'fixed';
- content.style.left = startLeft + 'px';
- content.style.top = startTop + 'px';
- content.style.margin = '0';
- };
- const onDrag = (e) => {
- if (!dragging) return;
- content.style.left = (startLeft + e.clientX - dragX) + 'px';
- content.style.top = (startTop + e.clientY - dragY) + 'px';
- };
- const stopDrag = () => {
- dragging = false;
- document.removeEventListener('mousemove', onDrag);
- document.removeEventListener('mouseup', stopDrag);
- };
- mh.addEventListener('mousedown', (e) => {
- if (e.target.closest('.close-btn, .minimize-btn, .modal-minimize-btn')) return;
- e.preventDefault();
- startDrag(e.clientX, e.clientY);
- document.addEventListener('mousemove', onDrag);
- document.addEventListener('mouseup', stopDrag);
- });
- }
-
- // Open the new tab in front, on top of the email library. The user
- // can tap `_` to tab it down to a chip when they're done reading.
- //
- // Mobile: bottom-sheet windows fill the viewport, so stacking multiple
- // readers on top of each other is confusing — only one window can be
- // meaningfully visible at a time. So when the new tab opens, chip down
- // the library AND any other email-reader-* tab that's currently up.
- // The user gets a stack of mini chips to toggle between them.
- if (window.innerWidth <= 768) {
- try {
- if (Modals.isRegistered('email-lib-modal') && !Modals.isMinimized('email-lib-modal')) {
- Modals.minimize('email-lib-modal');
- }
- } catch {}
- document.querySelectorAll('.modal[id^="email-reader-"]').forEach(other => {
- if (other.id === modalId) return;
- try {
- if (Modals.isRegistered(other.id) && !Modals.isMinimized(other.id)) {
- Modals.minimize(other.id);
- }
- } catch {}
- });
- }
- _ensureEmailTabObserver();
- _syncEmailTabBadge();
-
- // Fetch + render the email body using the exact same template as
- // _toggleCardPreview so the visuals match perfectly.
- const reader = modal.querySelector('.email-card-reader');
- const showFailedTab = (message) => {
- try { showToast(message || 'Failed to load email'); } catch (_) {}
- _showEmailReaderLoadError(reader, message, () => {
- try { Modals.close(modalId); } catch (_) { try { modal.remove(); } catch (_) {} }
- setTimeout(() => { _openEmailAsTab(em, useFolder); }, 0);
- });
- };
- _markEmailReaderActive(reader);
- const loading = modal.querySelector('.email-reader-tab-loading');
- if (loading) loading.remove();
- if (reader) {
- reader.classList.add('email-card-reader-loading');
- reader.innerHTML = _emailReaderSkeletonHtml();
- }
- try {
- const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(useFolder)}${_acct()}`);
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- let data = await res.json();
- if (data.error) {
- showFailedTab(`Failed to load email: ${data.error}`);
- return;
- }
- data = _normalizeEmailStateFlags({ ...em, ...data });
- Object.assign(em, data);
- _syncEmailDoneState(em.uid, data.is_answered);
- _syncEmailReadState(em.uid, true);
- _stampReaderContext(reader, data, useFolder, state._libAccountId);
- const buildChips = (str) => {
- if (!str) return '';
- return _splitRecipientList(str).map(a => {
- const name = _extractName(a);
- return _recipientChipHtml(a, name);
- }).join('');
- };
- const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip');
- let attsHtml = '';
- try { attsHtml = _buildAttsHtmlFor(em.uid, data); } catch {}
- reader.innerHTML = `
-
- ${attsHtml}
-
${_safeRenderEmailBody(data)}
- `;
- _markEmailReaderActive(reader);
- reader.classList.remove('email-card-reader-loading');
- _wireRecipientChips(reader);
- _wireEmailAttachmentWrap(reader, useFolder);
- _wireEmailInlineImages(reader);
- _loadDeferredAttachmentsIntoReader(reader, em.uid, useFolder, data, !!em.has_attachments);
- _maybeAutoTranslateEmail(reader);
- reader.querySelector('[data-act="reply"]')?.addEventListener('click', async (ev) => {
- ev.stopPropagation();
- _snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal'));
- if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply' });
- });
- reader.querySelector('[data-act="reply-all"]')?.addEventListener('click', async (ev) => {
- ev.stopPropagation();
- _snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal'));
- if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply-all' });
- });
- reader.querySelector('[data-act="ai-reply"]')?.addEventListener('click', (ev) => _handleAiReplyButton(ev, em, data));
- reader.querySelector('[data-act="forward"]')?.addEventListener('click', async (ev) => {
- ev.stopPropagation();
- if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'forward' });
- });
- reader.querySelector('[data-act="summarize"]')?.addEventListener('click', async (ev) => {
- ev.stopPropagation();
- try { await _summarizeEmail(reader, data, ev.currentTarget); } catch {}
- });
- _wireMetaToggle(reader);
- _wireReaderActionOverflow(reader);
- reader.querySelector('[data-act="more"]')?.addEventListener('click', (ev) => {
- ev.stopPropagation();
- try { _showReaderMoreMenu(em, modal, reader, ev.currentTarget, data); } catch {}
- });
- } catch (err) {
- showFailedTab(err?.message ? `Failed to load email: ${err.message}` : 'Failed to load email');
- }
-}
-
-
-// "Open in new window" — spawns a floating draggable modal that shows just
-// the email content. Multiple windows can stack; each has its own DOM id
-// and close button. Uses `_makeDraggable` so dragging the header pans the
-// window around. Renders the body via _renderEmailBody for parity with the
-// expanded reader.
-let _emailWindowSeq = 0;
-async function _openEmailWindow(em, folder) {
- const useFolder = folder || state._libFolder || 'INBOX';
- _emailWindowSeq += 1;
- const winId = `email-window-${em.uid}-${_emailWindowSeq}`;
- const modal = document.createElement('div');
- modal.className = 'modal email-window-modal';
- modal.id = winId;
- modal.style.cssText = 'pointer-events:none;background:transparent;';
- modal.innerHTML = `
-
- `;
- document.body.appendChild(modal);
- modal.style.display = 'block';
- const content = modal.querySelector('.modal-content');
- // Position offset from screen center so successive windows cascade.
- const isMobile = window.innerWidth <= 768;
- if (isMobile) {
- content.style.position = 'fixed';
- content.style.pointerEvents = 'auto';
- content.style.left = '0';
- content.style.right = '0';
- content.style.bottom = '0';
- content.style.top = 'auto';
- } else {
- content.style.position = 'fixed';
- content.style.pointerEvents = 'auto';
- requestAnimationFrame(() => {
- const w = content.offsetWidth, h = content.offsetHeight;
- const off = (_emailWindowSeq % 6) * 28;
- content.style.left = Math.max(20, (window.innerWidth - w) / 2 + off) + 'px';
- content.style.top = Math.max(20, (window.innerHeight - h) / 3 + off) + 'px';
- });
- }
- modal.querySelector('.close-btn')?.addEventListener('click', () => modal.remove());
- try { _makeDraggable(content, modal, 'email-window-fullscreen'); } catch {}
-
- // Load + render
- const bodyEl = modal.querySelector('.email-window-body');
- const loading = modal.querySelector('.email-window-loading');
- try {
- if (loading) loading.remove();
- if (bodyEl) {
- bodyEl.classList.add('email-card-reader', 'email-card-reader-loading');
- bodyEl.style.padding = '0';
- bodyEl.innerHTML = _emailReaderSkeletonHtml();
- }
- const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(useFolder)}${_acct()}`);
- let data = await res.json();
- if (data.error) {
- bodyEl.innerHTML = `
${_esc(data.error)}
`;
- return;
- }
- data = _normalizeEmailStateFlags({ ...em, ...data });
- Object.assign(em, data);
- _syncEmailDoneState(em.uid, data.is_answered);
- _syncEmailReadState(em.uid, true);
- const subjEl = modal.querySelector('.email-window-subject');
- if (subjEl && data.subject) subjEl.textContent = data.subject;
- // Build recipient chips the same way the inline reader does so the
- // standalone viewer looks/feels exactly like a real email view.
- const _chipsFor = (addrs) => {
- if (!addrs) return '';
- const list = _splitRecipientList(addrs);
- return list.map(a => {
- const name = _extractName(a);
- return _recipientChipHtml(a, name);
- }).join('');
- };
- const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip');
- let attsHtml = '';
- try { attsHtml = _buildAttsHtmlFor(em.uid, data); } catch {}
- // Repurpose bodyEl as a full email-card-reader so the inline reader's
- // CSS applies (sized header, action buttons in two rows, etc.).
- bodyEl.classList.add('email-card-reader');
- bodyEl.classList.remove('email-card-reader-loading');
- _stampReaderContext(bodyEl, { ...em, ...data }, useFolder, state._libAccountId);
- _markEmailReaderActive(bodyEl);
- bodyEl.style.padding = '0';
- bodyEl.innerHTML = `
-
- ${attsHtml}
-
${_safeRenderEmailBody(data)}
- `;
- _markEmailReaderActive(bodyEl);
- _wireRecipientChips(bodyEl);
- // Wire all the same action handlers the inline reader has.
- _wireEmailAttachmentWrap(bodyEl, useFolder);
- _wireEmailInlineImages(bodyEl);
- _loadDeferredAttachmentsIntoReader(bodyEl, em.uid, useFolder, data, !!em.has_attachments);
- _maybeAutoTranslateEmail(bodyEl);
- bodyEl.querySelector('[data-act="reply"]')?.addEventListener('click', async (ev) => {
- ev.stopPropagation();
- _snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal'));
- if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply' });
- });
- bodyEl.querySelector('[data-act="reply-all"]')?.addEventListener('click', async (ev) => {
- ev.stopPropagation();
- _snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal'));
- if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply-all' });
- });
- bodyEl.querySelector('[data-act="ai-reply"]')?.addEventListener('click', (ev) => _handleAiReplyButton(ev, em, data));
- bodyEl.querySelector('[data-act="forward"]')?.addEventListener('click', async (ev) => {
- ev.stopPropagation();
- if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'forward' });
- });
- bodyEl.querySelector('[data-act="summarize"]')?.addEventListener('click', async (ev) => {
- ev.stopPropagation();
- try { await _summarizeEmail(bodyEl, data, ev.currentTarget); } catch {}
- });
- _wireMetaToggle(bodyEl);
- _wireReaderActionOverflow(bodyEl);
- bodyEl.querySelector('[data-act="more"]')?.addEventListener('click', (ev) => {
- ev.stopPropagation();
- // Use a synthetic "card" — the more-menu only needs the anchor
- // element and the email data. The card param is mostly used to find
- // the next sibling; the standalone window has none so we just pass
- // bodyEl as a stand-in.
- try { _showReaderMoreMenu(em, modal, bodyEl, ev.currentTarget, data); } catch {}
- });
- } catch (err) {
- bodyEl.innerHTML = `
Failed to load: ${_esc(String(err))}
`;
- }
-}
-
-async function _summarizeEmail(reader, data, btn) {
- const body = reader.querySelector('.email-reader-body');
- if (!body) return;
-
- // If a summary panel already exists, toggle: hide/show
- const existing = body.querySelector('.email-summary-panel');
- if (existing) {
- if (existing.style.display === 'none') {
- existing.style.display = '';
- if (btn) {
- btn.classList.add('active');
- btn.querySelector('.btn-label').textContent = 'Summary';
- }
- } else {
- existing.style.display = 'none';
- if (btn) {
- btn.classList.remove('active');
- btn.querySelector('.btn-label').textContent = 'Summary';
- }
- }
- return;
- }
-
- // No panel yet. If the email has no cached AI summary, show a placeholder
- // "not generated — create now?" prompt instead of firing the LLM immediately.
- // This avoids accidental LLM spend and makes the state explicit to the user.
- if (!data.cached_summary) {
- const prompt = document.createElement('div');
- prompt.className = 'email-summary-panel';
- prompt.innerHTML = `
-
-
No AI summary generated.
`;
- body.insertBefore(prompt, body.firstChild);
- if (btn) {
- btn.classList.add('active');
- const label = btn.querySelector('.btn-label');
- if (label) label.textContent = 'Summary';
- }
- // No Cancel button — toggling the Summary button again hides this panel
- // (handled by the existing-panel branch above), so it'd be redundant.
- prompt.querySelector('[data-act="summary-generate"]').addEventListener('click', async (ev) => {
- ev.stopPropagation();
- prompt.remove();
- await _generateSummary(reader, data, btn);
- });
- return;
- }
-
- // Cached summary exists — show it immediately.
- await _generateSummary(reader, data, btn);
-}
-
-async function _generateSummary(reader, data, btn) {
- const body = reader.querySelector('.email-reader-body');
- if (!body) return;
-
- const panel = document.createElement('div');
- panel.className = 'email-summary-panel';
- panel.innerHTML =
- ''
- + '
';
- if (_summaryCollapsedPref()) panel.classList.add('collapsed');
- body.insertBefore(panel, body.firstChild);
- const _genToggle = panel.querySelector('.email-summary-toggle');
- if (_genToggle) {
- const _genFlip = () => {
- panel.classList.toggle('collapsed');
- _setSummaryCollapsedPref(panel.classList.contains('collapsed'));
- };
- _genToggle.addEventListener('click', (ev) => { ev.stopPropagation(); _genFlip(); });
- _genToggle.addEventListener('keydown', (ev) => {
- if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); _genFlip(); }
- });
- }
-
- const sp = spinnerModule.createWhirlpool(18);
- const content = panel.querySelector('.email-summary-content');
- content.appendChild(sp.element);
-
- if (btn) btn.disabled = true;
- try {
- const res = await fetch(`${API_BASE}/api/email/summarize`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- body: data.body,
- subject: data.subject,
- from: `${data.from_name} <${data.from_address}>`,
- // Send identifiers so the backend can fetch the raw message and
- // pull attachment text for the summary (PDFs, invoices, etc.).
- uid: data.uid || '',
- folder: state._libFolder || 'INBOX',
- message_id: data.message_id || '',
- account_id: data.account_id || '',
- }),
- });
- const result = await res.json();
- sp.destroy();
- content.innerHTML = '';
- if (result.success && result.summary) {
- content.textContent = result.summary;
- if (btn) {
- btn.classList.add('active');
- const label = btn.querySelector('.btn-label');
- if (label) label.textContent = 'Summary';
- }
- } else {
- _renderEmailSummaryError(content, result);
- }
- } catch (e) {
- sp.destroy();
- _renderEmailSummaryError(content, null);
- if (uiModule) uiModule.showError?.('Failed to summarize');
- } finally {
- if (btn) btn.disabled = false;
- }
-}
-
-function _emailBodyTextForTranslate(reader) {
- const body = reader?.querySelector?.('.email-reader-body');
- if (!body) return '';
- const clone = body.cloneNode(true);
- clone.querySelectorAll('.email-summary-panel, details.email-quote-fold, details.email-sig-fold').forEach(n => n.remove());
- return (clone.innerText || clone.textContent || '').trim();
-}
-
-async function _translateEmail(reader, language, opts = {}) {
- const body = reader?.querySelector?.('.email-reader-body');
- if (!body) return;
- const existing = body.querySelector('.email-translation-panel');
- if (existing) {
- if (opts.auto && !opts.force) return;
- existing.remove();
- }
- const targetLanguage = language || 'English';
- const sourceText = _emailBodyTextForTranslate(reader);
- if (!sourceText) {
- try { uiModule?.showError?.('No email body to translate'); } catch {}
- return;
- }
-
- body.querySelectorAll('.email-translation-panel').forEach(p => p.remove());
- const panel = document.createElement('div');
- panel.className = 'email-summary-panel email-translation-panel';
- panel.innerHTML =
- ''
- + '
Translating...
';
- body.insertBefore(panel, body.firstChild);
- const translationToggle = panel.querySelector('.email-summary-toggle');
- if (translationToggle) {
- const flip = () => panel.classList.toggle('collapsed');
- translationToggle.addEventListener('click', (ev) => { ev.stopPropagation(); flip(); });
- translationToggle.addEventListener('keydown', (ev) => {
- if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); flip(); }
- });
- }
-
- const content = panel.querySelector('.email-summary-content');
- const sp = spinnerModule.createWhirlpool(18);
- content.querySelector('.email-translation-spinner')?.appendChild(sp.element);
- try {
- const res = await fetch(`${API_BASE}/api/email/translate`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- body: sourceText,
- subject: reader.dataset.emailSubject || '',
- from: reader.dataset.emailFrom || '',
- target_language: targetLanguage,
- auto: !!opts.auto,
- }),
- });
- const result = await res.json().catch(() => ({}));
- sp.destroy();
- content.innerHTML = '';
- if (res.ok && result.success && result.same_language) {
- panel.remove();
- } else if (res.ok && result.success && result.translation) {
- content.textContent = String(result.translation || '')
- .replace(/^\s*<<
>>\s*/i, '')
- .replace(/\s*<<>>\s*$/i, '')
- .trim();
- } else {
- panel.remove();
- try { uiModule?.showError?.(result.error || 'Failed to translate'); } catch {}
- }
- } catch (_) {
- sp.destroy();
- panel.remove();
- try { uiModule?.showError?.('Failed to translate'); } catch {}
- }
-}
-
-async function _maybeAutoTranslateEmail(reader) {
- if (reader) reader.dataset.autoTranslateChecked = '1';
-}
-
-// Keep an email ⋮ dropdown inside the viewport: when it would spill past the
-// bottom (e.g. an email low on a phone screen), flip it above the anchor if
-// there's more room up there, and cap height + scroll if it still overflows.
-function _fitEmailDropdown(dropdown, rect) {
- requestAnimationFrame(() => {
- const margin = 8;
- // Horizontal clamp — keep the dropdown inside the viewport regardless of
- // whether it was anchored via left or right. Needed now that some
- // triggers (e.g. the right-aligned bulk "Actions" button) sit close to
- // the right edge, where a left-anchored menu would spill off-screen.
- const dw = dropdown.offsetWidth;
- const curLeft = dropdown.getBoundingClientRect().left;
- if (curLeft + dw > window.innerWidth - margin) {
- dropdown.style.left = Math.max(margin, window.innerWidth - margin - dw) + 'px';
- dropdown.style.right = 'auto';
- } else if (curLeft < margin) {
- dropdown.style.left = margin + 'px';
- dropdown.style.right = 'auto';
- }
- // Vertical fit — flip up or cap+scroll if it doesn't fit below.
- const dh = dropdown.offsetHeight;
- const below = window.innerHeight - rect.bottom - margin;
- const above = rect.top - margin;
- if (dh <= below) return; // fits below as-is
- if (above > below) { // flip upward
- dropdown.style.top = 'auto';
- dropdown.style.bottom = (window.innerHeight - rect.top + 4) + 'px';
- if (dh > above) { dropdown.style.maxHeight = above + 'px'; dropdown.style.overflowY = 'auto'; }
- } else { // keep below, cap + scroll
- dropdown.style.maxHeight = below + 'px';
- dropdown.style.overflowY = 'auto';
- }
- });
-}
-
-function _fitReaderActions(meta) {
- const row = meta?.querySelector(':scope > .email-reader-actions-inline');
- if (!row) return;
- const candidates = row.querySelectorAll(':scope > .reader-icon-btn:not([data-act="more"]), :scope > :not(.email-reader-more-wrap) > .reader-icon-btn');
- candidates.forEach(button => button.classList.remove('reader-action-overflowed'));
- row.classList.remove('email-reader-actions-compact');
- const buttonsWidth = Array.from(row.children).reduce((sum, child) => sum + child.getBoundingClientRect().width, 0)
- + Math.max(0, row.children.length - 1) * 4;
- const compact = buttonsWidth > Math.max(190, meta.clientWidth - 150);
- row.classList.toggle('email-reader-actions-compact', compact);
- if (compact) {
- // Keep the two common drafting actions visible on narrow screens. Put
- // the less frequent recipient variants in More first.
- row.querySelectorAll('[data-act="reply-all"], [data-act="forward"]')
- .forEach(button => button.classList.add('reader-action-overflowed'));
- }
-}
-
-const _readerActionFitObserver = typeof ResizeObserver === 'function'
- ? new ResizeObserver(entries => {
- for (const entry of entries) _fitReaderActions(entry.target);
- })
- : null;
-
-function _wireReaderActionOverflow(reader) {
- const meta = reader?.querySelector('.email-reader-meta');
- if (!meta) return;
- requestAnimationFrame(() => _fitReaderActions(meta));
- _readerActionFitObserver?.observe(meta);
-}
-
-function _showReaderMoreMenu(em, card, reader, anchor, data) {
- // Toggle: if a dropdown for THIS anchor is already open, close it.
- const existing = document.querySelector('.email-card-dropdown');
- if (existing && existing._anchor === anchor) {
- dismissOrRemove(existing);
- return;
- }
- // Otherwise close any other open dropdown (its own teardown clears its
- // anchor's active state) before opening a fresh one.
- document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
-
- const dropdown = document.createElement('div');
- dropdown.className = 'email-card-dropdown';
- dropdown._anchor = anchor;
- anchor.classList.add('reader-more-active');
- const rect = anchor.getBoundingClientRect();
- dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;right:${window.innerWidth - rect.right}px;`;
-
- const _icon = (svg) => `${svg}`;
- const _unreadIcon = '';
- const _archIcon = '';
- const _spamIcon = '';
- const _trashIcon = '';
- const _deleteForeverIcon = '';
- const _bellIcon = '';
- const _newTabIcon = '';
- const _checkIcon = '';
- const _translateIcon = '';
-
- const closeAndRemove = async () => {
- // Pick the next neighbour BEFORE we re-render so we know which email to
- // jump to. Prefer the next card; fall back to the previous one if this
- // was the last card.
- const sibling = _findSiblingEmailCard(card, +1) || _findSiblingEmailCard(card, -1);
- const nextUid = sibling ? sibling.dataset.uid : null;
- await _animateEmailCardRemoval([em.uid]);
- state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid));
- _renderGrid();
- _libCacheWriteBack();
- if (!nextUid) return;
- // After _renderGrid, the card nodes are fresh — re-resolve and expand.
- 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) {
- _toggleCardPreview(nextCard, nextEm);
- nextCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
- }
- };
-
- const _bubblesIcon = '';
- const _contactIcon = '';
- // Three groups separated by dividers:
- // 1. Open / Mark Unread / Remind — the per-email view actions
- // 2. Save sender / Not Done / Archive — non-destructive state changes
- // 3. Move to Spam / Move to Trash / Delete — destructive
- const overflowActions = Array.from(reader.querySelectorAll('.reader-action-overflowed')).map(button => ({
- label: button.querySelector('.reader-btn-label')?.textContent?.trim() || button.title || 'Action',
- icon: button.querySelector('svg')?.outerHTML || '',
- button,
- action: (positionAnchor) => {
- if (button.dataset.act === 'ai-reply') {
- _handleAiReplyButton({
- currentTarget: button,
- stopPropagation() {},
- }, em, data, positionAnchor || button);
- } else {
- button.click();
- }
- },
- }));
- const actions = [
- ...overflowActions,
- ...(overflowActions.length ? [{ separator: true }] : []),
- {
- label: 'Open in new tab',
- icon: _newTabIcon,
- action: async () => {
- const folder = state._libFolder || 'INBOX';
- await _openEmailAsTab(em, folder);
- },
- },
- {
- label: 'Remind to reply',
- icon: _bellIcon,
- submenu: 'remind',
- },
- {
- label: 'Translate',
- icon: _translateIcon,
- submenu: 'translate',
- },
- { separator: true },
- {
- label: em.is_read ? 'Mark as Unread' : 'Mark as Read',
- icon: _unreadIcon,
- action: async () => {
- const newRead = !em.is_read;
- _syncEmailReadState(em.uid, newRead);
- try {
- if (newRead) {
- await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
- } else {
- await fetch(`${API_BASE}/api/email/mark-unread/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
- }
- } catch (e) { console.error(e); }
- _renderGrid();
- },
- },
- {
- // Favorite (pin to top). Same bookmark glyph we use for the
- // sidebar-pin / favorites filter so the visual language stays
- // consistent. Toggling updates em.is_flagged and re-sorts via
- // _renderGrid (favorited rows are always pinned at the top).
- label: em.is_flagged ? 'Unfavorite' : 'Favorite (pin to top)',
- icon: '',
- action: async () => {
- const next = !em.is_flagged;
- em.is_flagged = next;
- _renderGrid();
- try {
- await fetch(`${API_BASE}/api/email/flag/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&on=${next ? 'true' : 'false'}`, { method: 'POST' });
- } catch (e) {
- // Roll back the optimistic flip if the server didn't take it.
- em.is_flagged = !next;
- _renderGrid();
- console.error('Failed to toggle favorite:', e);
- }
- },
- },
- {
- label: em.is_answered ? 'Mark as Not Done' : 'Mark as Done',
- icon: _checkIcon,
- action: async () => {
- const newState = !em.is_answered;
- em.is_answered = newState;
- if (newState) {
- _clearDoneResponseTagsLocal(em);
- _syncEmailReadState(em.uid, true);
- }
- try {
- if (newState) {
- await fetch(`${API_BASE}/api/email/mark-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
- await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
- } else {
- await fetch(`${API_BASE}/api/email/clear-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
- }
- } catch (e) { console.error('Failed to toggle done:', e); }
- _renderGrid();
- },
- },
- {
- label: 'Move to Archive',
- icon: _archIcon,
- action: async () => {
- try {
- await fetch(`${API_BASE}/api/email/archive/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
- } catch (e) { console.error(e); }
- await closeAndRemove();
- },
- },
- {
- // Save the sender to CardDAV contacts. Pulls name + address off the
- // list-item (em); falls back to splitting the local-part for a name.
- label: 'Save sender to contacts',
- icon: _contactIcon,
- action: async () => {
- const email = (em.from_address || em.from || '').trim();
- if (!email) {
- import('./ui.js?v=20260916largetoolscroll1').then(m => m.showError && m.showError('No sender address')).catch(() => {});
- return;
- }
- const name = (em.from_name || '').trim() || email.split('@')[0];
- try {
- const r = await fetch(`${API_BASE}/api/contacts/add`, {
- method: 'POST', credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ name, email }),
- });
- const d = await r.json();
- import('./ui.js?v=20260916largetoolscroll1').then(m => {
- if (!m.showToast) return;
- if (d.success && d.message === 'Already exists') m.showToast('Already in contacts');
- else if (d.success) m.showToast('Saved to contacts');
- else m.showError && m.showError('Failed to save contact');
- }).catch(() => {});
- } catch (_) {
- import('./ui.js?v=20260916largetoolscroll1').then(m => m.showError && m.showError('Failed to save contact')).catch(() => {});
- }
- },
- },
- { separator: true },
- {
- label: 'Move to Spam',
- icon: _spamIcon,
- action: async () => {
- try {
- await fetch(`${API_BASE}/api/email/move/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&dest=Junk`, { method: 'POST' });
- } catch (e) { console.error(e); }
- await closeAndRemove();
- },
- },
- {
- label: 'Move to Trash',
- icon: _trashIcon,
- action: async () => {
- const busy = _showEmailDeleteOverlay(card);
- await busy?.ready;
- 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 (e) {
- console.error(e);
- busy?.remove?.();
- showToast('Failed to delete email');
- return;
- }
- busy?.remove?.();
- await closeAndRemove();
- },
- },
- {
- label: 'Delete Permanently',
- icon: _deleteForeverIcon,
- danger: true,
- action: async () => {
- const subject = em.subject || '(no subject)';
- const ok = await styledConfirm(
- `Permanently delete "${subject}"? This cannot be undone.`,
- { confirmText: 'Delete', cancelText: 'Cancel', danger: true }
- );
- if (!ok) return;
- const busy = _showEmailDeleteOverlay(card);
- await busy?.ready;
- try {
- const response = await fetch(`${API_BASE}/api/email/delete-permanent/${encodeURIComponent(em.uid)}?${_emailMutationQuery(em)}`, { method: 'DELETE' });
- await _requireSuccessfulEmailMutation(response, 'Failed to delete email');
- } catch (e) {
- console.error(e);
- busy?.remove?.();
- showToast('Failed to delete email');
- return;
- }
- busy?.remove?.();
- await closeAndRemove();
- },
- },
- ];
-
- for (const a of actions) {
- if (a.separator) {
- const sep = document.createElement('div');
- sep.className = 'dropdown-divider';
- dropdown.appendChild(sep);
- continue;
- }
- const item = document.createElement('div');
- item.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
- const arrow = a.submenu ? '›' : '';
- item.innerHTML = _icon(a.icon) + `${a.label}${arrow}`;
- item.addEventListener('click', (e) => {
- e.stopPropagation();
- if (a.submenu === 'remind') {
- _showLibRemindSubmenu(em, dropdown);
- return;
- }
- if (a.submenu === 'translate') {
- _showEmailTranslateSubmenu(reader, dropdown);
- return;
- }
- // A hidden overflowed button has a zero-sized client rect. Invoke the
- // AI chooser while the visible More item is still mounted, then close
- // the action menu after the chooser has captured its position.
- if (a.button?.dataset.act === 'ai-reply') {
- a.action(item);
- close();
- return;
- }
- close();
- a.action();
- });
- dropdown.appendChild(item);
- }
- // Mobile-only Cancel item — explicit close for touch users. CSS hides it
- // on desktop where outside-click already dismisses cleanly.
- const _cancelIco = '';
- const cancelItem = document.createElement('div');
- cancelItem.className = 'dropdown-item-compact dropdown-cancel-mobile';
- cancelItem.innerHTML = _icon(_cancelIco) + 'Cancel';
- cancelItem.addEventListener('click', (e) => {
- e.stopPropagation();
- close();
- });
- dropdown.appendChild(cancelItem);
-
- document.body.appendChild(dropdown);
- _fitEmailDropdown(dropdown, rect);
- const close = bindMenuDismiss(dropdown, () => {
- dropdown.remove();
- anchor.classList.remove('reader-more-active');
- }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
-}
-
-function _showCardMenu(em, anchor) {
- const openMenu = document.querySelector('.email-card-dropdown:not(.email-bulk-menu)');
- if (openMenu && openMenu._emailMenuAnchor === anchor) {
- dismissOrRemove(openMenu);
- return;
- }
- document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
-
- const dropdown = document.createElement('div');
- dropdown.className = 'email-card-dropdown';
- dropdown._emailMenuAnchor = anchor;
- const rect = anchor.getBoundingClientRect();
- dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:140px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;right:${window.innerWidth - rect.right}px;`;
-
- const _icon = (svg) => `${svg}`;
- const _replyIcon = '';
- const _archIcon = '';
- const _delIcon = '';
- const _unreadIcon = '';
- const _checkIcon = '';
- const _cardBellIcon = '';
-
- const isSentFolder = /sent/i.test(state._libFolder);
-
- const _newTabIcon = '';
- const actions = [
- { label: 'Open', icon: _replyIcon, action: async () => {
- // Just expand inline (same as tapping the row).
- const card = anchor.closest('.doclib-card');
- if (card && !card.classList.contains('doclib-card-expanded')) {
- await _toggleCardPreview(card, em);
- }
- }},
- { label: 'Open in new tab', icon: _newTabIcon, action: async () => {
- // Open this email as its own in-app modal that registers a dock
- // chip — multiple emails can be opened simultaneously, each gets
- // its own chip in the minimized dock.
- const folder = state._libFolder || 'INBOX';
- await _openEmailAsTab(em, folder);
- }},
- { label: 'Remind to reply', icon: _cardBellIcon, submenu: 'remind' },
- ];
-
- if (!isSentFolder) {
- // Source of truth = the visible "active" class on the card's done
- // check, so the menu label and the actual toggle behaviour can't
- // disagree with what the user sees.
- const _cardForLabel = anchor.closest('.doclib-card');
- const _checkForLabel = _cardForLabel ? _cardForLabel.querySelector('.email-card-done') : null;
- const _currentlyDone = _checkForLabel ? _checkForLabel.classList.contains('active') : !!em.is_answered;
- actions.push({
- label: _currentlyDone ? 'Not Done' : 'Done',
- icon: _checkIcon,
- action: async () => {
- const card = anchor.closest('.doclib-card');
- const check = card ? card.querySelector('.email-card-done') : null;
- const wasActive = check ? check.classList.contains('active') : !!em.is_answered;
- const newState = !wasActive;
- em.is_answered = newState;
- if (newState) {
- _clearDoneResponseTagsLocal(em);
- _syncEmailReadState(em.uid, true); // mark-done implies mark-read
- }
- try {
- if (newState) {
- await fetch(`${API_BASE}/api/email/mark-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
- await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
- } else {
- await fetch(`${API_BASE}/api/email/clear-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
- }
- } catch (e) { console.error('Failed to toggle done:', e); }
- if (card) {
- if (check) check.classList.toggle('active', newState);
- if (newState) {
- _syncEmailReadState(em.uid, true);
- card.querySelectorAll('.email-tag-urgent, .email-tag-reply-soon, .email-tag-action-needed').forEach(n => n.remove());
- }
- }
- },
- });
- actions.push({
- label: em.is_flagged ? 'Unfavorite' : 'Favorite (pin to top)',
- icon: '',
- action: async () => {
- const next = !em.is_flagged;
- em.is_flagged = next;
- _renderGrid();
- try {
- await fetch(`${API_BASE}/api/email/flag/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&on=${next ? 'true' : 'false'}`, { method: 'POST' });
- } catch (e) {
- em.is_flagged = !next;
- _renderGrid();
- console.error('Failed to toggle favorite:', e);
- }
- },
- });
- actions.push({
- label: 'Archive',
- icon: _archIcon,
- action: async () => {
- await fetch(`${API_BASE}/api/email/archive/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
- await _animateEmailCardRemoval([em.uid]);
- state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid));
- _renderGrid();
- _libCacheWriteBack();
- },
- });
- } else {
- actions.push({
- label: em.is_flagged ? 'Unfavorite' : 'Favorite (pin to top)',
- icon: '',
- action: async () => {
- const next = !em.is_flagged;
- em.is_flagged = next;
- _renderGrid();
- try {
- await fetch(`${API_BASE}/api/email/flag/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&on=${next ? 'true' : 'false'}`, { method: 'POST' });
- } catch (e) {
- em.is_flagged = !next;
- _renderGrid();
- console.error('Failed to toggle favorite:', e);
- }
- },
- });
- actions.push({
- label: 'Archive',
- icon: _archIcon,
- action: async () => {
- await fetch(`${API_BASE}/api/email/archive/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
- await _animateEmailCardRemoval([em.uid]);
- state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid));
- _renderGrid();
- _libCacheWriteBack();
- },
- });
- }
-
- // "Select" — switch to multi-select mode with THIS email pre-selected so
- // the user can quickly fan-out to neighbours with the bulk bar.
- // Match the chat-sidebar Select icon — a thick bullet character reads
- // much heavier than a small SVG circle. Nudged up 2px so its visual
- // center lines up with the SVG icons above (which sit a bit higher).
- const _selectIcon = SELECT_MENU_ICON;
- actions.push({
- label: 'Select',
- icon: _selectIcon,
- action: () => {
- state._selectMode = true;
- state._selectedUids.add(em.uid);
- _updateBulkBar();
- _renderGrid();
- },
- });
-
- actions.push(
- { label: 'Delete', icon: _delIcon, danger: true, action: async () => {
- const subject = em.subject || '(no subject)';
- const ok = await styledConfirm(`Delete "${subject}"?`, { confirmText: 'Delete', cancelText: 'Cancel', danger: true });
- if (!ok) return;
- const card = document.querySelector(`#email-lib-grid .doclib-card[data-uid="${CSS.escape(String(em.uid))}"]`);
- const busy = _showEmailDeleteOverlay(card);
- await busy?.ready;
- 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 (e) {
- busy?.remove?.();
- showToast(e?.message || 'Could not move email to Trash');
- return;
- }
- busy?.remove?.();
- await _animateEmailCardRemoval([em.uid]);
- state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid));
- _renderGrid();
- _libCacheWriteBack();
- }},
- );
-
- const orderedActions = orderActionMenuItems(actions);
- let addedActionDivider = false;
- for (const [index, a] of orderedActions.entries()) {
- if (!addedActionDivider && index > 0 && actionMenuRank(a) >= 700) {
- const divider = document.createElement('div');
- divider.className = 'dropdown-divider';
- dropdown.appendChild(divider);
- addedActionDivider = true;
- }
- const item = document.createElement('div');
- item.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
- const arrow = a.submenu ? '›' : '';
- item.innerHTML = _icon(a.icon) + `${a.label}${arrow}`;
- item.addEventListener('click', (e) => {
- e.stopPropagation();
- if (a.submenu === 'remind') {
- _showLibRemindSubmenu(em, dropdown);
- return;
- }
- close();
- a.action();
- });
- dropdown.appendChild(item);
- }
- // Mobile-only Cancel item — explicit close for touch users. CSS hides it
- // on desktop where outside-click already dismisses cleanly.
- const _cancelIco = '';
- const cancelItem = document.createElement('div');
- cancelItem.className = 'dropdown-item-compact dropdown-cancel-mobile';
- cancelItem.innerHTML = _icon(_cancelIco) + 'Cancel';
- cancelItem.addEventListener('click', (e) => {
- e.stopPropagation();
- close();
- });
- dropdown.appendChild(cancelItem);
-
- document.body.appendChild(dropdown);
- _fitEmailDropdown(dropdown, rect);
- const close = bindMenuDismiss(dropdown, () => {
- dropdown.remove();
- anchor.classList.remove('reader-more-active');
- }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
-}
-
-// Bulk "Actions" dropdown for select mode — Delete is a separate visible button.
-function _showBulkActionsMenu(anchor) {
- document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
- const dropdown = document.createElement('div');
- dropdown.className = 'email-card-dropdown email-bulk-menu';
- const rect = anchor.getBoundingClientRect();
- dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:160px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:${rect.left}px;`;
- const _readIco = '';
- const _unreadIco = '';
- const _doneIco = '';
- const _exportIco = '';
- const items = [
- { label: 'Export Attachments', icon: _exportIco, action: () => _exportSelectedAttachments() },
- { label: 'Done', icon: _doneIco, action: () => _bulkAction('done') },
- { label: 'Mark Read', icon: _readIco, action: () => _bulkAction('read') },
- { label: 'Mark Unread', icon: _unreadIco, action: () => _bulkAction('unread') },
- ];
- for (const a of items) {
- const it = document.createElement('div');
- it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
- it.innerHTML = `${a.icon}${a.label}`;
- it.addEventListener('click', (e) => { e.stopPropagation(); close(); a.action(); });
- dropdown.appendChild(it);
- }
- // Mobile-only Cancel — matches the per-card and sidebar dropdowns.
- const _cancelIco2 = '';
- const cancelIt = document.createElement('div');
- cancelIt.className = 'dropdown-item-compact dropdown-cancel-mobile';
- cancelIt.innerHTML = `${_cancelIco2}Cancel`;
- cancelIt.addEventListener('click', (e) => {
- e.stopPropagation();
- close();
- // Cancel inside the bulk-Actions menu also exits select mode — matches the
- // documents bulk dropdown.
- state._selectMode = false;
- state._selectedUids.clear();
- _updateBulkBar();
- _renderGrid();
- });
- dropdown.appendChild(cancelIt);
- document.body.appendChild(dropdown);
- _fitEmailDropdown(dropdown, rect);
- const close = bindMenuDismiss(dropdown, () => {
- dropdown.remove();
- }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
-}
-
-function _updateBulkBar() {
- const bar = document.getElementById('email-lib-bulk');
- const grid = document.getElementById('email-lib-grid');
- if (grid) grid.classList.toggle('email-selecting', !!state._selectMode);
- const selectBtn = document.getElementById('email-lib-select-btn');
- if (bar) bar.classList.toggle('hidden', !state._selectMode);
- if (selectBtn) {
- selectBtn.textContent = state._selectMode ? 'Cancel' : 'Select';
- selectBtn.classList.toggle('active', state._selectMode);
- }
- const count = document.getElementById('email-lib-selected-count');
- if (count) count.textContent = `${state._selectedUids.size} Selected`;
- const all = document.getElementById('email-lib-select-all');
- if (all) all.checked = state._libEmails.length > 0 && state._libEmails.every(e => state._selectedUids.has(e.uid));
- // When something's selected, brighten Actions to the same full --fg color as
- // the "N Selected" count (the button is a dimmer 60% --fg by default).
- const actions = document.getElementById('email-lib-bulk-actions');
- if (actions) actions.style.color = state._selectedUids.size > 0 ? 'var(--fg)' : '';
- const deleteBtn = document.getElementById('email-lib-bulk-delete');
- if (deleteBtn) deleteBtn.style.color = state._selectedUids.size > 0 ? 'var(--red)' : '';
-}
-
-async function _bulkAction(action) {
- const uids = Array.from(state._selectedUids);
- if (uids.length === 0) return;
- let failedReadSync = 0;
- const failedUids = new Set();
- if (action === 'delete') {
- const ok = await styledConfirm(
- `Delete ${uids.length} selected email${uids.length === 1 ? '' : 's'}?`,
- { confirmText: 'Delete', cancelText: 'Cancel', danger: true },
- );
- if (!ok) return;
- // Deletion is intentionally optimistic. The user should not have to keep
- // the mail window open while IMAP moves a batch; refresh and restore the
- // cards only if the provider rejects the background operation.
- const removed = new Set(uids.map(uid => String(uid)));
- state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid)));
- state._selectedUids.clear();
- state._selectMode = false;
- _updateBulkBar();
- _renderGrid();
- _libCacheWriteBack();
- showToast(`Moved ${uids.length} email${uids.length === 1 ? '' : 's'} to Trash`);
- fetch(`${API_BASE}/api/email/delete-bulk`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ uids, folder: state._libFolder, account_id: state._libAccountId || null }),
- }).then(async (res) => {
- const data = await res.json().catch(() => null);
- if (!res.ok || data?.success === false || (data?.failed_uids || []).length) {
- throw new Error(data?.error || `${data?.failed_uids?.length || 0} emails could not be deleted`);
- }
- }).catch((error) => {
- console.error('Background bulk email delete failed:', error);
- showToast('Delete failed; restoring mailbox');
- _loadEmailsFresh({ force: true, useCache: false, refreshAfterCache: false }).catch(() => {});
- });
- return;
- }
-
- const deleteBtn = action === 'delete' ? document.getElementById('email-lib-bulk-delete') : null;
- const actionsBtn = document.getElementById('email-lib-bulk-actions');
- 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 originalDeleteHtml = deleteBtn?.innerHTML || '';
- const originalCountText = countEl?.textContent || '';
- let busySpinner = null;
- // Loading state for every bulk action, not just delete — large
- // selections (e.g. 90+ Dones) used to silently hammer the server
- // with sequential requests and the user got zero feedback. Now the
- // Actions button (or Delete button) shows a whirlpool + verb-ing
- // label, and the count surfaces progress.
- const verbing = {
- delete: 'Deleting',
- archive: 'Archiving',
- done: 'Marking done',
- read: 'Marking read',
- unread: 'Marking unread',
- }[action] || 'Updating';
- const targetBtn = action === 'delete' ? deleteBtn : actionsBtn;
- let originalTargetHtml = '';
- if (targetBtn) {
- originalTargetHtml = targetBtn.innerHTML;
- targetBtn.disabled = true;
- targetBtn.classList.add('email-bulk-loading');
- targetBtn.innerHTML = `${verbing}`;
- busySpinner = spinnerModule.create('', 'clean', 'whirlpool');
- const spEl = busySpinner.createElement();
- spEl.classList.add('email-bulk-whirlpool');
- targetBtn.appendChild(spEl);
- busySpinner.start();
- }
- if (action !== 'delete' && deleteBtn) deleteBtn.disabled = true;
- if (action === 'delete' && actionsBtn) actionsBtn.disabled = true;
- if (cancelBtn) cancelBtn.disabled = true;
- if (selectAll) selectAll.disabled = true;
- if (countEl) countEl.textContent = `${verbing} ${uids.length}…`;
- const deleteOverlays = action === 'delete'
- ? uids.map(uid => {
- const card = document.querySelector(`#email-lib-grid .doclib-card[data-uid="${CSS.escape(String(uid))}"]`);
- return _showEmailDeleteOverlay(card);
- }).filter(Boolean)
- : [];
- if (deleteOverlays.length) {
- await Promise.all(deleteOverlays.map(busy => busy.ready).filter(Boolean));
- }
-
- // Single-uid worker.
- const handleOne = async (uid) => {
- try {
- if (action === 'archive') {
- const res = await fetch(`${API_BASE}/api/email/archive/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
- const data = await res.json().catch(() => null);
- if (!res.ok || data?.success === false) throw new Error(data?.error || `HTTP ${res.status}`);
- } else if (action === 'delete') {
- // Bulk Delete matches the permanent-delete behavior used by the
- // library and albums. Moving a batch through provider Trash folders
- // is unreliable on several IMAP implementations.
- const res = await fetch(`${API_BASE}/api/email/delete-permanent/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' });
- const data = await res.json().catch(() => null);
- if (!res.ok || data?.success === false) throw new Error(data?.error || `HTTP ${res.status}`);
- } else if (action === 'done') {
- // uid may come back from the Set as a string while em.uid is
- // numeric (or vice versa) — coerce both sides so the in-memory
- // state actually flips and the post-loop re-render shows the
- // done checkmark.
- const em = state._libEmails.find(e => String(e.uid) === String(uid));
- if (em) {
- em.is_answered = true;
- em.is_read = true;
- _clearDoneResponseTagsLocal(em);
- }
- const ansRes = await fetch(`${API_BASE}/api/email/mark-answered/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
- const readRes = await fetch(`${API_BASE}/api/email/mark-read/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
- if (!ansRes.ok || !readRes.ok) throw new Error(`mark-done HTTP ${ansRes.status}/${readRes.status}`);
- } else if (action === 'read' || action === 'unread') {
- const endpoint = action === 'read' ? 'mark-read' : 'mark-unread';
- const res = await fetch(`${API_BASE}/api/email/${endpoint}/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
- let data = null;
- try { data = await res.json(); } catch (_) {}
- if (!res.ok || data?.success === false) {
- throw new Error(data?.error || `HTTP ${res.status}`);
- }
- _syncEmailReadState(uid, action === 'read');
- }
- } catch (e) {
- if (action === 'read' || action === 'unread') failedReadSync += 1;
- failedUids.add(String(uid));
- console.error(`Failed to ${action} ${uid}:`, e);
- }
- };
-
- try {
- // Run in parallel with a concurrency cap so 92 emails don't take
- // 30 seconds sequentially but we also don't open 92 simultaneous
- // connections.
- const CONCURRENCY = 6;
- const queue = uids.slice();
- let inFlight = 0;
- let nextSlot = 0;
- let finishedCount = 0;
- let bulkDeletedUids = null;
- if (action === 'delete') {
- const res = await fetch(`${API_BASE}/api/email/delete-bulk`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ uids, folder: state._libFolder, account_id: state._libAccountId || null }),
- });
- const data = await res.json().catch(() => null);
- if (!res.ok || data?.success === false) throw new Error(data?.error || `HTTP ${res.status}`);
- for (const uid of data?.failed_uids || []) failedUids.add(String(uid));
- bulkDeletedUids = (data?.deleted_uids || []).map(String);
- if (countEl) countEl.textContent = `${verbing} ${bulkDeletedUids.length}/${uids.length}…`;
- } else await new Promise((resolve) => {
- const launch = () => {
- while (inFlight < CONCURRENCY && nextSlot < queue.length) {
- const uid = queue[nextSlot++];
- inFlight++;
- handleOne(uid).finally(() => {
- inFlight--;
- finishedCount++;
- if (countEl) countEl.textContent = `${verbing} ${finishedCount}/${queue.length}…`;
- if (nextSlot >= queue.length && inFlight === 0) resolve();
- else launch();
- });
- }
- if (queue.length === 0) resolve();
- };
- launch();
- });
-
- if (action === 'archive' || action === 'delete') {
- const successfulUids = bulkDeletedUids || uids.filter(uid => !failedUids.has(String(uid)));
- if (action === 'delete') {
- deleteOverlays.forEach(busy => busy.remove?.());
- }
- await _animateEmailCardRemoval(successfulUids);
- const removed = new Set(successfulUids.map(uid => String(uid)));
- state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid)));
- } else if (action === 'done' && state._libFilter === 'undone') {
- // The undone filter is a "show only not-done" view — after marking
- // selected emails done, they no longer match. Animate them out and
- // drop them from the local list so the view reflects the filter
- // instead of leaving freshly-done cards sitting there.
- await _animateEmailCardRemoval(uids);
- const removed = new Set(uids.map(uid => String(uid)));
- state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid)));
- }
- } finally {
- deleteOverlays.forEach(busy => busy.remove?.());
- if (busySpinner) busySpinner.destroy();
- // Restore whichever button we hijacked (delete vs actions).
- if (targetBtn) {
- targetBtn.disabled = false;
- targetBtn.classList.remove('email-bulk-loading');
- targetBtn.innerHTML = originalTargetHtml || targetBtn.innerHTML;
- }
- if (deleteBtn && deleteBtn !== targetBtn) {
- deleteBtn.disabled = false;
- deleteBtn.innerHTML = originalDeleteHtml || deleteBtn.innerHTML;
- }
- if (actionsBtn && actionsBtn !== targetBtn) actionsBtn.disabled = false;
- if (cancelBtn) cancelBtn.disabled = false;
- if (selectAll) selectAll.disabled = false;
- if (countEl) countEl.textContent = originalCountText;
- }
- state._selectedUids.clear();
- state._selectMode = false;
- _updateBulkBar();
- _renderGrid();
- if (failedReadSync > 0) {
- showToast(`Failed to update ${failedReadSync} email${failedReadSync === 1 ? '' : 's'}`);
- }
- if (failedUids.size > 0 && (action === 'delete' || action === 'archive')) {
- showToast(`${failedUids.size} email${failedUids.size === 1 ? '' : 's'} could not be ${action === 'delete' ? 'deleted' : 'archived'}`);
- }
- // Sync successful local mutations into the SWR cache so reopen doesn't
- // briefly show the pre-bulk state.
- _libCacheWriteBack();
-}
-
-// _extractName lives in ./emailLibrary/utils.js
-
-function _aiReplyIcon(data) {
- return '';
-}
-
-function _summaryIcon(data) {
- const fill = data?.cached_summary ? 'var(--accent-primary, var(--red))' : 'currentColor';
- return ``;
-}
-
-async function _emailTranslateLanguage() {
- try {
- const res = await fetch(`${API_BASE}/api/email/config`);
- const cfg = await res.json();
- return cfg?.email_translate_language || 'English';
- } catch (_) {
- return 'English';
- }
-}
-
-async function _runAiReplyFromButton(btn, em, data, mode, noteHint = '') {
- _snapEmailModalToLeftSidebar(btn.closest('.modal'));
- btn.disabled = true;
- const orig = btn.innerHTML;
- let wp = null;
- try {
- wp = spinnerModule.createWhirlpool(14);
- wp.element.style.cssText = 'width:14px;height:14px;display:inline-block;vertical-align:middle;position:relative;top:-2px;';
- btn.innerHTML = '';
- btn.appendChild(wp.element);
- } catch (_) {}
- try {
- if (state._onEmailClick) {
- return await state._onEmailClick({ email: em, emailData: data, mode, noteHint });
- }
- return false;
- } finally {
- try { wp && wp.stop(); } catch (_) {}
- btn.disabled = false;
- btn.innerHTML = orig;
- }
-}
-
-const _AI_REPLY_CONTEXT_DRAFT_PREFIX = 'odysseus:email-ai-reply-context:v2:';
-let _aiReplyChoiceOutsideClose = null;
-
-function _aiReplyContextDraftKey(em, data) {
- const accountId = data?.account_id || em?.account_id || state._libAccountId || '';
- const folder = data?.folder || em?.folder || state._libFolder || 'INBOX';
- const uid = data?.uid || em?.uid || data?.message_id || em?.message_id || '';
- if (!uid) return '';
- return _AI_REPLY_CONTEXT_DRAFT_PREFIX
- + [accountId, folder, uid].map(value => encodeURIComponent(String(value || ''))).join(':');
-}
-
-function _loadAiReplyContextDraft(key) {
- if (!key) return '';
- try { return localStorage.getItem(key) || ''; } catch (_) { return ''; }
-}
-
-function _saveAiReplyContextDraft(key, value) {
- if (!key) return;
- try {
- const text = String(value || '');
- if (text.trim()) localStorage.setItem(key, text);
- else localStorage.removeItem(key);
- } catch (_) {}
-}
-
-function _clearAiReplyContextDraft(key) {
- if (!key) return;
- try { localStorage.removeItem(key); } catch (_) {}
-}
-
-function _closeAiReplyChoice() {
- if (_aiReplyChoiceOutsideClose) {
- document.removeEventListener('click', _aiReplyChoiceOutsideClose, true);
- _aiReplyChoiceOutsideClose = null;
- }
- document.querySelectorAll('.email-ai-reply-choice').forEach(el => {
- const input = el.querySelector('[data-note-input]');
- _saveAiReplyContextDraft(el.dataset.contextDraftKey || '', input?.value || '');
- el.remove();
- });
-}
-
-function _showAiReplyChoice(btn, em, data, positionAnchor = btn) {
- _closeAiReplyChoice();
- const rect = positionAnchor.getBoundingClientRect();
- const menu = document.createElement('div');
- menu.className = 'email-ai-reply-choice';
- /* Clamp width to viewport minus 16px margin so the menu never spills off
- the right edge on narrow mobile screens. */
- const menuMaxW = Math.min(220, window.innerWidth - 16);
- const left = Math.max(8, Math.min(rect.left, window.innerWidth - menuMaxW - 8));
- /* Vertical placement: prefer below the button, but flip above if
- there's not enough room (e.g. button near bottom of viewport).
- Estimated menu height is ~150px (textarea + buttons + padding). */
- const estHeight = 150;
- const spaceBelow = window.innerHeight - rect.bottom - 8;
- const spaceAbove = rect.top - 8;
- let top;
- if (spaceBelow >= estHeight || spaceBelow >= spaceAbove) {
- top = Math.max(8, Math.min(rect.bottom + 6, window.innerHeight - estHeight - 8));
- } else {
- top = Math.max(8, rect.top - estHeight - 6);
- }
- menu.style.cssText = [
- 'position:fixed',
- `left:${left}px`,
- `top:${top}px`,
- `max-width:${menuMaxW}px`,
- `max-height:${window.innerHeight - 16}px`,
- 'overflow:auto',
- 'box-sizing:border-box',
- `z-index:${topPortalZ()}`,
- 'display:flex',
- 'gap:6px',
- 'padding:6px',
- 'background:var(--bg,#111)',
- 'border:1px solid var(--border,#333)',
- 'border-radius:7px',
- 'box-shadow:0 8px 24px rgba(0,0,0,.28)',
- ].join(';');
- menu.innerHTML = `
-
- `;
- const noteInput = menu.querySelector('[data-note-input]');
- const contextDraftKey = _aiReplyContextDraftKey(em, data);
- menu.dataset.contextDraftKey = contextDraftKey;
- noteInput.value = _loadAiReplyContextDraft(contextDraftKey);
- noteInput.addEventListener('input', () => {
- _saveAiReplyContextDraft(contextDraftKey, noteInput.value || '');
- });
- setTimeout(() => noteInput.focus(), 0);
- menu.addEventListener('click', async (ev) => {
- const choice = ev.target.closest('[data-mode]');
- if (!choice) return;
- ev.preventDefault();
- ev.stopPropagation();
- const mode = choice.getAttribute('data-mode') || 'ai-reply-fast';
- const noteHint = (noteInput.value || '').trim();
- _saveAiReplyContextDraft(contextDraftKey, noteInput.value || '');
- _closeAiReplyChoice();
- const draftOpened = await _runAiReplyFromButton(btn, em, data, mode, noteHint);
- if (draftOpened === true) _clearAiReplyContextDraft(contextDraftKey);
- });
- // Esc closes the popover; ignore plain clicks inside the menu so the
- // textarea stays focused.
- menu.addEventListener('mousedown', (ev) => ev.stopPropagation());
- document.body.appendChild(menu);
- // Outside-click closer: only fires when the click target is OUTSIDE
- // the menu. The original handler closed on any click which made
- // focusing the textarea immediately dismiss the popover.
- const outsideClose = (ev) => {
- if (menu.contains(ev.target)) return;
- document.removeEventListener('click', outsideClose, true);
- if (_aiReplyChoiceOutsideClose === outsideClose) _aiReplyChoiceOutsideClose = null;
- _closeAiReplyChoice();
- };
- _aiReplyChoiceOutsideClose = outsideClose;
- setTimeout(() => {
- if (_aiReplyChoiceOutsideClose === outsideClose) {
- document.addEventListener('click', outsideClose, true);
- }
- }, 0);
-}
-
-function _handleAiReplyButton(ev, em, data, positionAnchor = ev.currentTarget) {
- ev.stopPropagation();
- const btn = ev.currentTarget;
- // First click on a cached email surfaces the cached draft. Second
- // click clears the cache and opens the Fast/Full + context menu so
- // the user can ask for a fresh draft (with new steering).
- if (data?.cached_ai_reply && !btn.dataset.shownOnce) {
- btn.dataset.shownOnce = '1';
- _runAiReplyFromButton(btn, em, data, 'ai-reply');
- return;
- }
- if (data?.cached_ai_reply) {
- data.cached_ai_reply = null;
- btn.dataset.shownOnce = '';
- }
- _showAiReplyChoice(btn, em, data, positionAnchor);
-}
-
-function _hasMultipleRecipients(data) {
- // Count distinct addresses in To + Cc (minus the current user). Empty
- // fallback when the user's address isn't yet known — no exclusion.
- const myAddress = (window._myEmailAddress || '').toLowerCase();
- const extractEmails = (str) => {
- if (!str) return [];
- return str.split(',')
- .map(s => {
- const m = s.match(/<([^>]+)>/);
- return (m ? m[1] : s).trim().toLowerCase();
- })
- .filter(e => e && e !== myAddress);
- };
- const recipients = new Set([
- ...extractEmails(data.to),
- ...extractEmails(data.cc),
- ]);
- // Sender counts as one other person too
- if (data.from_address && data.from_address.toLowerCase() !== myAddress) {
- recipients.add(data.from_address.toLowerCase());
- }
- return recipients.size > 1;
-}
-
-// _esc lives in ./emailLibrary/utils.js
-
-function _showEmailTranslateSubmenu(reader, parentDropdown) {
- parentDropdown.innerHTML = '';
- const header = document.createElement('div');
- header.className = 'dropdown-item-compact';
- header.style.cssText = 'opacity:0.5;font-size:10px;pointer-events:none;text-transform:uppercase;letter-spacing:0.5px;padding-top:6px;';
- header.innerHTML = 'Translate to';
- parentDropdown.appendChild(header);
-
- const customRow = document.createElement('div');
- customRow.className = 'dropdown-item-compact email-translate-custom-row';
- customRow.style.cssText = 'display:flex;gap:5px;align-items:center;padding:5px 7px;cursor:default;';
- customRow.innerHTML = `
-
-
- `;
- parentDropdown.appendChild(customRow);
- const input = customRow.querySelector('.email-translate-custom-input');
- const go = customRow.querySelector('.email-translate-custom-go');
- const runCustom = async () => {
- const language = (input?.value || '').trim();
- if (!language) {
- input?.focus();
- return;
- }
- parentDropdown.remove();
- await _translateEmail(reader, language);
- };
- customRow.addEventListener('click', e => e.stopPropagation());
- go?.addEventListener('click', async e => {
- e.stopPropagation();
- await runCustom();
- });
- input?.addEventListener('keydown', async e => {
- if (e.key === 'Enter') {
- e.preventDefault();
- e.stopPropagation();
- await runCustom();
- }
- });
- _emailTranslateLanguage().then(language => {
- if (input && !input.value) input.value = language || 'English';
- }).catch(() => {});
-
- const languages = ['English', 'Swedish', 'Japanese', 'Spanish', 'French', 'German'];
- for (const language of languages) {
- const item = document.createElement('div');
- item.className = 'dropdown-item-compact';
- item.innerHTML = `${language}`;
- item.addEventListener('click', async (e) => {
- e.stopPropagation();
- parentDropdown.remove();
- await _translateEmail(reader, language);
- });
- parentDropdown.appendChild(item);
- }
-}
-
-// ---- Reminder submenu (used by both email menus) ----
-function _showLibRemindSubmenu(em, parentDropdown) {
- parentDropdown.innerHTML = '';
- const header = document.createElement('div');
- header.className = 'dropdown-item-compact';
- header.style.cssText = 'opacity:0.5;font-size:10px;pointer-events:none;text-transform:uppercase;letter-spacing:0.5px;padding-top:6px;';
- header.innerHTML = 'Remind me';
- parentDropdown.appendChild(header);
-
- const now = new Date();
- const laterToday = new Date(now);
- const sixPm = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0);
- if (sixPm - now < 60*60*1000) laterToday.setTime(now.getTime() + 3*60*60*1000);
- else laterToday.setTime(sixPm.getTime());
- const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate()+1); tomorrow.setHours(8,0,0,0);
- const daysUntilMon = (8 - now.getDay()) % 7 || 7;
- const nextWeek = new Date(now); nextWeek.setDate(now.getDate()+daysUntilMon); nextWeek.setHours(8,0,0,0);
-
- const presets = [
- { label: 'Later today', sub: laterToday.toLocaleTimeString([], { hour:'numeric', minute:'2-digit' }), date: laterToday },
- { label: 'Tomorrow', sub: tomorrow.toLocaleTimeString([], { hour:'numeric', minute:'2-digit' }), date: tomorrow },
- { label: 'Next week', sub: nextWeek.toLocaleDateString([], { weekday:'short' }) + ' ' + nextWeek.toLocaleTimeString([], { hour:'numeric', minute:'2-digit' }), date: nextWeek },
- ];
- for (const p of presets) {
- const item = document.createElement('div');
- item.className = 'dropdown-item-compact';
- item.innerHTML = `${p.label}${p.sub}`;
- item.addEventListener('click', async (e) => {
- e.stopPropagation();
- parentDropdown.remove();
- await _createEmailReplyReminder(em, p.date);
- });
- parentDropdown.appendChild(item);
- }
- const customItem = document.createElement('div');
- customItem.className = 'dropdown-item-compact';
- customItem.innerHTML = 'Pick date and time…';
- customItem.addEventListener('click', (e) => {
- e.stopPropagation();
- parentDropdown.remove();
- const tmp = document.createElement('input');
- tmp.type = 'datetime-local';
- const def = new Date(tomorrow);
- const pad = n => String(n).padStart(2,'0');
- tmp.value = `${def.getFullYear()}-${pad(def.getMonth()+1)}-${pad(def.getDate())}T${pad(def.getHours())}:${pad(def.getMinutes())}`;
- tmp.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:99999;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:13px;';
- document.body.appendChild(tmp);
- tmp.focus();
- if (typeof tmp.showPicker === 'function') { try { tmp.showPicker(); } catch {} }
- tmp.addEventListener('change', async () => {
- if (tmp.value) await _createEmailReplyReminder(em, new Date(tmp.value));
- tmp.remove();
- });
- tmp.addEventListener('blur', () => setTimeout(() => tmp.remove(), 200));
- });
- parentDropdown.appendChild(customItem);
- // "Note" — prompts for free-text and saves it as a note without a
- // due_date, so no timer/reminder fires.
- const noteItem = document.createElement('div');
- noteItem.className = 'dropdown-item-compact';
- noteItem.innerHTML = 'Note';
- noteItem.addEventListener('click', (e) => {
- e.stopPropagation();
- parentDropdown.remove();
- _promptEmailNote(em);
- });
- parentDropdown.appendChild(noteItem);
-}
-
-function _promptEmailNote(em) {
- const overlay = document.createElement('div');
- overlay.style.cssText = 'position:fixed;inset:0;z-index:99998;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;padding:16px;';
- const card = document.createElement('div');
- card.style.cssText = 'background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:14px;min-width:280px;max-width:min(420px, 92vw);display:flex;flex-direction:column;gap:8px;box-shadow:0 12px 32px rgba(0,0,0,0.4);';
- const subject = em.subject || '(no subject)';
- card.innerHTML = `
- Note about ${_esc(subject)}
-
-
- `;
- overlay.appendChild(card);
- document.body.appendChild(overlay);
- const ta = card.querySelector('[data-note]');
- setTimeout(() => ta.focus(), 0);
- const close = () => overlay.remove();
- overlay.addEventListener('click', (ev) => { if (ev.target === overlay) close(); });
- card.querySelector('[data-act="cancel"]').addEventListener('click', close);
- card.querySelector('[data-act="save"]').addEventListener('click', async () => {
- const text = (ta.value || '').trim();
- if (!text) { ta.focus(); return; }
- close();
- await _createEmailReplyReminder(em, null, text);
- });
- ta.addEventListener('keydown', (ev) => {
- if (ev.key === 'Escape') close();
- else if ((ev.ctrlKey || ev.metaKey) && ev.key === 'Enter') card.querySelector('[data-act="save"]').click();
- });
-}
-
-async function _createEmailReplyReminder(em, dueDate, customText = '') {
- const pad = n => String(n).padStart(2,'0');
- const iso = dueDate
- ? `${dueDate.getFullYear()}-${pad(dueDate.getMonth()+1)}-${pad(dueDate.getDate())}T${pad(dueDate.getHours())}:${pad(dueDate.getMinutes())}`
- : null;
- const fullFrom = em.from || em.sender || '';
- // Extract just the first name from "First Last " or fall back to email local part
- let from = 'someone';
- if (fullFrom) {
- const fullName = _extractName(fullFrom);
- if (fullName) {
- // Strip quotes, take the first whitespace-separated word, capitalize
- const first = fullName.replace(/^["']|["']$/g, '').trim().split(/[\s,]+/)[0] || '';
- if (first) from = first.charAt(0).toUpperCase() + first.slice(1);
- }
- }
- const subject = em.subject || '(no subject)';
- const folder = state._libFolder || 'INBOX';
- const deepLink = `${window.location.origin}/#email=${encodeURIComponent(folder)}:${em.uid}`;
- const itemText = customText || `Reply to ${from}: ${subject}`;
- const payload = {
- title: `Reply: ${subject}`,
- note_type: 'todo',
- items: [
- { text: itemText, checked: false },
- ],
- content: `Open email: ${deepLink}`,
- label: 'email reminder',
- source: 'email',
- };
- if (iso) payload.due_date = iso;
- try {
- const res = await fetch(`${API_BASE}/api/notes`, {
- method: 'POST', credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
- });
- if (!res.ok) throw new Error('Failed');
- const { showToast } = await import('./ui.js?v=20260916largetoolscroll1');
- if (dueDate) {
- const fmt = dueDate.toLocaleString([], { month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
- showToast(`Todo reminder set for ${fmt}`);
- } else {
- showToast('Reply note saved');
- }
- if ('Notification' in window && Notification.permission === 'default') {
- try { Notification.requestPermission(); } catch {}
- }
- } catch (e) {
- const { showError } = await import('./ui.js?v=20260916largetoolscroll1');
- showError('Failed to create reminder');
- }
-}
-
-// Sanitize untrusted HTML email bodies before injecting via innerHTML.
+// The email library now lives in `static/js/emailLibrary/`. This file stays at
+// the old path because five call sites import it, four of them dynamically
+// with a version query string (`emailInbox.js`, `chatStream.js`,
+// `chatRenderer.js`, `document.js`, `settings.js`), and `sw.js` caches URLs
+// verbatim. Re-exporting here means the move needed no coordinated edit to any
+// of them.
//
-// Denylist sanitizer — has to block every well-known XSS sink:
-// -