Files
odysseus/static/js/emailLibrary.js
T
pewdiepie-archdaemon 297ad19248 Harden maintainer-preview harness and review fixes
Unify conversational domain routing, preserve artifact completion evidence, replace provisional tool-round prose with terminal synthesis, and resolve verified maintainer review findings across search, frontend module identity, path policy, configuration, and built-in skill startup.
2026-09-21 06:54:03 +00:00

11376 lines
529 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 15.5A3.5 3.5 0 1 0 12 8a3.5 3.5 0 0 0 0 7.5Z"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06A2 2 0 1 1 7.04 4.3l.06.06A1.65 1.65 0 0 0 8.92 4a1.65 1.65 0 0 0 1-1.51V2a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82 1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/></svg>`;
const _EMAIL_SAVE_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg>';
const _EMAIL_SAVED_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>';
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
? ` <span class="email-settings-account-scope">(${_esc(accountAddress)})</span>`
: '';
return `
<div class="admin-card email-settings-section email-settings-auto-reply-section">
<div class="email-settings-section-head">
<div>
<div class="email-settings-auto-reply-title-row">
<div class="email-settings-section-title"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg><span>Auto Reply${accountScope}</span></div>
<div class="email-settings-enabled-control">
<span class="email-settings-enabled-state"></span>
<label class="email-settings-toggle admin-switch">
<input type="checkbox" id="email-auto-reply-enabled" ${cfg.enabled ? 'checked' : ''}>
<span class="admin-slider"></span>
</label>
</div>
</div>
<div class="email-settings-section-desc">Holiday or away replies for incoming mail.</div>
</div>
</div>
<div id="email-settings-auto-reply-body" class="email-settings-section-body">
<div class="email-settings-grid">
<label><span>Start date</span><input type="date" id="email-auto-reply-start" value="${_esc(_autoReplyDateValue(cfg.start))}"></label>
<label><span>End date</span><input type="date" id="email-auto-reply-end" value="${_esc(_autoReplyDateValue(cfg.end))}"></label>
</div>
<label class="email-settings-field">
<span>Subject</span>
<input type="text" id="email-auto-reply-subject" value="${_esc(cfg.subject || _DEFAULT_AUTO_REPLY_SUBJECT)}" placeholder="${_esc(_DEFAULT_AUTO_REPLY_SUBJECT)}">
</label>
<label class="email-settings-field">
<span>Message</span>
<textarea id="email-auto-reply-message" rows="4">${_esc(cfg.message || _DEFAULT_AUTO_REPLY_MESSAGE)}</textarea>
</label>
<label class="email-settings-field">
<span>Send to same sender</span>
<select id="email-auto-reply-cooldown">
<option value="period" ${cfg.cooldown === 'period' ? 'selected' : ''}>Once while active</option>
<option value="1d" ${cfg.cooldown === '1d' ? 'selected' : ''}>Every day</option>
<option value="3d" ${cfg.cooldown === '3d' ? 'selected' : ''}>Every 3 days</option>
<option value="7d" ${cfg.cooldown === '7d' ? 'selected' : ''}>Every 7 days</option>
</select>
</label>
<label class="email-settings-check">
<span>Pause email notifications while active</span>
<span class="email-settings-check-spacer"></span>
<span class="admin-switch"><input type="checkbox" id="email-auto-reply-pause" ${cfg.pauseNotifications ? 'checked' : ''}><span class="admin-slider"></span></span>
</label>
</div>
</div>
`;
}
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
? ` <span class="email-settings-account-scope">(${_esc(accountAddress)})</span>`
: '';
return `
<div class="admin-card email-settings-section email-settings-cleanup-section">
<div class="email-settings-section-head">
<div>
<span class="email-settings-section-title"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg><span>Newsletter Unsubscribe${accountScope}</span></span>
<div class="email-settings-section-desc">Find newsletter and ad emails with unsubscribe options.</div>
</div>
</div>
<div id="email-settings-cleanup-body" class="email-settings-section-body">
<div class="email-settings-clean-actions">
<span class="email-unsub-status email-settings-clean-status" style="font-size:12px;color:color-mix(in srgb,var(--fg) 68%,transparent);"></span>
<button type="button" class="memory-toolbar-btn email-settings-clean-btn">
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style="vertical-align:-1px;margin-right:2px;color:var(--accent,var(--red));"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>
<span>Clean</span>
</button>
<button type="button" class="memory-toolbar-btn email-settings-save">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg>
Save
</button>
</div>
</div>
</div>
`;
}
function _emailDisplaySettingsHtml(cfg = {}) {
return `
<div class="admin-card email-settings-section email-settings-display-section">
<div class="email-settings-section-head">
<div>
<div class="email-settings-display-title-row">
<div class="email-settings-section-title"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.59 13.41 11 3.83A2 2 0 0 0 9.59 3H4a1 1 0 0 0-1 1v5.59A2 2 0 0 0 3.59 11l9.59 9.59a2 2 0 0 0 2.82 0l4.59-4.59a2 2 0 0 0 0-2.82Z"/><path d="M7 7h.01"/></svg><span>Show Email Tags</span></div>
<span class="email-settings-enabled-control">
<span class="email-settings-enabled-state email-settings-display-enabled-state"></span>
<label class="email-settings-toggle admin-switch">
<input type="checkbox" id="email-settings-show-tags" ${state._libShowTags ? 'checked' : ''}>
<span class="admin-slider"></span>
</label>
</span>
</div>
<div class="email-settings-section-desc email-settings-support-copy">Choose which labels appear on email cards. <span class="email-settings-display-task-note">Turn off tagging in <button type="button" class="email-settings-inline-link" data-open-email-tasks>Tasks</button></span></div>
</div>
</div>
<div class="email-settings-display-spacer" aria-hidden="true"></div>
</div>
<div class="admin-card email-settings-section email-settings-inline-images-section">
<div class="email-settings-section-head">
<div>
<div class="email-settings-display-title-row">
<div class="email-settings-section-title"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg><span>Hide Inline Images</span></div>
<span class="email-settings-enabled-control">
<span class="email-settings-enabled-state email-settings-inline-images-enabled-state"></span>
<label class="email-settings-toggle admin-switch">
<input type="checkbox" id="email-settings-view-inline-images" ${cfg.viewInlineImages === false ? 'checked' : ''}>
<span class="admin-slider"></span>
</label>
</span>
</div>
<div class="email-settings-section-desc email-settings-support-copy">Keep images embedded in email bodies hidden until you load them.</div>
</div>
</div>
<div class="email-settings-inline-images-spacer" aria-hidden="true"></div>
</div>
`;
}
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 `<option value="${_esc(a.id || '')}" ${state._libAccountId === a.id ? 'selected' : ''}>${_esc(label + suffix)}</option>`;
}).join('');
const selected = accounts.find(a => a.id === state._libAccountId) || accounts[0];
const active = !!selected?.is_default;
const icon = active
? '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="8 12 11 15 16 9"/></svg>'
: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/></svg>';
const defaultTitle = active ? 'Default account' : 'Make this the default account';
return `<div class="email-settings-account-picker" style="margin-left:auto;display:inline-flex;align-items:center;gap:8px;">`
+ `<span class="cookbook-srv-default email-settings-default${active ? ' active' : ''}" role="button" tabindex="0" aria-pressed="${active ? 'true' : 'false'}" data-email-default-id="${_esc(selected?.id || '')}" title="${defaultTitle}"><span class="cookbook-srv-default-icon" aria-hidden="true">${icon}</span><span class="cookbook-srv-default-label">default</span></span>`
+ `<select id="email-settings-account-select" class="memory-toolbar-btn" title="Active email for all email settings" style="height:28px;max-width:240px;position:relative;top:-2px;">${opts}</select>`
+ `</div>`;
}
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
? '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="8 12 11 15 16 9"/></svg>'
: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/></svg>';
}
function _emailWritingStyleHtml(style) {
return `
<div class="admin-card email-settings-section email-settings-style-section">
<div class="email-settings-section-head">
<div>
<div class="email-settings-section-title"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg><span>Email Reply Writing Style</span></div>
<div class="email-settings-section-desc email-settings-support-copy">Used when Odysseus drafts replies for you.</div>
</div>
</div>
<div id="email-settings-style-body" class="email-settings-section-body">
<label class="email-settings-field">
<span>Writing style</span>
<textarea id="email-writing-style-text" rows="6">${_esc(style)}</textarea>
</label>
<div class="email-settings-actions">
<button type="button" class="memory-toolbar-btn email-style-settings-extract">
<svg class="email-unsub-accent-icon" width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>
Extract
</button>
<button class="email-style-extract-help" aria-label="What does Extract do?" title="Extract analyzes sent emails from the selected account and saves a writing-style prompt for AI replies.">?</button>
<button type="button" class="memory-toolbar-btn email-style-settings-save">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg>
Save
</button>
</div>
</div>
</div>
`;
}
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 = `
<div class="email-settings-page-head">
<h2 class="email-settings-title">${_EMAIL_SETTINGS_ICON}<span>Email Settings</span></h2>
</div>
<div class="email-settings-body">
${_emailSettingsLoadingHtml()}
</div>
`;
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 = `<div class="email-settings-error">Could not load settings.</div>`;
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 = `<div class="email-settings-error">Could not load settings.</div>`;
}
});
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 `
<div class="email-reader-load-error">
<div class="email-reader-load-error-title">Could not load email</div>
<div class="email-reader-load-error-msg">${_esc(message || 'Failed to load email')}</div>
<button type="button" class="memory-toolbar-btn email-reader-retry-btn">Retry</button>
</div>`;
}
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 `<button type="button" class="email-tag email-tag-${_esc(normalized)} email-tag-clickable" data-calendar-event-uid="${_esc(eventUid)}" title="Open calendar event">${_esc(normalized)}</button>`;
}
return `<button type="button" class="email-tag email-tag-${_esc(normalized)} email-tag-clickable" data-email-filter-tag="${_esc(normalized)}" title="Show ${_esc(normalized)} emails">${_esc(normalized)}</button>`;
}
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 => `<span class="email-tag-extra">${html}</span>`).join('');
const additionalCount = visible.length - 1;
const mini = `<button type="button" class="email-tags-more email-tags-more-single" data-email-tags-more aria-expanded="false" title="Show all tags"><span>+${additionalCount}</span></button>`;
return `${visible[0]}${extra}<button type="button" class="email-tags-more" data-email-tags-more aria-expanded="false" title="Show all tags"><span data-email-tags-count-normal>+${additionalCount}</span><span data-email-tags-count-mini>+${additionalCount}</span><svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg></button>${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 = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
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 `<button class="email-reader-meta-toggle email-reader-cc-toggle" type="button" aria-expanded="false" title="Show Cc recipients"><span>Cc +${ccCount}</span><svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>`;
}
if (!data.to) return '';
return `<button class="email-reader-meta-toggle" type="button" aria-expanded="false" title="Show recipients"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>`;
}
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 `<span class="${cls}" data-full="${_esc(fullText || labelText)}" data-email="${_esc(addr)}" title="Click for details"><span class="recipient-chip-label">${_esc(labelText)}</span><button type="button" class="recipient-chip-copy" title="Copy email" aria-label="Copy email" hidden>${_COPY_EMAIL_ICON}</button></span>`;
}
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 = `
<div class="recipient-chip-popover-main">
${name && detail !== name ? `<div class="recipient-chip-popover-name">${_esc(name)}</div>` : ''}
<div class="recipient-chip-popover-detail">${_esc(detail)}</div>
</div>
${email ? `<button type="button" class="recipient-chip-popover-copy" title="Copy email" aria-label="Copy email">${_COPY_EMAIL_ICON}</button>` : ''}
`;
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 = '<span class="email-bulk-loading-label">Exporting</span>';
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 '<div style="margin-top:6px;opacity:0.72;font-size:11px;">' +
'Setup: <a href="#" data-open-settings="integrations" style="color:var(--accent,var(--red));text-decoration:underline;">Settings &rsaquo; Integrations</a>' +
'</div>';
}
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 = '<svg class="email-unsub-accent-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></svg>';
const _UNSUB_CHECK_ICON = '<svg class="email-unsub-accent-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>';
const _UNSUB_CLOSE_ICON = '<svg class="email-unsub-accent-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
const _UNSUB_AGENT_ICON = '<svg class="email-unsub-agent-icon email-unsub-accent-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 8V4H8"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2M20 14h2M15 13v2M9 13v2"/></svg>';
const _UNSUB_TRASH_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/></svg>';
const _UNSUB_REFRESH_ICON = '<svg class="email-unsub-accent-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 0 1 15.3-6.4L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15.3 6.4L3 16"/><path d="M3 21v-5h5"/></svg>';
const _UNSUB_EXTERNAL_ICON = '<svg class="email-unsub-accent-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
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', `<span class="email-tag email-unsub-done-badge">${_UNSUB_CHECK_ICON}<span>${_esc(label)}</span></span>`);
}
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 `
<div style="display:flex;gap:8px;align-items:center;min-width:0;">
<div style="min-width:0;flex:1;">
<div style="font-weight:650;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${_esc(c.subject || '(no subject)')}</div>
<div style="font-size:11px;color:color-mix(in srgb,var(--fg) 62%,transparent);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${_esc(c.from_name || c.from_address || '')} · ${_esc(c.from_address || '')}</div>
</div>
${uids.length > 1 ? `<span class="email-tag">x${uids.length}</span>` : ''}
</div>`;
}).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 = `
<div style="display:flex;gap:8px;align-items:flex-start;justify-content:space-between;flex-wrap:wrap;">
<div style="font-size:12px;font-weight:700;">${_esc(heading)}</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;">
<button type="button" class="memory-toolbar-btn email-unsub-clean-spam" style="display:inline-flex;align-items:center;gap:4px;">${_UNSUB_CHECK_ICON}<span>Mark as spam</span></button>
<button type="button" class="memory-toolbar-btn email-unsub-clean-junk-delete" style="display:inline-flex;align-items:center;gap:4px;color:var(--red);">${_UNSUB_TRASH_ICON}<span>Spam + delete</span></button>
<button type="button" class="memory-toolbar-btn email-unsub-clean-delete" style="display:inline-flex;align-items:center;gap:4px;color:var(--red);">${_UNSUB_TRASH_ICON}<span>Delete</span></button>
<button type="button" class="memory-toolbar-btn email-unsub-clean-keep" style="display:inline-flex;align-items:center;gap:4px;">${_UNSUB_CHECK_ICON}<span>Keep</span></button>
${options.scanFurther ? '<button type="button" class="memory-toolbar-btn email-unsub-scan-further">Scan further</button>' : ''}
</div>
</div>
<div style="display:flex;flex-direction:column;gap:5px;font-size:12px;">${rows}</div>`;
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 = `<span style="font-size:12px;font-weight:700;">${_esc(heading)}</span><button type="button" class="memory-toolbar-btn email-unsub-scan-further">Scan further</button>`;
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 = `
<span style="font-size:12px;font-weight:700;">${candidates.length} unsubscribe${candidates.length === 1 ? '' : 's'} still need review</span>
<span class="email-unsub-remaining-actions">
<button type="button" class="memory-toolbar-btn email-unsub-clean-remaining-btn">${_UNSUB_TRASH_ICON}<span>Spam + delete remaining</span></button>
<button type="button" class="memory-toolbar-btn email-unsub-agent-all-btn">${_UNSUB_AGENT_ICON}<span>Agent Unsubscribe All</span></button>
</span>`;
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 = `
<div class="modal-body" style="display:flex;flex-direction:column;gap:10px;overflow:visible;">
<div class="email-unsub-actions" style="display:none;gap:7px;justify-content:flex-end;align-items:center;flex-wrap:wrap;">
<span class="email-unsub-panel-status" aria-live="polite"></span>
<button type="button" class="memory-toolbar-btn email-unsub-rescan-btn" style="display:inline-flex;align-items:center;gap:4px;">${_UNSUB_REFRESH_ICON}<span>Rescan</span></button>
<button type="button" class="memory-toolbar-btn email-unsub-delete-all-btn" style="display:inline-flex;align-items:center;gap:4px;">${_UNSUB_TRASH_ICON}<span>Delete all</span></button>
<button type="button" class="memory-toolbar-btn email-unsub-auto-safe-btn" style="display:none;align-items:center;gap:4px;">${_UNSUB_CHECK_ICON}<span>Auto Unsubscribe All</span></button>
</div>
<div class="email-unsub-list" style="display:none;flex-direction:column;gap:8px;"></div>
</div>`;
inlineHost.appendChild(modal);
} else {
modal.style.display = 'block';
modal.innerHTML = `
<div class="modal-content doclib-modal-content" style="width:min(680px,92vw);background:var(--bg);">
<div class="modal-header">
<h4>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>
Unsubscribe review
</h4>
</div>
<div class="modal-body" style="display:flex;flex-direction:column;gap:10px;max-height:min(72vh,620px);overflow:auto;">
<div class="email-unsub-status" style="font-size:10.5px;color:color-mix(in srgb,var(--fg) 68%,transparent);">Scanning ${_esc(scanFolderLabel)} headers…</div>
<div class="email-unsub-actions" style="display:none;gap:7px;justify-content:flex-end;align-items:center;flex-wrap:wrap;">
<span class="email-unsub-panel-status" aria-live="polite"></span>
<button type="button" class="memory-toolbar-btn email-unsub-rescan-btn" style="display:inline-flex;align-items:center;gap:4px;">${_UNSUB_REFRESH_ICON}<span>Rescan</span></button>
<button type="button" class="memory-toolbar-btn email-unsub-delete-all-btn" style="display:inline-flex;align-items:center;gap:4px;">${_UNSUB_TRASH_ICON}<span>Delete all</span></button>
<button type="button" class="memory-toolbar-btn email-unsub-auto-safe-btn" style="display:none;align-items:center;gap:4px;">${_UNSUB_CHECK_ICON}<span>Auto Unsubscribe All</span></button>
</div>
<div class="email-unsub-list" style="display:none;flex-direction:column;gap:8px;"></div>
</div>
</div>`;
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 => `<span class="email-tag">${_esc(r)}</span>`).join('');
const urlMethod = (c.methods || []).find(m => m.kind === 'url');
const duplicateCount = Number(c.duplicate_count || 1);
const duplicateBadge = duplicateCount > 1 ? `<span class="email-tag email-unsub-duplicate-badge">x${duplicateCount}</span>` : '';
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 `
<div class="email-unsub-card" data-idx="${idx}" style="border:1px solid var(--border);border-radius:8px;padding:10px;background:color-mix(in srgb,var(--fg) 3%,transparent);display:flex;flex-direction:column;gap:7px;">
<div class="email-unsub-card-top" style="display:flex;gap:8px;align-items:center;padding-right:0;">
<div class="email-unsub-card-summary" style="min-width:0;flex:1;">
<div class="email-unsub-subject-line" style="display:flex;align-items:center;gap:6px;min-width:0;">
<div style="font-weight:700;font-size:13px;min-width:0;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${_esc(c.subject || '(no subject)')}</div>
</div>
<div class="email-unsub-card-subtitle" style="font-size:11px;color:color-mix(in srgb,var(--fg) 62%,transparent);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${_esc(c.from_name || c.from_address || '')} · ${_esc(c.from_address || '')}</div>
</div>
<button type="button" class="email-unsub-card-toggle" data-idx="${idx}" aria-expanded="false" aria-controls="email-unsub-details-${idx}" title="Show unsubscribe details" aria-label="Show unsubscribe details">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
</button>
</div>
<div id="email-unsub-details-${idx}" class="email-unsub-card-details" hidden>
<div class="email-unsub-detail-head" style="display:flex;gap:7px;align-items:center;flex-wrap:wrap;">
<span class="email-tag email-tag-spam" style="flex:0 0 auto;">score ${_esc(c.score || 0)}</span>
${duplicateBadge}
<span style="flex:1 1 auto;min-width:8px;"></span>
<button type="button" class="memory-toolbar-btn email-unsub-done-btn" data-idx="${idx}" title="${deleteTitle}" aria-label="${deleteLabel} unsubscribe emails">${_UNSUB_TRASH_ICON}<span>${deleteLabel}</span></button>
<button type="button" class="memory-toolbar-btn email-unsub-ignore-btn" data-idx="${idx}" title="Ignore this unsubscribe candidate" aria-label="Ignore this unsubscribe candidate">${_UNSUB_CLOSE_ICON}</button>
</div>
<div class="email-unsub-reasons" style="display:flex;flex-wrap:wrap;gap:4px;">${reasons}</div>
<div class="email-unsub-actions-row" style="display:flex;gap:7px;align-items:center;justify-content:flex-end;flex-wrap:wrap;">
${c.can_execute ? `<button type="button" class="memory-toolbar-btn email-unsub-send-btn" data-idx="${idx}" style="display:inline-flex;align-items:center;gap:4px;">${_UNSUB_EMAIL_ICON}<span>${_esc(_unsubscribeMethodLabel(method))}</span></button>` : ''}
${urlMethod ? `<button type="button" class="memory-toolbar-btn email-unsub-agent-btn" data-idx="${idx}" style="display:inline-flex;align-items:center;gap:6px;"><svg class="email-unsub-agent-icon email-unsub-accent-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 8V4H8"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2M20 14h2M15 13v2M9 13v2"/></svg><span>Agent Unsubscribe</span></button>` : ''}
${urlMethod ? `<a class="memory-toolbar-btn email-unsub-link-btn" href="${_esc(urlMethod.target)}" target="_blank" rel="noopener noreferrer" style="text-decoration:none;display:inline-flex;align-items:center;gap:4px;">${_UNSUB_EXTERNAL_ICON}<span>Link Unsubscribe</span></a>` : ''}
</div>
</div>
</div>`;
}).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}<span>Sent</span>`;
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}<span>Sent</span>`;
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}<span>${_esc(_unsubscribeMethodLabel(c.recommended_method))}</span>`;
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 = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;"><polyline points="20 6 9 17 4 12"/></svg>';
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 = '<div class="chat-pull-refresh-spinner"></div>';
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 = `
<div class="modal-content doclib-modal-content" style="width:min(720px, 92vw);background:var(--bg);">
<div class="modal-header">
<h4>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
<span id="email-lib-title-text">Email</span>
<span id="email-lib-auto-reply-status-dot" class="email-lib-auto-reply-status-dot" aria-label="Auto Reply active" title="Auto Reply active" style="display:none"></span>
<span id="email-lib-auto-reply-badge" class="email-lib-auto-reply-badge" role="button" tabindex="0" title="Auto Reply is active" style="display:none">Auto Reply</span>
<span id="email-lib-unread-badge" class="email-lib-unread-badge" role="button" tabindex="0" title="Show unread emails" style="display:none"></span>
<span id="email-lib-stats" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal;margin-left:8px;position:relative;top:-2px"></span>
</h4>
<div class="email-lib-header-actions" style="display:flex;align-items:center;gap:8px;">
<button class="email-settings-header-back" id="email-settings-header-back" type="button" title="Back" aria-label="Back" style="display:none;">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
<span>Back</span>
</button>
<button class="close-btn" id="email-lib-close">\u2716</button>
</div>
</div>
<div class="modal-body" style="display:flex;flex-direction:column;gap:10px;overflow:hidden;">
<div class="admin-card" style="flex:1;flex-direction:column;display:flex;overflow:hidden;">
<div class="email-accounts-row">
<div id="email-lib-accounts" style="display:flex;gap:4px;flex:1;min-width:0;"></div>
<button class="memory-toolbar-btn email-compose-jiggle" id="email-lib-compose-btn">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-2px;margin-right:3px;"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
New
</button>
</div>
<div class="memory-toolbar">
<div class="memory-category-filters">
<div class="email-folder-picker" id="email-folder-picker" style="flex:1;min-width:0;position:relative;">
<select class="memory-sort-select email-folder-select-control" id="email-lib-folder" style="display:none;">
<option value="INBOX">Inbox</option>
</select>
<button type="button" class="email-filter-btn email-folder-btn" id="email-folder-btn" aria-haspopup="listbox" aria-expanded="false">
<span class="email-filter-current"><span class="email-filter-icon email-folder-current-icon"></span><span class="email-filter-label email-folder-label">Inbox</span></span>
<svg class="email-filter-caret" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div class="email-filter-menu email-folder-menu" id="email-folder-menu" role="listbox" hidden></div>
</div>
<!-- Hidden native select kept as the source of truth — all
existing change handlers still fire via the custom picker
dispatching 'change' on it. -->
<select class="memory-sort-select" id="email-lib-filter" style="display:none;">
<option value="all">All</option>
<option value="unread">Unread</option>
<option value="favorites">Favorites</option>
<option value="undone">Undone</option>
<option value="reminders">Reminders</option>
<option value="unanswered">Unanswered</option>
<option value="pending_30d">Pending · 30d</option>
<option value="stale_30d">Stale · &gt;30d</option>
<optgroup label="Tags">
<option value="tag:urgent">Urgent</option>
<option value="tag:reply-soon">Reply soon</option>
<option value="tag:bills">Bills</option>
<option value="tag:receipt">Receipt</option>
<option value="tag:travel">Travel</option>
<option value="tag:spam">Spam</option>
</optgroup>
</select>
<div class="email-filter-picker" id="email-filter-picker" style="flex:1;min-width:0;position:relative;">
<button type="button" class="email-filter-btn" id="email-filter-btn" aria-haspopup="listbox" aria-expanded="false">
<span class="email-filter-current"><span class="email-filter-icon"></span><span class="email-filter-label">All</span></span>
<svg class="email-filter-caret" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div class="email-filter-menu" id="email-filter-menu" role="listbox" hidden></div>
</div>
<button class="memory-toolbar-btn email-filter-select-btn" id="email-lib-select-btn"><svg class="memory-select-btn-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px;"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg>Select</button>
<button class="memory-toolbar-btn email-filter-refresh-btn" id="email-lib-refresh-btn" title="Refresh">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;"><path d="M1 4v6h6"/><path d="M23 20v-6h-6"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg>
</button>
<button class="memory-toolbar-btn email-filter-settings-btn" id="email-lib-settings-btn" title="Email settings" aria-label="Email settings" aria-expanded="false">
${_EMAIL_SETTINGS_ICON}
</button>
<button class="memory-toolbar-btn email-reminders-clear-btn hidden" id="email-reminders-clear-btn" title="Permanently delete Odysseus reminder emails">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/></svg>
Clear
</button>
</div>
<div class="email-search-row" style="display:flex;gap:6px;align-items:flex-start;">
<div class="email-search-wrap" style="position:relative;flex:1;min-width:140px;">
<div class="email-lib-chip-bar memory-search-input" id="email-lib-chip-bar" style="width:100%;padding-right:36px;padding-left:26px;display:flex;align-items:center;flex-wrap:nowrap;gap:4px;cursor:text;min-height:30px;position:relative;">
<svg class="email-lib-chip-bar-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="position:absolute;left:8px;top:50%;transform:translateY(-50%);pointer-events:none;color:var(--accent, var(--red));"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.35-4.35"/></svg>
<span id="email-lib-pills" style="display:flex;align-items:center;gap:4px;min-width:0;max-width:calc(100% - 70px);overflow-x:auto;overflow-y:hidden;scrollbar-width:none;flex:0 1 auto;"></span>
<input type="text" id="email-lib-search" placeholder="Search by name or text" autocomplete="off" style="flex:1;min-width:80px;border:0;outline:none;background:transparent;color:inherit;font:inherit;padding:0;position:relative;top:-1px;" />
<button type="button" class="memory-toolbar-btn email-search-options-btn" id="email-search-options-btn" title="Search options" aria-label="Search options" aria-expanded="false">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
</button>
</div>
<div id="email-lib-suggest" style="display:none;position:absolute;top:calc(100% + 2px);left:0;right:0;z-index:60;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:6px;box-shadow:0 6px 18px rgba(0,0,0,0.25);max-height:240px;overflow-y:auto;"></div>
<div class="email-search-options-menu" id="email-search-options-menu" hidden>
<div class="email-search-options-title">Filter by...</div>
<button type="button" class="dropdown-item-compact" data-email-search-option="undone"><span class="dropdown-icon email-search-option-icon"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></span><span>Not done</span></button>
<button type="button" class="dropdown-item-compact hidden" data-email-search-option="reminders"><span class="dropdown-icon email-search-option-icon"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></svg></span><span>Reminders</span></button>
<button type="button" class="dropdown-item-compact" data-email-search-option="attachments"><span class="dropdown-icon email-search-option-icon"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83 2.83l8.49-8.48"/></svg></span><span>Attachments</span></button>
<button type="button" class="dropdown-item-compact" data-email-search-option="date"><span class="dropdown-icon email-search-option-icon"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="17" rx="2"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></span><span>Date range</span></button>
<div class="email-search-date-panel" hidden>
<label><span>From</span><input type="date" id="email-search-date-from"></label>
<label><span>To</span><input type="date" id="email-search-date-to"></label>
<div class="email-search-date-actions"><button type="button" data-email-date-clear>Clear</button><button type="button" data-email-date-apply>Apply</button></div>
</div>
</div>
</div>
</div>
</div>
<div id="email-lib-bulk" class="memory-bulk-bar hidden" style="margin-bottom:5px;">
<label class="memory-bulk-check-all" style="position:relative;top:0px;" title="Select all emails currently loaded in this view"><input type="checkbox" id="email-lib-select-all"> All in view</label>
<span id="email-lib-selected-count" style="position:relative;top:1px;">0 Selected</span>
<button class="memory-toolbar-btn" id="email-lib-bulk-actions" style="position:relative;top:-2px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>Actions <span style="opacity:0.55;font-size:9px;">▼</span></button>
<button class="memory-toolbar-btn" id="email-lib-bulk-delete" style="position:relative;top:-2px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Delete</button>
<button class="memory-toolbar-btn" id="email-lib-bulk-cancel" title="Cancel (Esc)" style="margin-left:4px;padding:3px 6px;position:relative;top:-2px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div id="email-lib-grid" class="doclib-grid"></div>
<div id="email-lib-sync-status" class="email-lib-sync-status" aria-live="polite"></div>
<div id="email-lib-settings-page" class="email-settings-page" hidden></div>
<button class="email-lib-fab" id="email-lib-fab" type="button" aria-label="New email">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="4.5" width="19" height="15" rx="2.5"/><path d="M3 6.5l9 6 9-6"/></svg>
<span class="email-lib-fab-label">New</span>
</button>
</div>
</div>
</div>
`;
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 = '<svg class="memory-select-btn-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px;"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg>';
const _SELECT_BTN_X_SVG = '<svg class="memory-select-btn-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" style="vertical-align:-2px;margin-right:3px;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></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, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
// The 'Default' chip caused desync bugs (changing the server-side
// default via the dot while still on the cached 'default' view would
// open the wrong account's emails). Each account renders as its own
// chip; the active one is selected explicitly via _loadAccounts.
let html = '';
// Keep the server default first; unread status remains the only dot on a
// mailbox chip, while the explicit default control lives in Settings.
const accounts = (state._libAccounts || []).slice().sort((a, b) => 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 ? '<span class="email-account-away-label">(AWAY)</span>' : '';
const unreadDot = unreadCount > 0
? `<span class="email-account-unread-dot" aria-hidden="true"></span>`
: '';
html += `<span class="gallery-chip-wrap" style="position:relative;display:inline-flex;align-items:center;">`
+ `<button class="memory-toolbar-btn gallery-chip email-account-chip${active}${unreadClass}" data-acc-id="${esc(a.id)}" title="${esc(a.from_address || a.imap_user || '')}${away ? ' · auto reply active' : ''}${unreadTitle}" style="padding-right:24px;">${unreadDot}${awayLabel}<span class="email-account-chip-label">${esc(label)}</span>${unreadCount > 0 ? `<span class="email-account-unread-count">${unreadCount > 999 ? '999+' : unreadCount}</span>` : ''}</button>`
+ `</span>`;
}
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 = '<div class="email-settings-error">Could not load settings.</div>';
}
};
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:<X>' — strip prefix and reuse the existing icon map.
const v = String(value || '').replace(/^filter:/, '');
if (v === 'has-attachments') return '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>';
if (v === 'date-range') return '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="17" rx="2"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
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, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
const folderLabel = folderDisplayName(folder);
const folderPill = showingFolderPill
? `<span class="email-lib-pill email-lib-folder-pill" data-folder-pill="${esc(folder)}" title="${esc(folderLabel)}" style="display:inline-flex;align-items:center;gap:3px;padding:0 5px 0 7px;border-radius:999px;background:color-mix(in srgb, var(--accent, var(--red)) 14%, transparent);color:var(--accent, var(--red));font-size:11px;line-height:20px;height:20px;font-weight:600;max-width:170px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex-shrink:0;">
<span style="overflow:hidden;text-overflow:ellipsis;">${esc(folderLabel)}</span>
<button type="button" class="email-lib-folder-pill-x" title="Back to Inbox" style="background:transparent;border:0;color:inherit;cursor:pointer;font-size:12px;line-height:1;padding:0 2px;opacity:0.7;position:relative;top:-4px;">×</button>
</span>`
: '';
const filterPillHtml = filterPills.map(p => {
const titleAttr = esc(p.label);
return `<span class="email-lib-pill email-lib-filter-pill" data-email-filter-pill="${esc(p.value)}" title="${titleAttr}" style="display:inline-flex;align-items:center;gap:3px;padding:0 5px 0 7px;border-radius:999px;background:color-mix(in srgb, var(--accent, var(--red)) 14%, transparent);color:var(--accent, var(--red));font-size:11px;line-height:20px;height:20px;font-weight:600;max-width:170px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex-shrink:0;">
<span class="email-lib-pill-icon" style="display:inline-flex;align-items:center;width:13px;height:13px;flex-shrink:0;">${_libFilterIconFor(`filter:${p.value}`)}</span>
<span class="email-lib-filter-pill-label" style="overflow:hidden;text-overflow:ellipsis;">${esc(p.label)}</span>
<button type="button" class="email-lib-filter-pill-x" data-email-filter-pill="${esc(p.value)}" title="Remove" style="background:transparent;border:0;color:inherit;cursor:pointer;font-size:12px;line-height:1;padding:0 2px;opacity:0.7;position:relative;top:-4px;">×</button>
</span>`;
}).join('');
wrap.innerHTML = folderPill + filterPillHtml + pills.map((p, i) => {
const label = p.type === 'contact' ? (p.name || p.email || '?') : (p.text || '');
return `<span class="email-lib-pill" data-pill-idx="${i}" style="display:inline-flex;align-items:center;gap:3px;padding:0 5px 0 7px;border-radius:999px;background:color-mix(in srgb, var(--accent, var(--red)) 14%, transparent);color:var(--accent, var(--red));font-size:11px;line-height:20px;height:20px;font-weight:600;max-width:170px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex-shrink:0;">
<span style="overflow:hidden;text-overflow:ellipsis;">${esc(label)}</span>
<button type="button" class="email-lib-pill-x" data-pill-idx="${i}" title="Remove" style="background:transparent;border:0;color:inherit;cursor:pointer;font-size:12px;line-height:1;padding:0 2px;opacity:0.7;position:relative;top:-4px;">×</button>
</span>`;
}).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, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
menu.innerHTML = items.map((s, i) => {
const highlight = i === _libSuggestionFocusIdx ? 'background:color-mix(in srgb, var(--fg) 8%, transparent);' : '';
if (s.kind === 'filter') {
return `<div class="email-lib-suggest-item" data-idx="${i}" style="display:flex;align-items:center;gap:8px;padding:6px 10px;cursor:pointer;font-size:12px;${highlight}">
<span style="display:inline-flex;align-items:center;width:13px;height:13px;color:var(--accent, var(--red));flex-shrink:0;">${s.icon}</span>
<span style="font-weight:600;">${esc(s.label)}</span>
</div>`;
}
if (s.kind === 'email') {
return `<div class="email-lib-suggest-item" data-idx="${i}" style="display:flex;align-items:center;gap:6px;padding:6px 10px;cursor:pointer;font-size:12px;${highlight}">
<span style="display:inline-flex;align-items:center;width:13px;height:13px;color:var(--fg-muted, var(--fg));opacity:0.55;flex-shrink:0;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="2 6 12 13 22 6"/></svg></span>
<span style="font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(s.subject)}</span>
${s.from_name ? `<span style="opacity:0.55;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">— ${esc(s.from_name)}</span>` : ''}
${s.date_label ? `<span style="margin-left:auto;opacity:0.48;font-size:11px;white-space:nowrap;flex-shrink:0;">${esc(s.date_label)}</span>` : ''}
</div>`;
}
return `<div class="email-lib-suggest-item" data-idx="${i}" style="display:flex;align-items:center;gap:6px;padding:6px 10px;cursor:pointer;font-size:12px;${highlight}">
<span style="font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(s.name || s.email)}</span>
${s.name ? `<span style="opacity:0.55;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(s.email)}</span>` : ''}
</div>`;
}).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 <select> so each row can carry an SVG icon. The hidden
// <select id="email-lib-filter"> stays as the value source — clicking a
// menu item updates its value and dispatches 'change', so every existing
// listener keeps working.
const _EMAIL_FILTER_ICONS = {
'all': '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>',
'unread': '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg>',
'favorites': '<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>',
'undone': '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="3"/></svg>',
'reminders': '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></svg>',
'unanswered': '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>',
'pending_30d': '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
'stale_30d': '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><line x1="10" y1="14" x2="14" y2="18"/><line x1="14" y1="14" x2="10" y2="18"/></svg>',
'tag:urgent': '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
'tag:reply-soon':'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/><circle cx="18" cy="6" r="2" fill="currentColor" stroke="none"/></svg>',
'tag:spam': '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>',
};
function _filterIcon(value) {
return _EMAIL_FILTER_ICONS[value] || _EMAIL_FILTER_ICONS['all'];
}
function _folderIcon(value) {
const v = String(value || '').toLowerCase();
if (value === '__scheduled__') {
return '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
}
if (v.includes('sent')) {
return '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>';
}
if (v.includes('star') || v.includes('favorite')) {
return '<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>';
}
if (v.includes('archive') || v.includes('all mail')) {
return '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="5" rx="1"/><path d="M5 9v10h14V9"/><path d="M10 13h4"/></svg>';
}
if (v.includes('junk') || v.includes('spam')) {
return '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>';
}
if (v.includes('trash') || v.includes('bin')) {
return '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>';
}
if (v.includes('draft')) {
return '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>';
}
if (v === 'inbox' || v.includes('inbox')) {
return '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11Z"/></svg>';
}
return '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>';
}
function _renderFolderPicker() {
const sel = document.getElementById('email-lib-folder');
const btn = document.getElementById('email-folder-btn');
const menu = document.getElementById('email-folder-menu');
if (!sel || !btn || !menu) return;
const value = sel.value || state._libFolder || 'INBOX';
const selectedOpt = [...sel.options].find(o => o.value === value && !o.disabled);
const label = selectedOpt?.textContent || folderDisplayName(value);
const iconWrap = btn.querySelector('.email-folder-current-icon');
const labelEl = btn.querySelector('.email-folder-label');
if (iconWrap) iconWrap.innerHTML = _folderIcon(value);
if (labelEl) labelEl.textContent = label;
const esc = s => String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
const rows = [];
for (const opt of sel.children) {
if (opt.disabled) {
rows.push('<div class="email-filter-group email-folder-group">Folders</div>');
continue;
}
rows.push(`<button type="button" role="option" class="email-filter-item email-folder-item" data-value="${esc(opt.value)}">
<span class="email-filter-item-icon">${_folderIcon(opt.value)}</span>
<span class="email-filter-item-label">${esc(opt.textContent || folderDisplayName(opt.value))}</span>
</button>`);
}
menu.innerHTML = rows.join('');
}
function _initFolderPicker() {
const sel = document.getElementById('email-lib-folder');
const picker = document.getElementById('email-folder-picker');
const btn = document.getElementById('email-folder-btn');
const menu = document.getElementById('email-folder-menu');
if (!sel || !picker || !btn || !menu || picker._wired) return;
picker._wired = true;
let dismiss = () => {};
const finishClose = () => {
menu.hidden = true;
btn.setAttribute('aria-expanded', 'false');
dismiss = () => {};
};
const open = () => {
const filterMenu = document.getElementById('email-filter-menu');
const filterBtn = document.getElementById('email-filter-btn');
filterMenu?._dismiss?.();
if (filterMenu && !filterMenu.hidden) filterMenu.hidden = true;
if (filterBtn) filterBtn.setAttribute('aria-expanded', 'false');
_renderFolderPicker();
menu.hidden = false;
btn.setAttribute('aria-expanded', 'true');
dismiss = bindMenuDismiss(menu, finishClose, e => !picker.contains(e.target));
};
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (menu.hidden) open(); else dismiss();
});
menu.addEventListener('click', (e) => {
const item = e.target.closest('.email-folder-item');
if (!item) return;
sel.value = item.dataset.value;
sel.dispatchEvent(new Event('change', { bubbles: true }));
dismiss();
});
document.addEventListener('click', (e) => {
if (!menu.hidden && !picker.contains(e.target)) dismiss();
});
_renderFolderPicker();
}
function _renderFilterPickerCurrent() {
const sel = document.getElementById('email-lib-filter');
const btn = document.getElementById('email-filter-btn');
if (!sel || !btn) return;
const value = sel.value || 'all';
const opt = sel.querySelector(`option[value="${CSS.escape(value)}"]`);
const label = opt ? opt.textContent : value;
const iconWrap = btn.querySelector('.email-filter-icon');
const labelEl = btn.querySelector('.email-filter-label');
if (iconWrap) iconWrap.innerHTML = _filterIcon(value);
if (labelEl) labelEl.textContent = label;
}
function _initFilterPicker() {
const sel = document.getElementById('email-lib-filter');
const picker = document.getElementById('email-filter-picker');
const btn = document.getElementById('email-filter-btn');
const menu = document.getElementById('email-filter-menu');
if (!sel || !picker || !btn || !menu || picker._wired) return;
picker._wired = true;
// Build menu from the hidden <select> contents (preserves optgroup labels).
const items = [];
for (const child of sel.children) {
if (child.tagName === 'OPTGROUP') {
items.push({ group: child.label });
for (const o of child.children) {
items.push({ value: o.value, label: o.textContent, group: child.label });
}
} else if (child.tagName === 'OPTION') {
items.push({ value: child.value, label: child.textContent });
}
}
menu.innerHTML = '<div class="email-filter-menu-title">Filter by...</div>' + items.map(it => {
if (!it.value) {
return `<div class="email-filter-group">${it.group}</div>`;
}
return `<button type="button" role="option" class="email-filter-item" data-value="${it.value}">
<span class="email-filter-item-icon">${_filterIcon(it.value)}</span>
<span class="email-filter-item-label">${it.label}</span>
</button>`;
}).join('');
let dismiss = () => {};
const finishClose = () => {
menu.hidden = true;
btn.setAttribute('aria-expanded', 'false');
dismiss = () => {};
};
const open = () => {
const folderMenu = document.getElementById('email-folder-menu');
const folderBtn = document.getElementById('email-folder-btn');
folderMenu?._dismiss?.();
if (folderMenu && !folderMenu.hidden) folderMenu.hidden = true;
if (folderBtn) folderBtn.setAttribute('aria-expanded', 'false');
menu.hidden = false;
btn.setAttribute('aria-expanded', 'true');
dismiss = bindMenuDismiss(menu, finishClose, e => !picker.contains(e.target));
};
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (menu.hidden) open(); else dismiss();
});
menu.addEventListener('click', (e) => {
const item = e.target.closest('.email-filter-item');
if (!item) return;
sel.value = item.dataset.value;
sel.dispatchEvent(new Event('change', { bubbles: true }));
dismiss();
});
document.addEventListener('click', (e) => {
if (!menu.hidden && !picker.contains(e.target)) dismiss();
});
_renderFilterPickerCurrent();
}
function _renderEmailLoading(grid) {
if (!grid) return null;
grid.innerHTML = '';
const wrap = document.createElement('div');
wrap.className = 'email-list-skeleton';
wrap.setAttribute('aria-label', 'Loading emails');
wrap.innerHTML = Array.from({ length: 8 }, (_, idx) => `
<div class="email-skeleton-row${idx % 3 === 2 ? ' compact' : ''}">
<span class="email-skeleton-dot"></span>
<div class="email-skeleton-lines">
<span class="email-skeleton-line title"></span>
<span class="email-skeleton-line meta"></span>
</div>
<span class="email-skeleton-line date"></span>
</div>
`).join('');
grid.appendChild(wrap);
return null;
}
function _emailReaderSkeletonHtml() {
return `
<div class="email-reader-skeleton" aria-label="Loading email">
<div class="email-reader-skeleton-header">
<span class="email-skeleton-line chip"></span>
<span class="email-skeleton-line chip short"></span>
<span class="email-skeleton-line action"></span>
<span class="email-skeleton-line action"></span>
<span class="email-skeleton-line action"></span>
</div>
<div class="email-reader-skeleton-atts">
<span class="email-skeleton-line attachment"></span>
<span class="email-skeleton-line attachment short"></span>
</div>
<div class="email-reader-skeleton-body">
<span class="email-skeleton-line body wide"></span>
<span class="email-skeleton-line body"></span>
<span class="email-skeleton-line body medium"></span>
<span class="email-skeleton-line body gap"></span>
<span class="email-skeleton-line body wide"></span>
<span class="email-skeleton-line body medium"></span>
<span class="email-skeleton-line body"></span>
<span class="email-skeleton-line body wide"></span>
<span class="email-skeleton-line body medium"></span>
<span class="email-skeleton-line body gap"></span>
<span class="email-skeleton-line body"></span>
<span class="email-skeleton-line body wide"></span>
<span class="email-skeleton-line body short"></span>
</div>
</div>
`;
}
function _appendEmailSearchProgressRow(grid) {
if (!grid || !_libSearchInFlight || grid.querySelector('.email-search-progress-row')) return;
const row = document.createElement('div');
row.className = 'email-search-progress-row';
row.innerHTML = `
<span class="email-search-progress-dot"></span>
<span>Searching more mail...</span>
`;
grid.appendChild(row);
}
// Refreshes the small accent-pill in the modal title with the unread count
// for the current folder. When the inbox is currently filtered to unread, the
// pill flips to show the total-emails count + "all" label, because clicking
// it would toggle the filter off — so the label needs to advertise the
// action, not the now-current view. Uses the cheap unread-state endpoint for
// the normal badge; silent on failure.
async function _refreshUnreadBadge({ unreadCountOverride = null, preserveAutoReplyTitle = false, syncAutoReplyTitle = false } = {}) {
const badge = document.getElementById('email-lib-unread-badge');
if (!badge) return;
const refreshSeq = ++_autoReplyRefreshSeq;
const accountAtStart = String(state._libAccountId || '');
try {
const folder = state._libFolder || 'INBOX';
const autoReplyBadge = document.getElementById('email-lib-auto-reply-badge');
if (folder === '__scheduled__') {
badge.style.display = 'none';
if (syncAutoReplyTitle && !preserveAutoReplyTitle) _syncEmailAutoReplyTitle(false);
return;
}
const cfg = await _fetchEmailSettingsConfig().catch(() => null);
// Several mailbox/account refreshes can overlap while the settings page
// is saving. Do not let an older response restore the previous account's
// Auto Reply state after the current account has already changed.
if (refreshSeq !== _autoReplyRefreshSeq || accountAtStart !== String(state._libAccountId || '')) return;
if (!cfg) return;
const awayActive = _isAutoReplyActiveForCurrentAccount(cfg);
if (!preserveAutoReplyTitle) {
const awayChanged = state._libAutoReplyActive !== awayActive;
state._libAutoReplyActive = awayActive;
if (awayChanged) _renderAccountsStrip();
if (syncAutoReplyTitle && typeof state._libAutoReplyDraftActive !== 'boolean') {
_syncEmailAutoReplyTitle(awayActive);
}
}
if (autoReplyBadge) {
autoReplyBadge.title = awayActive ? 'Auto Reply is active - open settings' : 'Auto Reply is off';
}
let n;
if (unreadCountOverride !== null && unreadCountOverride !== undefined) {
n = Math.max(0, Number(unreadCountOverride) || 0);
} else {
const res = await fetch(emailApiUrl('/api/email/unread-state', {
folder,
account_id: state._libAccountId || undefined,
}));
const data = await res.json();
n = data.unread_count || 0;
}
_syncUnreadTabBadge(n);
if (folder === 'INBOX') _syncCurrentAccountUnreadCount(n);
badge.classList.remove('email-lib-away-badge');
if (state._libFilter === 'unread') {
// Currently viewing unread — show what the click will take you to.
try {
const allRes = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folder)}${_acct()}&limit=1&filter=all`);
const allData = await allRes.json();
const t = allData.total || 0;
badge.textContent = `${t} all`;
badge.title = 'Show all emails';
badge.dataset.mode = 'unread';
badge.style.display = '';
} catch (_) {
badge.textContent = 'Show all';
badge.title = 'Show all emails';
badge.dataset.mode = 'unread';
badge.style.display = '';
}
} else if (n > 0) {
badge.textContent = n > 999 ? '999+ unread' : `${n} unread`;
badge.title = 'Show unread emails';
badge.dataset.mode = 'unread';
badge.style.display = '';
} else {
delete badge.dataset.mode;
badge.style.display = 'none';
}
} catch (_) { _syncUnreadTabBadge(0); }
}
async function _loadEmails({ force = false, useCache = true } = {}) {
const seq = ++_libLoadSeq;
state._libLoading = true;
const accountAtStart = state._libAccountId || '';
const folderAtStart = state._libFolder;
const filterAtStart = state._libFilter;
const offsetAtStart = state._libOffset;
const searchAtStart = state._libSearch;
const hasAttachmentsAtStart = state._libHasAttachments;
const dateFromAtStart = state._libDateFrom || '';
const dateToAtStart = state._libDateTo || '';
const grid = document.getElementById('email-lib-grid');
if (!grid) { if (seq === _libLoadSeq) state._libLoading = false; return; }
// SWR: when loading the first page of a real folder with no search,
// paint the cached list immediately (no spinner, no blank grid) and
// then quietly refetch behind it. Pagination, search, and the
// scheduled virtual folder skip the cache and use the old spinner
// path. `force` (Refresh button) can still consult the cache for
// perceptual continuity, but adds a cache-buster so the server's 8s
// list cache is bypassed too. Account/folder/filter changes pass
// `useCache: false` so stale rows from the previous view never flash.
const cacheable =
offsetAtStart === 0 &&
!searchAtStart &&
folderAtStart !== '__scheduled__';
const ck = cacheable ? _libCacheKey() : null;
const cachedCandidate = (useCache && cacheable) ? _libCacheGet(ck) : null;
// Do not paint a cached empty result while a forced refresh is already
// underway. A transient provider failure can cache an empty tag filter;
// rendering it first flashes "No emails" before the fresh rows arrive.
const cached = cachedCandidate
&& Array.isArray(cachedCandidate.emails)
&& cachedCandidate.emails.length
? cachedCandidate
: null;
let paintedExisting = Boolean(cached || state._libEmails.length);
const paintData = (data, { cacheSource = false } = {}) => {
state._libEmails = data.emails || [];
state._libTotal = data.total || 0;
_libRenderedViewKey = ck || '';
const sync = data.sync || {};
const _activePills = (state._libSearchPills || []).length > 0
|| (state._libSearchDraft || '').length > 0;
if (_activePills) {
_libPreSearchEmails = state._libEmails.slice();
_libPreSearchTotal = state._libTotal;
_applyPillFilter();
} else {
_renderGrid();
}
const stats = document.getElementById('email-lib-stats');
if (stats) stats.textContent = `${state._libTotal} emails`;
_setEmailSyncStatus({
updatedAt: sync.updated_at || '',
source: sync.source || (cacheSource ? 'client_cache' : ''),
loading: false,
});
if (cacheable && !cacheSource) {
_libCachePut(ck, { emails: state._libEmails.slice(), total: state._libTotal, sync });
}
};
let sp = null;
if (cached) {
// Suppress the open-cascade animation when we're painting from
// cache — the data was already on screen a moment ago, so sliding
// each card in fresh feels janky. Also prevents the cascade from
// re-firing when the bg refetch lands within the 900ms cleanup
// window and appends new card nodes into the still-classed grid.
state._libJustOpened = false;
const grid2 = document.getElementById('email-lib-grid');
if (grid2) grid2.classList.remove('email-lib-just-opened');
paintData(cached, { cacheSource: true });
paintedExisting = true;
if (force) _setEmailSyncStatus({ loading: true });
if (!force) return;
} else if (state._libEmails.length && cacheable) {
_renderGrid();
_setEmailSyncStatus({ loading: true });
paintedExisting = true;
} else {
sp = _renderEmailLoading(grid);
}
try {
_syncUnreadWindowGlow();
if (folderAtStart === '__scheduled__') {
await _loadScheduled(grid, sp);
} else {
const accountQS = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : '';
const attQS = hasAttachmentsAtStart ? '&has_attachments=1' : '';
const dateQS = `${dateFromAtStart ? `&date_from=${encodeURIComponent(dateFromAtStart)}` : ''}${dateToAtStart ? `&date_to=${encodeURIComponent(dateToAtStart)}` : ''}`;
if (!cached && cacheable && !force) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 450);
try {
const fastRes = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folderAtStart)}${accountQS}&limit=${_LIB_INITIAL_PAGE_SIZE}&offset=${offsetAtStart}&filter=${filterAtStart}${attQS}&cached_only=1`, {
signal: ctrl.signal,
});
const fastData = await fastRes.json().catch(() => null);
if (seq === _libLoadSeq
&& accountAtStart === (state._libAccountId || '')
&& fastData
&& !fastData.error
&& Array.isArray(fastData.emails)
&& fastData.emails.length) {
if (sp) { sp.destroy(); sp = null; }
state._libJustOpened = false;
grid.classList.remove('email-lib-just-opened');
paintData(fastData);
paintedExisting = true;
if (!force) return;
}
} catch (_) {
// Cold index miss/timeout: leave the spinner and continue to IMAP.
} finally {
clearTimeout(timer);
}
}
// `refresh=1` is the explicit manual-refresh contract: the server
// evicts its list cache, drops the pooled IMAP handle, and refetches
// visible rows instead of trusting the durable index. `&_=...` remains
// as a browser/proxy cache-buster.
const buster = force ? `&refresh=1&_=${Date.now()}` : '';
const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folderAtStart)}${accountQS}&limit=${_LIB_INITIAL_PAGE_SIZE}&offset=${offsetAtStart}&filter=${filterAtStart}${attQS}${dateQS}${buster}`);
const data = await res.json();
if (seq !== _libLoadSeq || accountAtStart !== (state._libAccountId || '')) return;
if (data.error) throw new Error(data.error);
const sync = data.sync || {};
if (sp) sp.destroy();
paintData({ emails: data.emails || [], total: data.total || 0, sync });
if (filterAtStart === 'unread') {
_refreshUnreadBadge({ unreadCountOverride: data.total || 0 });
} else {
_refreshUnreadBadge();
_refreshAccountUnreadHighlights().catch(() => {});
}
}
} catch (e) {
if (seq !== _libLoadSeq || accountAtStart !== (state._libAccountId || '')) return;
if (sp) sp.destroy();
// If we already painted the cached list, leave it on screen — beats
// wiping it for "Failed to load" when there's still readable content.
if (!paintedExisting) {
const msg = e && e.message ? `Failed to load: ${e.message}` : 'Failed to load';
grid.innerHTML = `<div class="email-loading">${_esc(msg)}${_emailSetupHintHtml()}</div>`;
_wireEmailSetupHint(grid);
}
} finally {
if (seq === _libLoadSeq) state._libLoading = false;
}
}
async function _loadScheduled(grid, sp) {
const res = await fetch(`${API_BASE}/api/email/scheduled`);
const data = await res.json();
if (sp) sp.destroy();
const items = data.scheduled || [];
grid.innerHTML = '';
const stats = document.getElementById('email-lib-stats');
if (stats) stats.textContent = `${items.length} scheduled`;
_setEmailSyncStatus({
updatedAt: new Date().toISOString(),
source: 'local',
loading: false,
});
if (items.length === 0) {
grid.innerHTML = '<div class="email-loading">No scheduled emails</div>';
return;
}
for (const it of items) {
const card = document.createElement('div');
card.className = 'doclib-card memory-item';
const sendDate = new Date(it.send_at);
const dateStr = sendDate.toLocaleString([], {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
});
const content = document.createElement('div');
content.style.cssText = 'flex:1;min-width:0;';
const subject = it.subject || '(no subject)';
const toDisplay = it.to || '(no recipient)';
content.innerHTML = `
<div style="display:flex;align-items:center;gap:6px;">
<span class="memory-item-title">${_esc(subject)}</span>
${it.status === 'failed' ? '<span style="font-size:9px;color:var(--red);border:1px solid var(--red);padding:1px 4px;border-radius:4px;">FAILED</span>' : '<span style="font-size:9px;opacity:0.6;border:1px solid var(--border);padding:1px 4px;border-radius:4px;">PENDING</span>'}
</div>
<div style="font-size:10px;opacity:0.7;margin-top:2px;">
To: ${_esc(toDisplay)} · Sends ${_esc(dateStr)}
</div>
${it.error ? `<div style="font-size:10px;color:var(--red);margin-top:2px;">${_esc(it.error)}</div>` : ''}
`;
card.appendChild(content);
// Cancel button
const cancelBtn = document.createElement('button');
cancelBtn.className = 'memory-item-btn';
cancelBtn.title = 'Cancel scheduled send';
cancelBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
cancelBtn.addEventListener('click', async (e) => {
e.stopPropagation();
const { styledConfirm } = await import('./ui.js?v=20260916largetoolscroll1');
const ok = await styledConfirm(`Cancel scheduled email "${subject}"?`, { confirmText: 'Cancel Send', cancelText: 'Keep', danger: true });
if (!ok) return;
try {
await fetch(`${API_BASE}/api/email/scheduled/${it.id}`, { method: 'DELETE' });
_loadEmails();
} catch (err) { console.error(err); }
});
const actionsWrap = document.createElement('div');
actionsWrap.className = 'memory-item-actions';
actionsWrap.appendChild(cancelBtn);
card.appendChild(actionsWrap);
grid.appendChild(card);
}
}
function _emailDateBucketLabel(value) {
if (!value) return 'Older';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return 'Older';
const dayStart = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
const now = new Date();
const today = dayStart(now);
const day = dayStart(d);
const diff = Math.round((today - day) / 86400000);
if (diff === 0) return 'Today';
if (diff === 1) return 'Yesterday';
if (diff > 1 && diff < 7) return d.toLocaleDateString([], { weekday: 'long' });
if (diff >= 365) {
const years = Math.floor(diff / 365);
return `${years} ${years === 1 ? 'year' : 'years'} ago`;
}
if (diff >= 180) return '6 months ago';
if (diff >= 30) return `${Math.floor(diff / 30) * 30} days ago`;
const sameYear = d.getFullYear() === now.getFullYear();
return d.toLocaleDateString([], sameYear ? { month: 'long', day: 'numeric' } : { month: 'long', day: 'numeric', year: 'numeric' });
}
function _createEmailDateHeader(label, timelineBreak = false) {
const el = document.createElement('div');
el.className = 'date-section-header email-date-section-header' + (timelineBreak ? ' email-date-gap-break' : '');
el.textContent = label;
return el;
}
// Add breathing room only for an unexpected hole in an otherwise dense
// timeline. A uniformly sparse archive should remain compact, even when its
// dates are months apart.
function _emailTimelineGapThreshold(items) {
const dates = items.map(em => new Date(em?.date).getTime()).filter(Number.isFinite);
const gaps = [];
for (let i = 1; i < dates.length; i++) {
const gap = Math.abs(dates[i - 1] - dates[i]) / 86400000;
if (gap > 0) gaps.push(gap);
}
if (!gaps.length) return 90;
gaps.sort((a, b) => a - b);
const middle = Math.floor(gaps.length / 2);
const median = gaps.length % 2 ? gaps[middle] : (gaps[middle - 1] + gaps[middle]) / 2;
return Math.max(90, median * 2.5);
}
function _dateGroupEmailsWithPinned(items, mode = 'recent') {
const ordered = [];
let groupLabel = null;
let groupItems = [];
const priority = (em) => {
if (mode === 'unread') return Number(!em?.is_read);
return Number(!!em?.is_flagged);
};
const flushGroup = () => {
if (!groupItems.length) return;
ordered.push(...groupItems.map((em, idx) => ({ em, idx }))
.sort((a, b) => priority(b.em) - priority(a.em) || a.idx - b.idx)
.map(item => item.em));
groupItems = [];
};
for (const em of items) {
const label = _emailDateBucketLabel(em?.date);
if (label !== groupLabel) {
flushGroup();
groupLabel = label;
}
groupItems.push(em);
}
flushGroup();
return ordered;
}
function _renderGrid() {
const grid = document.getElementById('email-lib-grid');
if (!grid) return;
const modal = document.getElementById('email-lib-modal');
// A background mailbox refresh must not destroy the reader currently being
// viewed. Clearing the grid here removes the expanded card while leaving
// `.email-reading` on the modal, which produces a headerless full email
// list. Explicit folder/account/search changes call the reset helper first.
if (grid.querySelector('.doclib-card-expanded, .email-card-expanded')) return;
if (modal?.classList.contains('email-reading')) {
modal.classList.remove('email-reading');
modal.style.removeProperty('--email-reading-modal-min-h');
}
grid.innerHTML = '';
let filtered = state._libEmails;
try { console.log('[email-search] _renderGrid: state._libEmails.length=', (state._libEmails || []).length, 'pills=', (state._libSearchPills || []).length, 'draft=', JSON.stringify(state._libSearchDraft || ''), 'libSearch=', JSON.stringify(state._libSearch || '')); } catch {}
_syncSearchOptionsMenu();
// 'recent' is the default order from the API. Date stays the primary
// grouping; unread/favorite priorities float inside each date section.
filtered = _dateGroupEmailsWithPinned([...filtered], state._libSort);
if (filtered.length === 0) {
// Active search — don't flash "No emails": the IMAP fetch is still
// running. Show a "Searching…" placeholder until _doSearch resolves
// and renders again. Without this the user saw an empty state
// smiley for ~500ms between the optimistic pill-filter clear and
// the server response landing.
if (_libSearchInFlight) {
_renderEmailLoading(grid);
return;
}
// Inbox-zero is a win — pair the message with a small smiley so the
// empty state reads as "all caught up", not "something's broken".
const _smileyIco = '<span style="vertical-align:-3px;margin-left:6px;">' + emptyStateIcon('smiley') + '</span>';
// Only show the "Set up at Settings › Integrations" hint when the inbox
// is TRULY empty — no filter, no search, no source emails. A sub-filter
// (reminders, unread, etc.) that happens to be empty isn't a setup
// problem; the link there reads as nonsense.
const _isTrulyEmpty = (
state._libEmails.length === 0
&& (!state._libFilter || state._libFilter === 'all')
&& !(state._libSearch || '').trim()
);
if (_isTrulyEmpty) {
grid.innerHTML =
'<div class="email-loading" style="display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;text-align:center;">' +
'<span>No emails' + _smileyIco + '</span>' +
'<span style="opacity:0.7;font-size:11px;">' +
'Set up at: <a href="#" data-open-settings="integrations" style="color:var(--accent,var(--red));text-decoration:underline;">Settings &rsaquo; Integrations</a>' +
'</span>' +
'</div>';
const _link = grid.querySelector('[data-open-settings]');
if (_link) _link.addEventListener('click', (e) => {
e.preventDefault();
_openSettingsTab(_link.dataset.openSettings || 'integrations');
});
} else {
grid.innerHTML =
'<div class="email-loading" style="display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap;">' +
'<span>No emails' + _smileyIco + '</span>' +
'</div>';
}
return;
}
// Cascade-on-open: fire the same domino-in animation the sidebar
// section uses. Only on the FIRST grid render after the library is
// opened — subsequent re-renders (filter/sort/search) need to be
// instant.
if (state._libJustOpened) {
grid.classList.add('email-lib-just-opened');
state._libJustOpened = false;
// Strip the class after the cascade so it doesn't restrict later
// animations (e.g. the FLIP reflow when archiving). Worst-case
// duration matches the longest delay in the keyframe set below.
setTimeout(() => grid.classList.remove('email-lib-just-opened'), 900);
}
let lastDateLabel = null;
const timelineGapThreshold = _emailTimelineGapThreshold(filtered);
let previousEmailTime = null;
for (const em of filtered) {
const dateLabel = _emailDateBucketLabel(em?.date);
if (dateLabel !== lastDateLabel) {
const emailTime = new Date(em?.date).getTime();
const gapDays = Number.isFinite(previousEmailTime) && Number.isFinite(emailTime)
? Math.abs(previousEmailTime - emailTime) / 86400000
: 0;
const timelineBreak = gapDays > 90 && gapDays > timelineGapThreshold;
grid.appendChild(_createEmailDateHeader(dateLabel, timelineBreak));
lastDateLabel = dateLabel;
}
grid.appendChild(_createCard(em));
const emailTime = new Date(em?.date).getTime();
if (Number.isFinite(emailTime)) previousEmailTime = emailTime;
}
_appendEmailSearchProgressRow(grid);
// If a deep-link asked us to expand a specific email, do it when the card
// exists. Keep the UID through cached paints so a later fresh load can
// still expand a newly-sent message that was absent from the cache.
if (state._libPendingExpandUid) {
const target = filtered.find(e => String(e.uid) === String(state._libPendingExpandUid));
const wantUid = state._libPendingExpandUid;
if (target) {
const cards = grid.querySelectorAll('.doclib-card');
const targetCard = Array.from(cards).find(c => c.dataset.uid === String(wantUid));
if (targetCard) {
state._libPendingExpandUid = null;
requestAnimationFrame(() => _toggleCardPreview(targetCard, target));
}
}
}
}
function _createCard(em) {
_normalizeEmailStateFlags(em);
const card = document.createElement('div');
let cls = 'doclib-card memory-item';
if (em.is_answered) cls += ' email-card-answered';
else if (!em.is_read) cls += ' email-card-unread';
card.className = cls;
card.dataset.uid = String(em.uid);
card.dataset.emailAccount = String(em.account_id || state._libAccountId || '');
const cardFolder = _hasActiveEmailSearchResults()
? (em.folder || state._libFolder || 'INBOX')
: (state._libFolder || em.folder || 'INBOX');
card.dataset.emailFolder = String(cardFolder);
if (state._selectMode && state._selectedUids.has(em.uid)) card.classList.add('selected');
// Checkbox in select mode
if (state._selectMode) {
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'memory-select-cb';
cb.checked = state._selectedUids.has(em.uid);
cb.addEventListener('click', e => e.stopPropagation());
cb.addEventListener('change', () => {
if (cb.checked) state._selectedUids.add(em.uid);
else state._selectedUids.delete(em.uid);
card.classList.toggle('selected', cb.checked);
_updateBulkBar();
});
card.appendChild(cb);
}
// In Sent results, show the recipient(s) — the sender is always you and
// hides the actually useful info. Search results can be stamped with their
// real folder while the visible folder selector still says INBOX, so the
// selected mailbox remains authoritative for normal folder views.
const isSentFolderEarly = /sent/i.test(cardFolder);
let senderName;
let senderAddress;
if (isSentFolderEarly) {
senderName = _formatRecipients(em.to) || em.to || '(no recipient)';
// First address out of em.to for click-to-pill targeting.
const _firstTo = String(em.to || '').split(',')[0] || '';
const _m = _firstTo.match(/<([^>]+)>/);
senderAddress = (_m ? _m[1] : _firstTo).trim();
} else {
senderName = em.from_name || em.from_address;
senderAddress = em.from_address || '';
}
const color = _senderColor(senderName);
let dateStr = '';
if (em.date) {
try {
const d = new Date(em.date);
const now = new Date();
const sameYear = d.getFullYear() === now.getFullYear();
const dateOpts = sameYear
? { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }
: { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' };
dateStr = d.toLocaleString([], dateOpts);
} catch (_) {}
}
const content = document.createElement('div');
content.style.cssText = 'flex:1;min-width:0;';
const titleRow = document.createElement('div');
titleRow.className = 'email-card-titlerow';
titleRow.style.cssText = 'display:flex;align-items:center;gap:6px;';
const titleEl = document.createElement('span');
titleEl.className = 'memory-item-title';
titleEl.textContent = em.subject || '(no subject)';
// Hover preview: surface the cached AI summary directly on the title via
// a native browser tooltip — no need to open the email to skim it.
if (em.cached_summary) {
titleEl.title = em.cached_summary;
titleEl.classList.add('email-card-has-summary');
}
titleRow.appendChild(titleEl);
const cardChevron = document.createElement('span');
cardChevron.className = 'doclib-card-chevron';
cardChevron.setAttribute('aria-hidden', 'true');
cardChevron.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
const isSentFolder = /sent/i.test(cardFolder);
const statusCluster = document.createElement('span');
statusCluster.className = 'email-card-status';
statusCluster.setAttribute('aria-label', 'Email status');
if (em.has_attachments) {
const att = document.createElement('span');
att.className = 'email-card-attachment';
att.title = 'Has attachments';
att.style.cssText = 'opacity:0.6;flex-shrink:0;display:inline-flex;';
att.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>';
statusCluster.appendChild(att);
}
const tags = state._libShowTags ? _visibleEmailTagsForRender(em) : [];
if (state._libShowTags && (tags.length || em.is_spam_verdict)) {
const tagWrap = document.createElement('span');
tagWrap.className = 'email-tags email-card-tags' + (tags.length > 2 ? ' email-tags-collapsed' : '');
tagWrap.innerHTML = _emailTagGroupHtml(tags, em);
if (em.is_spam_verdict) {
tagWrap.insertAdjacentHTML('beforeend', '<span class="email-tag email-tag-spam">spam</span>');
}
let moreBtn = tagWrap.querySelector('[data-email-tags-more]');
if (moreBtn) {
const tintSource = tagWrap.querySelector('.email-tag');
requestAnimationFrame(() => {
if (tintSource?.isConnected) moreBtn.style.setProperty('--email-tags-more-color', getComputedStyle(tintSource).color);
});
}
tagWrap.addEventListener('click', (ev) => {
if (state._selectMode) return;
const calBtn = ev.target.closest('[data-calendar-event-uid]');
const tagBtn = ev.target.closest('[data-email-filter-tag]');
const moreBtn = ev.target.closest('[data-email-tags-more]');
if (!calBtn && !tagBtn && !moreBtn) return;
ev.preventDefault();
ev.stopPropagation();
if (moreBtn) {
const expanded = tagWrap.classList.toggle('email-tags-expanded');
moreBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
const pills = tagWrap.querySelectorAll(':scope > .email-tag, :scope > .email-tag-extra');
if (expanded) {
tagWrap.style.flexWrap = 'wrap';
pills.forEach(pill => { pill.style.display = 'inline-flex'; });
moreBtn.style.display = 'inline-flex';
} else {
tagWrap.style.flexWrap = '';
pills.forEach(pill => { pill.style.display = ''; });
moreBtn.style.display = '';
requestAnimationFrame(() => _fitEmailCardTags(titleRow));
}
} else if (calBtn) _openCalendarEventFromEmail(calBtn.dataset.calendarEventUid);
else _applyTagFilterFromPill(tagBtn.dataset.emailFilterTag);
});
titleRow.appendChild(tagWrap);
}
// Keep the status controls in one right-aligned cluster. Favorite is always
// present as an outline so it is discoverable before activation.
if (!isSentFolder) {
const doneCheck = document.createElement('span');
doneCheck.className = 'email-card-done' + (em.is_answered ? ' active' : '');
doneCheck.title = em.is_answered ? 'Mark not done' : 'Mark done';
doneCheck.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
const _toggleDone = async (e) => {
if (e) e.stopPropagation();
// Use the visible class as source of truth — em.is_answered could
// be stale from a background sync, which would leave the user
// clicking and seeing no UI change.
const wasActive = doneCheck.classList.contains('active');
const newState = !wasActive;
em.is_answered = newState;
doneCheck.classList.toggle('active', newState);
doneCheck.title = newState ? 'Mark not done' : 'Mark done';
// Animate in both directions so the user gets explicit feedback when
// un-checking too — without this the hover state and the active state
// look identical, so the click felt like a no-op.
doneCheck.classList.remove('just-checked', 'just-unchecked');
void doneCheck.offsetWidth; // restart animation
doneCheck.classList.add(newState ? 'just-checked' : 'just-unchecked');
setTimeout(() => doneCheck.classList.remove('just-checked', 'just-unchecked'), 500);
if (newState) {
_clearDoneResponseTagsLocal(em);
titleRow.querySelectorAll('.email-tag-urgent, .email-tag-reply-soon, .email-tag-action-needed').forEach(n => n.remove());
_syncEmailReadState(em.uid, true);
}
try {
if (newState) {
await fetch(`${API_BASE}/api/email/mark-answered/${em.uid}?folder=${encodeURIComponent(cardFolder)}${_acct()}`, { method: 'POST' });
await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(cardFolder)}${_acct()}`, { method: 'POST' });
} else {
await fetch(`${API_BASE}/api/email/clear-answered/${em.uid}?folder=${encodeURIComponent(cardFolder)}${_acct()}`, { method: 'POST' });
}
} catch (err) { console.error(err); }
};
doneCheck.addEventListener('click', _toggleDone);
statusCluster.appendChild(doneCheck);
}
const favoriteToggle = document.createElement('button');
favoriteToggle.type = 'button';
favoriteToggle.className = 'email-card-favorite' + (em.is_flagged ? ' active' : '');
favoriteToggle.title = em.is_flagged ? 'Unfavorite' : 'Favorite';
favoriteToggle.setAttribute('aria-label', favoriteToggle.title);
favoriteToggle.setAttribute('aria-pressed', em.is_flagged ? 'true' : 'false');
favoriteToggle.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="' + (em.is_flagged ? 'currentColor' : 'none') + '" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>';
favoriteToggle.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const next = !em.is_flagged;
const favoritesView = state._libFilter === 'favorites';
const previousIndex = state._libEmails.findIndex(item => item === em);
em.is_flagged = next;
// The Favorites mailbox is already a server-filtered snapshot. Remove an
// unfavorited row locally so the click has immediate, visible feedback.
if (favoritesView && !next) {
state._libEmails = state._libEmails.filter(item => item !== em);
}
_renderGrid();
try {
const res = await fetch(`${API_BASE}/api/email/flag/${em.uid}?folder=${encodeURIComponent(cardFolder)}${_acct()}&on=${next ? 'true' : 'false'}`, { method: 'POST' });
const data = await res.json().catch(() => null);
if (!res.ok || data?.success === false) throw new Error(data?.error || `HTTP ${res.status}`);
_libCacheWriteBack();
} catch (err) {
em.is_flagged = !next;
if (favoritesView && !next && previousIndex >= 0) {
state._libEmails.splice(previousIndex, 0, em);
}
_renderGrid();
console.error('Failed to toggle favorite:', err);
}
});
const doneControl = statusCluster.querySelector('.email-card-done');
if (doneControl) statusCluster.insertBefore(favoriteToggle, doneControl);
else statusCluster.appendChild(favoriteToggle);
if (!isSentFolder && !em.is_read) {
const dot = document.createElement('span');
dot.className = 'email-card-unread-dot';
dot.style.cssText = `width:6px;height:6px;border-radius:50%;background:${color};flex-shrink:0;margin-left:2px;`;
statusCluster.appendChild(dot);
}
titleRow.appendChild(statusCluster);
requestAnimationFrame(() => _fitEmailCardTags(titleRow));
_emailTagFitObserver?.observe(titleRow);
// Prev/next arrows — visible only when this card is the expanded one
// (CSS-gated so collapsed cards stay clean). Click navigates by collapsing
// this card and expanding the neighbour.
const navArrows = document.createElement('span');
navArrows.className = 'email-card-nav-arrows';
navArrows.innerHTML = `
<button type="button" class="email-card-nav-btn" data-nav-dir="-1" title="Previous email"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg></button>
<button type="button" class="email-card-nav-btn" data-nav-dir="1" title="Next email"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></button>
`;
navArrows.addEventListener('click', async (ev) => {
const btn = ev.target.closest('.email-card-nav-btn');
if (!btn || btn.disabled) return;
ev.stopPropagation();
const card = navArrows.closest('.doclib-card');
if (!card) return;
const dir = parseInt(btn.dataset.navDir, 10);
const sibling = _findSiblingEmailCard(card, dir);
if (!sibling) return;
const nextEm = state._libEmails.find(e => String(e.uid) === String(sibling.dataset.uid));
if (!nextEm) return;
await _toggleCardPreview(card, em);
await _toggleCardPreview(sibling, nextEm);
sibling.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
// Keep navigation in the same status row as Favorite and Done. The
// per-card `.memory-item-actions` menu
// at the bottom of the card stays visible while expanded (see the CSS
// override below), so duplicating it in the header was redundant.
statusCluster.appendChild(navArrows);
content.appendChild(titleRow);
const meta = document.createElement('div');
meta.className = 'memory-item-meta';
meta.style.cssText = 'font-size:10px;opacity:0.7;margin-top:2px;';
const showFolderChip = !!(_libSearchHadResults && cardFolder);
const prettyFolder = folderDisplayName(cardFolder);
const sentChip = isSentFolderEarly ? '<span class="email-sent-chip" title="Sent email">Sent</span>' : '';
const folderChip = showFolderChip && !isSentFolderEarly
? `<span class="email-folder-chip" title="${_esc(cardFolder)}">${_esc(prettyFolder)}</span>`
: '';
const senderPrefix = isSentFolderEarly ? 'to ' : '';
meta.innerHTML = `${sentChip}<span class="email-meta-sender" data-email="${_esc(senderAddress || '')}" data-name="${_esc(senderName || '')}"><span style="opacity:0.55">${senderPrefix}</span><span style="color:${color};font-weight:600">${_esc(senderName)}</span></span><span class="email-meta-sep"> · </span><span class="email-meta-date-group"><span class="email-meta-date">${_esc(dateStr)}</span>${folderChip}</span>`;
content.appendChild(meta);
card.appendChild(content);
// Per-card menu button (... menu)
if (!state._selectMode) {
const actionsWrap = document.createElement('div');
actionsWrap.className = 'memory-item-actions';
const menuBtn = document.createElement('button');
menuBtn.className = 'memory-item-btn';
menuBtn.title = 'Actions';
menuBtn.style.position = 'relative';
menuBtn.style.top = '-1px';
menuBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>';
menuBtn.addEventListener('click', (e) => {
e.stopPropagation();
_showCardMenu(em, menuBtn);
});
actionsWrap.appendChild(menuBtn);
card.appendChild(actionsWrap);
card.appendChild(cardChevron);
// Long-press anywhere on the row opens the same actions menu — matches
// the chats / archive / research / documents tabs' long-press UX.
let _hold = null, _holdStart = null;
const _cancelHold = () => { if (_hold) { clearTimeout(_hold); _hold = null; } _holdStart = null; };
card.addEventListener('pointerdown', (e) => {
if (card.classList.contains('email-card-expanded') || card.classList.contains('doclib-card-expanded')) return;
if (e.target.closest('button, .email-card-done, .recipient-chip, .memory-select-cb, .email-card-nav-btn')) return;
_holdStart = { x: e.clientX, y: e.clientY };
_hold = setTimeout(() => {
_hold = null;
if (card.classList.contains('email-card-expanded') || card.classList.contains('doclib-card-expanded')) return;
card._suppressNextClick = true;
setTimeout(() => { card._suppressNextClick = false; }, 400);
if (navigator.vibrate) try { navigator.vibrate(15); } catch {}
if (window.innerWidth <= 768 && ('ontouchstart' in window || navigator.maxTouchPoints > 0)) {
const selectBtn = document.getElementById('email-lib-select-btn');
if (selectBtn) {
if (!selectBtn.classList.contains('active')) selectBtn.click();
setTimeout(() => document.querySelector(`#email-lib-grid .doclib-card[data-uid="${CSS.escape(String(em.uid))}"] .memory-select-cb`)?.click(), 40);
return;
}
}
_showCardMenu(em, menuBtn);
}, 500);
});
card.addEventListener('pointermove', (e) => {
if (!_holdStart) return;
if (Math.hypot(e.clientX - _holdStart.x, e.clientY - _holdStart.y) > 10) _cancelHold();
});
card.addEventListener('pointerup', _cancelHold);
card.addEventListener('pointercancel', _cancelHold);
}
// Click handler — toggle preview expansion
card.addEventListener('click', async (e) => {
if (card._suppressNextClick) { card._suppressNextClick = false; return; }
if (state._selectMode) {
if (state._selectedUids.has(em.uid)) state._selectedUids.delete(em.uid);
else state._selectedUids.add(em.uid);
card.classList.toggle('selected', state._selectedUids.has(em.uid));
const cb = card.querySelector('.memory-select-cb');
if (cb) cb.checked = state._selectedUids.has(em.uid);
_updateBulkBar();
return;
}
if (e.shiftKey) {
e.preventDefault();
await _openEmailAsTab(em, cardFolder);
return;
}
if (/draft/i.test(String(state._libFolder || '')) && state._docModule?.openEmailDraft) {
try {
await _openDraftInComposer(em);
} catch (err) {
console.error('Failed to open email draft:', err);
showToast(`Could not open draft: ${err.message || err}`);
}
return;
}
await _toggleCardPreview(card, em);
});
return card;
}
function _findSiblingEmailCard(card, dir) {
const grid = card.closest('.doclib-grid');
if (!grid) return null;
const cards = [...grid.querySelectorAll('.doclib-card[data-uid]')];
const idx = cards.indexOf(card);
if (idx === -1) return null;
return cards[idx + dir] || null;
}
function _syncCardNavArrows(card) {
const prev = card.querySelector('.email-card-nav-btn[data-nav-dir="-1"]');
const next = card.querySelector('.email-card-nav-btn[data-nav-dir="1"]');
if (prev) prev.disabled = !_findSiblingEmailCard(card, -1);
if (next) next.disabled = !_findSiblingEmailCard(card, 1);
}
async function _openDraftInComposer(em) {
if (!state._docModule?.openEmailDraft) return false;
const folder = em?.folder || state._libFolder || 'Drafts';
const account = state._libAccountId ? `&account_id=${encodeURIComponent(state._libAccountId)}` : '';
const res = await fetch(`${API_BASE}/api/email/read/${encodeURIComponent(em.uid)}?folder=${encodeURIComponent(folder)}${account}&mark_seen=false`);
if (!res.ok) throw new Error(`Draft load failed: HTTP ${res.status}`);
const data = await res.json();
if (data.error) throw new Error(data.error);
await state._docModule.openEmailDraft({ ...em, ...data });
return true;
}
const _emailReadPrefetching = new Set();
let _emailReadPrefetchTimer = null;
function _prefetchAdjacentEmails(card, count = 1) {
if (!card || state._libFolder === '__scheduled__') return;
const grid = card.closest('.doclib-grid');
if (!grid) return;
const cards = [...grid.querySelectorAll('.doclib-card[data-uid]')];
const idx = cards.indexOf(card);
if (idx === -1) return;
const targets = [];
for (let i = 1; i <= count; i++) {
if (cards[idx + i]) targets.push(cards[idx + i]);
}
if (targets.length < count) {
for (let i = 1; targets.length < count && cards[idx - i]; i++) targets.push(cards[idx - i]);
}
const target = targets.find(t => t?.dataset?.uid);
const uid = target?.dataset?.uid;
if (!uid) return;
// Use the email's actual folder when it was stamped by the search
// endpoint; otherwise default to the currently-selected folder.
const _emFold = (() => {
const emObj = (state._libEmails || []).find(e => String(e.uid) === String(uid));
return (emObj && emObj.folder) || state._libFolder || 'INBOX';
})();
const key = `${state._libAccountId || ''}|${_emFold}|${uid}`;
if (_emailReadPrefetching.has(key) || _emailReadPrefetching.size > 0) return;
if (_emailReadPrefetchTimer) clearTimeout(_emailReadPrefetchTimer);
_emailReadPrefetchTimer = setTimeout(() => {
_emailReadPrefetchTimer = null;
if (document.hidden) return;
_emailReadPrefetching.add(key);
fetch(`${API_BASE}/api/email/read/${encodeURIComponent(uid)}?folder=${encodeURIComponent(_emFold)}${_acct()}&mark_seen=false`)
.catch(() => {})
.finally(() => _emailReadPrefetching.delete(key));
}, 2500);
}
async function _toggleCardPreview(card, em) {
_normalizeEmailStateFlags(em);
const accountAtStart = state._libAccountId || '';
const libraryFolderAtStart = state._libFolder || 'INBOX';
// Prefer the per-email folder stamped by the search endpoint (results
// from "All Mail" carry folder="[Gmail]/All Mail"). Falls back to the
// currently-selected folder for normal inbox cards.
const folderAtStart = _hasActiveEmailSearchResults()
? ((em && em.folder) || libraryFolderAtStart)
: libraryFolderAtStart;
const uidAtStart = String(em?.uid || card?.dataset?.uid || '');
const wasReadAtStart = !!em?.is_read;
const openGeneration = ++_emailCardOpenSeq;
const readContext = Object.freeze({
accountId: String(accountAtStart),
libraryFolder: String(libraryFolderAtStart),
folder: String(folderAtStart),
uid: uidAtStart,
mailboxGeneration: _emailMailboxGeneration,
});
const readContextKey = _emailReadContextKey(readContext);
const isCurrentOpen = () => (
openGeneration === _emailCardOpenSeq &&
_emailReadContextIsCurrent(readContext) &&
accountAtStart === (state._libAccountId || '') &&
libraryFolderAtStart === (state._libFolder || 'INBOX') &&
uidAtStart === String(card?.dataset?.uid || '') &&
card.isConnected &&
card.classList.contains('email-card-expanded')
);
const grid = card.closest('.doclib-grid');
const gridRect = grid?.getBoundingClientRect?.();
const modal = document.getElementById('email-lib-modal');
const modalContent = card.closest('.modal-content');
const modalRect = modalContent?.getBoundingClientRect?.();
const currentRect = card.getBoundingClientRect();
const stableOpenHeight = Math.max(
currentRect.height || 0,
(modalRect?.height || 0) - 84,
Math.min(Math.max(260, window.innerHeight * 0.56), gridRect?.height || window.innerHeight)
);
// Already expanded — collapse
if (card.classList.contains('email-card-expanded')) {
unbindExpandedCardDismiss(card);
card.classList.remove('email-card-expanded');
card.classList.remove('doclib-card-expanded');
card.style.minHeight = '';
modal?.classList.remove('email-reading');
modal?.style.removeProperty('--email-reading-modal-min-h');
const reader = card.querySelector('.email-card-reader');
if (reader) reader.remove();
return;
}
// Every authoritative open supersedes any older optimistic mutation for the
// same immutable mailbox identity. Carry the original unread state forward
// so a close/reopen followed by failure still rolls back exactly once, while
// a late failure from the superseded request cannot undo a newer success.
const previousMutation = _emailReadMutations.get(readContextKey);
const readMutation = {
generation: ++_emailReadMutationSeq,
rollbackUnread: !wasReadAtStart || !!previousMutation?.rollbackUnread,
};
_emailReadMutations.set(readContextKey, readMutation);
const restoreUnreadState = () => {
if (_emailReadMutations.get(readContextKey)?.generation !== readMutation.generation) return;
_emailReadMutations.delete(readContextKey);
if (readMutation.rollbackUnread) _syncEmailReadState(uidAtStart, false, readContext);
};
const commitReadState = () => {
// A successful STORE/mark_seen is authoritative for this immutable
// mailbox identity even when a newer open is still pending. Retire that
// newer rollback token too, otherwise its later failure could restore an
// unread state that no longer exists at the provider.
_emailReadMutations.delete(readContextKey);
_syncEmailReadState(uidAtStart, true, readContext);
};
// Collapse any other expanded card
if (grid) {
grid.querySelectorAll('.email-card-expanded').forEach(c => {
unbindExpandedCardDismiss(c);
c.classList.remove('email-card-expanded');
c.classList.remove('doclib-card-expanded');
c.style.minHeight = '';
const r = c.querySelector('.email-card-reader');
if (r) r.remove();
});
}
card.classList.add('email-card-expanded');
card.classList.add('doclib-card-expanded');
bindExpandedCardDismiss(card, () => _toggleCardPreview(card, em));
card.style.minHeight = `${Math.round(stableOpenHeight)}px`;
// Pull the card into view in case the user clicked an email further up
// the list whose top is partially scrolled off the viewport. Wait for
// the layout to settle (minHeight just changed) before scrolling so
// the browser scrolls toward the post-expansion position.
requestAnimationFrame(() => {
try { card.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (_) {}
});
if (!wasReadAtStart) {
// Keep the current optimistic visual update, but let the read request below
// own the provider-side \Seen transition. A failure restores unread state.
_syncEmailReadState(uidAtStart, true, readContext);
}
// Class hook on the modal so the header-hide / padding rules work on
// browsers without :has() support (Firefox mobile) — the :has() versions
// below stay as the desktop path.
if (modal && modalRect?.height) {
modal.style.setProperty('--email-reading-modal-min-h', `${Math.round(modalRect.height)}px`);
}
modal?.classList.add('email-reading');
// Show loading reader with whirlpool spinner
const reader = document.createElement('div');
reader.className = 'email-card-reader email-card-reader-loading';
reader.style.minHeight = `${Math.max(180, Math.round(stableOpenHeight - 70))}px`;
reader.innerHTML = _emailReaderSkeletonHtml();
card.appendChild(reader);
_markEmailReaderActive(reader);
const showFailedReader = (message) => {
try {
_showEmailReaderLoadError(reader, message, () => {
try { reader.remove(); } catch (_) {}
card.classList.remove('email-card-expanded', 'doclib-card-expanded');
card.style.minHeight = '';
setTimeout(() => { _toggleCardPreview(card, em); }, 0);
});
showToast(message || 'Failed to load email');
} catch (_) {}
};
let authoritativeReadSucceeded = false;
try {
const accountQueryAtStart = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : '';
const res = await fetch(`${API_BASE}/api/email/read/${encodeURIComponent(uidAtStart)}?folder=${encodeURIComponent(folderAtStart)}${accountQueryAtStart}&mark_seen=true`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
let data = await res.json();
if (data.error) {
restoreUnreadState();
if (isCurrentOpen()) showFailedReader(`Failed to load email: ${data.error}`);
return;
}
data = _normalizeEmailStateFlags({ ...em, ...data });
Object.assign(em, data);
_syncEmailDoneState(uidAtStart, data.is_answered, readContext);
if (data.mark_seen_failed) {
// The body is authoritative even when the provider refused the \Seen
// transition. Render the message and roll the unread marker back so the
// list keeps telling the truth, rather than refusing to open a message
// we successfully read.
restoreUnreadState();
} else {
authoritativeReadSucceeded = true;
commitReadState();
}
if (!isCurrentOpen()) return;
_stampReaderContext(reader, { ...em, ...data }, state._libFolder, state._libAccountId);
// Build the attachments wrap using the shared helper so the signature-
// image filter (small inline PNGs/JPGs, Outlook image001 placeholders,
// logo/banner files) is applied here too. Falls back to '' when every
// attachment is filtered out.
const attsHtml = _buildAttsHtmlFor(em.uid, data);
// Format date nicely (compact): "Mar 21, 2026 14:32"
let dateDisplay = data.date || '';
try {
if (data.date) {
const d = new Date(data.date);
if (!isNaN(d.getTime())) {
dateDisplay = d.toLocaleString([], {
month: 'short', day: 'numeric', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
}
} catch (_) {}
// Build recipient chip group from a comma-separated address list
const buildRecipients = (str) => {
if (!str) return '';
const addrs = _splitRecipientList(str);
if (addrs.length === 0) return '';
return addrs.map(a => {
const name = _extractName(a);
return _recipientChipHtml(a, name);
}).join('');
};
// Build the From chip too — single chip with name, click reveals address
const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip');
reader.innerHTML = `
<div class="email-reader-header">
<div class="email-reader-meta">
<div class="email-reader-meta-row email-reader-meta-from">
<strong>From:</strong>
<span class="recipient-chips">${fromChip}${_recipientMetaToggleHtml(data)}</span>
</div>
${(data.to || data.cc) ? `<div class="email-reader-meta-details" hidden>
${data.to ? `<div class="email-reader-meta-row"><strong>To:</strong><span class="recipient-chips">${buildRecipients(data.to)}</span></div>` : ''}
${data.cc ? `<div class="email-reader-meta-row"><strong>Cc:</strong><span class="recipient-chips">${buildRecipients(data.cc)}</span></div>` : ''}
</div>` : ''}
<div class="email-reader-actions-inline">
<button class="memory-toolbar-btn reader-icon-btn" data-act="ai-reply" title="${data.cached_ai_reply ? 'AI Reply (cached draft ready)' : 'AI Reply (suggest a draft)'}">${_aiReplyIcon(data)}<span class="reader-btn-label">AI reply</span></button>
<button class="memory-toolbar-btn reader-icon-btn" data-act="reply" title="Reply"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg><span class="reader-btn-label">Reply</span></button>
${_hasMultipleRecipients(data) ? `<button class="memory-toolbar-btn reader-icon-btn" data-act="reply-all" title="Reply All"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 17 2 12 7 7"/><polyline points="12 17 7 12 12 7"/><path d="M22 18v-2a4 4 0 0 0-4-4H7"/></svg><span class="reader-btn-label">Reply all</span></button>` : ''}
<button class="memory-toolbar-btn reader-icon-btn" data-act="forward" title="Forward"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 17 20 12 15 7"/><path d="M4 18v-2a4 4 0 0 1 4-4h12"/></svg><span class="reader-btn-label">Forward</span></button>
<div class="email-reader-more-wrap" style="position:relative">
<button class="memory-toolbar-btn reader-icon-btn" data-act="more" title="More actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg><span class="reader-btn-label">More</span></button>
</div>
</div>
</div>
</div>
${attsHtml}
<div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_safeRenderEmailBody(data)}</div>
`;
_markEmailReaderActive(reader);
reader.classList.remove('email-card-reader-loading');
reader.style.minHeight = '';
_wireEmailAttachmentWrap(reader, folderAtStart);
_wireEmailInlineImages(reader);
_loadDeferredAttachmentsIntoReader(reader, em.uid, folderAtStart, 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="more"]')?.addEventListener('click', (ev) => {
ev.stopPropagation();
_showReaderMoreMenu(em, card, reader, ev.currentTarget, data);
});
reader.querySelector('[data-act="summarize"]')?.addEventListener('click', async (ev) => {
ev.stopPropagation();
await _summarizeEmail(reader, data, ev.currentTarget);
});
_wireMetaToggle(reader);
_wireReaderActionOverflow(reader);
// Refresh the title-row prev/next arrows for this newly-expanded card.
_syncCardNavArrows(card);
// Horizontal swipe on the reader switches to prev/next email — but
// only when the underlying content can't scroll further in the swipe
// direction. If the email body is wider than the viewport (HTML emails
// with tables, embedded images), normal horizontal scroll wins; nav
// only fires once the user has reached an edge.
{
let _sx = 0, _sy = 0, _swiping = false, _intent = null;
let _scrollEl = null;
let _startScrollLeft = 0;
const SWIPE_THRESHOLD = 60;
const VERT_ABORT = 14;
const findHScroller = (el) => {
while (el && el !== reader) {
if (el.scrollWidth - el.clientWidth > 2) return el;
el = el.parentElement;
}
return null;
};
reader.addEventListener('touchstart', (ev) => {
if (ev.touches.length !== 1) { _swiping = false; return; }
if (ev.target.closest('button, a, .recipient-chip, .email-attachment-chip, .email-reader-more-wrap')) { _swiping = false; return; }
_sx = ev.touches[0].clientX;
_sy = ev.touches[0].clientY;
_scrollEl = findHScroller(ev.target);
_startScrollLeft = _scrollEl ? _scrollEl.scrollLeft : 0;
_swiping = true;
_intent = null;
}, { passive: true });
reader.addEventListener('touchmove', (ev) => {
if (!_swiping) return;
const dx = ev.touches[0].clientX - _sx;
const dy = ev.touches[0].clientY - _sy;
if (!_intent) {
if (Math.abs(dy) > VERT_ABORT && Math.abs(dy) > Math.abs(dx)) {
_intent = 'scroll';
_swiping = false;
return;
}
if (Math.abs(dx) > 12) _intent = 'swipe';
}
}, { passive: true });
reader.addEventListener('touchend', (ev) => {
if (!_swiping) return;
_swiping = false;
const t = (ev.changedTouches && ev.changedTouches[0]) || null;
if (!t || _intent !== 'swipe') return;
const dx = t.clientX - _sx;
const dy = t.clientY - _sy;
if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dy) > Math.abs(dx)) return;
// If a horizontally-scrollable element captured the swipe, let it
// scroll instead of changing email — UNLESS the user was already
// at the edge (scrollLeft can't move further in that direction).
if (_scrollEl) {
const max = _scrollEl.scrollWidth - _scrollEl.clientWidth;
const atLeftEdge = _scrollEl.scrollLeft <= 2;
const atRightEdge = _scrollEl.scrollLeft >= max - 2;
// Swiping LEFT (dx<0) reveals content to the right → if not at
// right edge, that's a scroll, not a nav.
if (dx < 0 && !atRightEdge) return;
// Swiping RIGHT (dx>0) reveals content to the left → if not at
// left edge, that's a scroll, not a nav.
if (dx > 0 && !atLeftEdge) return;
// If the browser already scrolled during this gesture, treat as
// scroll regardless (the user clearly wanted to pan).
if (_scrollEl.scrollLeft !== _startScrollLeft) return;
}
const dir = dx < 0 ? 1 : -1;
const navBtn = card.querySelector(`.email-card-nav-btn[data-nav-dir="${dir}"]`);
if (navBtn && !navBtn.disabled) navBtn.click();
}, { passive: true });
}
// If the email has a pre-cached summary, show it immediately. Fold
// state is persisted via _summaryCollapsedPref inside the renderer.
if (data.cached_summary) {
const sumBtn = reader.querySelector('[data-act="summarize"]');
_showCachedSummary(reader, data.cached_summary, sumBtn);
}
_wireRecipientChips(reader);
// Always stop bubbling so the card's click doesn't fire while reading.
reader.addEventListener('click', (ev) => { ev.stopPropagation(); });
} catch (e) {
if (!authoritativeReadSucceeded) restoreUnreadState();
if (isCurrentOpen()) {
showFailedReader(e?.message ? `Failed to load email: ${e.message}` : 'Failed to load email');
}
}
}
/**
* Wrap a probable signature block in a collapsed <details> so it stops
* eating the whole reader. We try, in priority order:
* 1. Mail-client signature wrappers — Gmail's `gmail_signature` div is
* explicit, no guessing required. Same for Apple Mail's data-smartmail.
* 2. The standard "-- " RFC 3676 sig delimiter.
* 3. A common closing phrase ("Best regards", "Cheers", etc.) on its own
* line — fuzzier, but catches sigs without the dash marker.
* 4. "Sent from my iPhone/Android" / "Get Outlook for ..." mobile-client
* boilerplate.
* Anything matched gets wrapped from the marker through end-of-body.
*/
/**
* Render the email body with sig/quote folds. If the backend has cached
* LLM-detected boundary offsets (data.boundaries), use those for an exact
* fold based on plain-text positions. Otherwise fall back to the regex
* detectors. The plain-body branch is always preferred when boundaries
* exist because the offsets are computed against plain text.
*/
// Global escape hatch — when the server's thread parser misfires (it
// occasionally splits a single reply into two bogus "turns" by treating a
// signature/disclaimer as its own message), the user can flip this off to
// fall back to plain rendering. Survives reloads.
const _BUBBLES_DISABLED_KEY = 'odysseus.email.bubblesDisabled';
// Threaded chat-bubble email view is DISABLED for now — too buggy to
// ship. Force plain-text rendering everywhere by always returning true.
// Re-enable by restoring the localStorage-backed body + the toggle
// menu item in the reader's More menu.
function _bubblesDisabled() {
return true;
}
function _setBubblesDisabled(v) {
try { localStorage.setItem(_BUBBLES_DISABLED_KEY, v ? '1' : '0'); } catch {}
}
function _renderEmailBody(data) {
const plain = (typeof data?.body === 'string' && data.body.length) ? data.body : '';
const folder = String(data?.folder || '').toLowerCase();
const isSentFolder = folder.includes('sent');
const fromAddr = String(data?.from_address || '').toLowerCase().trim();
const isMine = !!fromAddr && _meEmailAddrs().has(fromAddr);
// Messages authored by the user (Sent folder or self-sent copies in INBOX)
// are current authored text. Do not let cached boundaries or HTML
// blockquote parsing hide the whole thing behind "Earlier reply".
if ((isSentFolder || isMine) && plain) {
const plainTurns = _renderPlaintextThread(plain);
if (plainTurns && !/^\s*<details\b/i.test(plainTurns.trim())) {
return _foldSignature(plainTurns, null);
}
return _foldSignature(_escLinkify(plain).replace(/\n/g, '<br>'), null);
}
// Prefer the normalized plain-text thread when the message contains
// explicit reply markers. HTML mail from Outlook/Gmail commonly wraps the
// same quoted message in several nested blockquotes; feeding that markup
// to the HTML walker creates phantom chains such as "Earlier reply >
// Earlier reply > sender". The plain representation has the actual quote
// levels and gives us one stable fold per real quoted message.
const hasPlainThreadMarkers = plain && (
/^\s*>/m.test(plain)
|| /^\s*-{5,}\s*(?:Previous message|Original message)\s*-{5,}\s*$/im.test(plain)
|| /^\s*On\s.+?\s(?:wrote|skrev|schrieb|écrit|escribió)\s*:\s*$/im.test(plain)
);
if (hasPlainThreadMarkers) {
const plainThread = _renderPlaintextThread(plain);
if (plainThread) return _foldSignature(plainThread, data && data.sender_signature || null);
}
// Prefer the server-cached thread parse — that's the richest structure
// and the one the chat-bubble layout is built around. Skip when the user
// has manually disabled bubble rendering.
if (!_bubblesDisabled() && Array.isArray(data && data.thread_turns) && data.thread_turns.length) {
return _foldSignature(
_renderTurnsAsBubbles(data.thread_turns, data),
data && data.sender_signature || null,
);
}
const b = data && data.boundaries;
// Use cached boundaries when present AND we have plain-text body to slice
if (b && plain && (b.sig_start >= 0 || b.quote_start >= 0)) {
// Pick the EARLIER of the two as the cut for "everything below this is
// foldable", but render sig and quote with their own labels.
let sig = (typeof b.sig_start === 'number' && b.sig_start >= 0) ? b.sig_start : -1;
let quote = (typeof b.quote_start === 'number' && b.quote_start >= 0) ? b.quote_start : -1;
// Clamp
if (sig >= plain.length) sig = -1;
if (quote >= plain.length) quote = -1;
let head = plain;
let sigSection = '';
let quoteSection = '';
if (sig >= 0 && quote >= 0) {
const earlier = Math.min(sig, quote);
head = plain.slice(0, earlier);
if (sig < quote) {
sigSection = plain.slice(sig, quote);
quoteSection = plain.slice(quote);
} else {
quoteSection = plain.slice(quote, sig);
sigSection = plain.slice(sig);
}
} else if (sig >= 0) {
head = plain.slice(0, sig);
sigSection = plain.slice(sig);
} else {
head = plain.slice(0, quote);
quoteSection = plain.slice(quote);
}
const fmt = (s) => _escLinkify(s).replace(/\n/g, '<br>');
let out = fmt(head);
if (quoteSection) {
out += '<details class="email-quote-fold">'
+ _foldSummary('Earlier thread', _QUOTE_ICON, _extractQuoteMeta(quoteSection))
+ fmt(quoteSection) + '</details>';
}
if (sigSection) {
const sigHtml = fmt(sigSection);
if (_isBloatedSig(sigHtml)) {
out += '<details class="email-sig-fold">' + _foldSummary('Signature', _SIG_ICON)
+ sigHtml + '</details>';
} else {
// Short closing — leave inline; folding would just add chrome.
out += sigHtml;
}
}
return out;
}
// Fallback: client-side parse (HTML or plaintext).
const hintSig = (data && data.sender_signature) || null;
const isHtml = !!data.body_html;
let rendered;
if (isHtml) {
rendered = _sanitizeHtml(data.body_html);
} else {
const plainTurns = _renderPlaintextThread(data.body || '');
if (plainTurns) return _foldSignature(plainTurns, hintSig);
rendered = _escLinkify(data.body || '').replace(/\n/g, '<br>');
}
const threaded = _renderThreadStructure(rendered);
if (threaded) return _prepareEmailInlineImages(_foldSignature(threaded, hintSig));
return _prepareEmailInlineImages(_foldSignature(_foldQuotedReplies(rendered), hintSig));
}
function _safeRenderEmailBody(data) {
try {
return _prepareEmailInlineImages(_renderEmailBody(data));
} catch (e) {
console.error('email body render failed:', e);
const plain = (typeof data?.body === 'string') ? data.body : '';
if (plain) return _escLinkify(plain).replace(/\n/g, '<br>');
if (data?.body_html) return _prepareEmailInlineImages(_sanitizeHtml(data.body_html));
return '<span style="opacity:.65">No body</span>';
}
}
function _prepareEmailInlineImages(html) {
const raw = String(html || '');
if (!/<img[\s>]/i.test(raw)) return raw;
const doc = new DOMParser().parseFromString(`<div>${raw}</div>`, 'text/html');
const root = doc.body.firstElementChild;
if (!root) return raw;
root.querySelectorAll('img').forEach((img, idx) => {
const src = (img.getAttribute('src') || '').trim();
const alt = (img.getAttribute('alt') || img.getAttribute('title') || '').trim();
const isHttp = /^https?:\/\//i.test(src);
const isCid = /^cid:/i.test(src);
const cid = isCid ? src.replace(/^cid:/i, '').replace(/^<|>$/g, '').trim() : '';
const label = alt || (isCid ? 'Inline image' : 'Remote image');
const ph = doc.createElement('span');
ph.className = 'email-inline-image-placeholder';
ph.setAttribute('role', 'group');
ph.setAttribute('aria-label', label);
if (src) ph.dataset.emailImgSrc = src;
if (cid) ph.dataset.emailImgCid = cid;
ph.innerHTML = `
<span class="email-inline-image-skeleton">
<span class="email-inline-image-icon">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>
</span>
</span>
<span class="email-inline-image-info">
<span class="email-inline-image-title">${_esc(label)}</span>
<span class="email-inline-image-sub">${isHttp ? 'Remote image blocked' : isCid ? 'Inline image hidden' : 'Image unavailable'}</span>
</span>
<span class="email-inline-image-actions">
${isHttp ? `<a class="email-inline-image-btn email-inline-image-download-btn" href="${_esc(src)}" target="_blank" rel="noopener noreferrer" download title="Download" aria-label="Download"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></a>` : ''}
${isCid ? `<button type="button" class="email-inline-image-btn email-inline-image-download-btn" data-email-img-download="${idx}" title="Download" aria-label="Download"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></button>` : ''}
</span>
`;
img.replaceWith(ph);
});
return root.innerHTML;
}
function _wireEmailInlineImages(reader) {
if (!reader) return;
const placeholders = Array.from(reader.querySelectorAll('.email-inline-image-placeholder'));
const visiblePlaceholders = placeholders.filter(ph => (
ph.isConnected &&
(ph.dataset.emailImgSrc || ph.dataset.emailImgCid) &&
getComputedStyle(ph).display !== 'none'
));
const autoLoadInlineImages = state._libViewInlineImages !== false;
const manualPlaceholders = autoLoadInlineImages
? visiblePlaceholders.filter(ph => !ph.dataset.emailImgCid)
: visiblePlaceholders;
const existingBulkBar = reader.querySelector('.email-inline-image-load-all');
if (!manualPlaceholders.length) existingBulkBar?.remove();
const loadPlaceholder = (ph) => {
if (!ph || !ph.isConnected) return;
const src = (ph.dataset.emailImgSrc || '').trim();
const cid = (ph.dataset.emailImgCid || '').trim();
const ctxRoot = reader.dataset?.emailUid
? reader
: ph.closest('[data-email-uid]') || reader.closest?.('[data-email-uid]');
const folder = ctxRoot?.dataset?.emailFolder || reader.dataset?.emailFolder || state._libFolder || 'INBOX';
const account = ctxRoot?.dataset?.emailAccount || reader.dataset?.emailAccount || state._libAccountId || '';
const uid = ctxRoot?.dataset?.emailUid || reader.dataset?.emailUid || '';
const inlineUrl = () => {
if (!uid || !cid) return '';
const params = new URLSearchParams({ cid, folder });
if (account) params.set('account_id', account);
return `${API_BASE}/api/email/inline-image/${encodeURIComponent(uid)}?${params.toString()}`;
};
const isRemoteImage = /^https?:\/\//i.test(src);
const isCidImage = !!cid && !isRemoteImage;
const loadSrc = isRemoteImage ? src : inlineUrl();
if (!loadSrc || ph.classList.contains('is-loading')) return;
ph.classList.remove('is-error');
ph.classList.add('is-loading');
const sub = ph.querySelector('.email-inline-image-sub');
if (sub) sub.textContent = 'Loading image...';
const actions = ph.querySelector('.email-inline-image-actions');
if (actions) actions.style.display = 'none';
const img = document.createElement('img');
img.className = 'email-inline-image-loaded';
img.alt = ph.querySelector('.email-inline-image-title')?.textContent || 'Loaded email image';
img.loading = 'eager';
img.referrerPolicy = 'no-referrer';
let settled = false;
let objectUrl = '';
let frame = null;
let directFallbackUsed = false;
const ensureFrame = () => {
if (frame) return frame;
frame = document.createElement('span');
frame.className = 'email-inline-image-frame is-loading';
frame.appendChild(img);
if (ph.isConnected) ph.replaceWith(frame);
return frame;
};
const showLoaded = () => {
if (settled) return;
settled = true;
if (frame) frame.classList.remove('is-loading');
img.classList.add('is-visible');
};
const showError = (err) => {
if (settled) return;
settled = true;
if (frame?.isConnected) frame.replaceWith(ph);
ph.classList.remove('is-loading');
ph.classList.add('is-error');
if (sub) sub.textContent = err?.message ? `Image failed: ${err.message}` : 'Image failed to load';
if (actions) actions.style.display = '';
if (objectUrl) {
try { URL.revokeObjectURL(objectUrl); } catch {}
}
};
const loadDirect = () => {
directFallbackUsed = true;
ensureFrame();
img.src = loadSrc;
if (img.complete && img.naturalWidth > 0) showLoaded();
};
img.addEventListener('load', showLoaded, { once: true });
img.addEventListener('error', () => {
if (!directFallbackUsed && objectUrl && isCidImage) {
try { URL.revokeObjectURL(objectUrl); } catch {}
objectUrl = '';
loadDirect();
return;
}
showError();
}, { once: true });
if (isCidImage || isRemoteImage) {
loadDirect();
setTimeout(() => {
if (!settled && frame) {
frame.classList.add('is-loading');
}
}, 12000);
return;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 20000);
fetch(loadSrc, { credentials: 'same-origin', signal: controller.signal })
.then(async res => {
clearTimeout(timeoutId);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
if (!blob || !blob.size) throw new Error('Empty image');
const type = blob.type && blob.type.startsWith('image/') ? blob.type : 'image/png';
objectUrl = URL.createObjectURL(blob.type === type ? blob : new Blob([blob], { type }));
ensureFrame();
img.src = objectUrl;
if (img.complete && img.naturalWidth > 0) showLoaded();
if (!settled && typeof img.decode === 'function') {
img.decode().then(showLoaded).catch(() => {
if (img.complete && img.naturalWidth === 0) showError();
});
}
})
.catch(err => {
clearTimeout(timeoutId);
showError(err?.name === 'AbortError' ? new Error('request timed out') : err);
});
setTimeout(() => {
if (!settled && !frame) {
ph.classList.remove('is-loading');
if (sub) sub.textContent = 'Still loading...';
if (actions) actions.style.display = '';
}
}, 12000);
};
if (manualPlaceholders.length && !reader.querySelector('.email-inline-image-load-all')) {
const first = manualPlaceholders[0];
const bar = document.createElement('div');
bar.className = 'email-inline-image-load-all';
const label = autoLoadInlineImages
? `${manualPlaceholders.length} external image${manualPlaceholders.length === 1 ? '' : 's'} blocked`
: `${manualPlaceholders.length} hidden inline image${manualPlaceholders.length === 1 ? '' : 's'}`;
bar.innerHTML = `
<span>${label}</span>
<button type="button" data-email-img-dismiss-all>Dismiss</button>
<button type="button" data-email-img-load-all>Load all</button>
`;
bar.querySelector('[data-email-img-load-all]')?.addEventListener('click', ev => {
ev.preventDefault();
ev.stopPropagation();
bar.remove();
manualPlaceholders.forEach(loadPlaceholder);
});
bar.querySelector('[data-email-img-dismiss-all]')?.addEventListener('click', ev => {
ev.preventDefault();
ev.stopPropagation();
bar.remove();
manualPlaceholders.forEach(ph => ph.remove());
_disableInlineImages().catch(() => {});
});
first.parentNode?.insertBefore(bar, first);
}
placeholders.forEach(ph => {
if (ph.dataset.wired !== '1') {
ph.dataset.wired = '1';
const src = (ph.dataset.emailImgSrc || '').trim();
const cid = (ph.dataset.emailImgCid || '').trim();
const ctxRoot = reader.dataset?.emailUid
? reader
: ph.closest('[data-email-uid]') || reader.closest?.('[data-email-uid]');
const folder = ctxRoot?.dataset?.emailFolder || reader.dataset?.emailFolder || state._libFolder || 'INBOX';
const account = ctxRoot?.dataset?.emailAccount || reader.dataset?.emailAccount || state._libAccountId || '';
const uid = ctxRoot?.dataset?.emailUid || reader.dataset?.emailUid || '';
const inlineUrl = () => {
if (!uid || !cid) return '';
const params = new URLSearchParams({ cid, folder });
if (account) params.set('account_id', account);
return `${API_BASE}/api/email/inline-image/${encodeURIComponent(uid)}?${params.toString()}`;
};
ph.querySelector('[data-email-img-download]')?.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
const url = inlineUrl();
if (!url) return;
const a = document.createElement('a');
a.href = url;
a.download = 'inline-image';
document.body.appendChild(a);
a.click();
a.remove();
});
}
const cid = (ph.dataset.emailImgCid || '').trim();
if (autoLoadInlineImages && cid && ph.isConnected && ph.dataset.emailImgAutoRequested !== '1') {
ph.dataset.emailImgAutoRequested = '1';
loadPlaceholder(ph);
}
});
}
// ── Chat-bubble rendering for email threads ──
// Each parsed turn renders as a chat bubble. Bubbles for the active
// account's outgoing replies align right; everyone else aligns left.
// Order is reversed so the oldest message sits at the top of the
// conversation and the newest (the message currently being read) sits
// at the bottom — matches the mental model people have from chat.
function _meEmailAddrs() {
const set = new Set();
for (const a of (state._libAccounts || [])) {
if (a && a.from_address) set.add(String(a.from_address).toLowerCase().trim());
if (a && a.imap_user) set.add(String(a.imap_user).toLowerCase().trim());
}
return set;
}
// _parseTurnMeta / _formatBubbleDate / _formatRecipients / _senderColor /
// _initials live in ./emailLibrary/utils.js
function _renderTurnsAsBubbles(turns, data) {
if (!Array.isArray(turns) || !turns.length) return '';
const mineSet = _meEmailAddrs();
const lvl0Email = String(data && data.from_address || '').toLowerCase().trim();
const lvl0Mine = !!lvl0Email && mineSet.has(lvl0Email);
const lvl0Author = (data && (data.from_name || data.from_address)) || '';
const lvl0Date = _formatBubbleDate(data && data.date);
// Newest reply on top, older history below. Turns come ordered shallow→deep
// (level 0 = current reply, deeper levels = older quoted material) so we
// render in source order without reversing.
const ordered = turns.slice();
// Gather per-turn sender identity + frequency for the no-self case below.
const turnIdentity = ordered.map((t) => {
if (t.level === 0) {
return { email: lvl0Email, author: lvl0Author };
}
const p = _parseTurnMeta(t.meta || '');
return { email: p.email, author: p.author };
});
const anyMine = turnIdentity.some(x => x.email && mineSet.has(x.email));
// When the user isn't a participant in this thread (forwarded chains,
// historical archives, etc.), assign the two most frequent senders to
// opposite sides so the conversation still reads side-to-side. Third+
// parties fall back to hash mod 2.
const sideForKey = (() => {
if (anyMine) return null;
const freq = new Map();
const firstSeen = new Map();
turnIdentity.forEach((x, i) => {
const key = (x.email || x.author || '').toLowerCase();
if (!key) return;
freq.set(key, (freq.get(key) || 0) + 1);
if (!firstSeen.has(key)) firstSeen.set(key, i);
});
const sorted = [...freq.entries()]
.sort((a, b) => (b[1] - a[1]) || (firstSeen.get(a[0]) - firstSeen.get(b[0])));
const leftKey = sorted[0] && sorted[0][0];
const rightKey = sorted[1] && sorted[1][0];
return (key) => {
if (!key) return 'theirs';
if (key === leftKey) return 'theirs';
if (key === rightKey) return 'mine';
// Stable hash for 3rd+ parties.
let h = 0;
for (let i = 0; i < key.length; i++) h = ((h << 5) - h + key.charCodeAt(i)) | 0;
return (h & 1) ? 'mine' : 'theirs';
};
})();
const rows = ordered.map((t, i) => {
let isMine, author, date;
if (t.level === 0) {
isMine = lvl0Mine;
author = lvl0Author || 'Me';
date = lvl0Date;
} else {
const p = _parseTurnMeta(t.meta || '');
isMine = !!p.email && mineSet.has(p.email);
author = p.author || (t.meta || 'Earlier reply');
date = p.date;
}
// No-self fallback: route by per-sender side mapping.
if (sideForKey) {
const id = turnIdentity[i];
const key = (id.email || id.author || '').toLowerCase();
isMine = sideForKey(key) === 'mine';
}
const side = isMine ? 'mine' : 'theirs';
const initials = _initials(author);
const color = _senderColor(author || (t.level === 0 ? lvl0Email : ''));
const head =
`<div class="email-bubble-head">`
+ `<span class="email-bubble-author" style="color:${color}">${_esc(author)}</span>`
+ (date ? `<span class="email-bubble-date">${_esc(date)}</span>` : '')
+ `</div>`;
const avatar = `<div class="email-bubble-avatar" aria-hidden="true" style="background:${color}">${_esc(initials)}</div>`;
return (
`<div class="email-bubble-row email-bubble-${side}" style="--bubble-accent:${color}">`
+ (isMine ? '' : avatar)
+ `<div class="email-bubble">`
+ head
+ `<div class="email-bubble-body">${_sanitizeHtml(t.body_html || '')}</div>`
+ `</div>`
+ (isMine ? avatar : '')
+ `</div>`
);
});
return `<div class="email-bubbles">${rows.join('')}</div>`;
}
/**
* Render server-cached thread turns (list of {level, body_html, meta})
* into the same nested-card structure the client-side parser produces.
*/
function _renderTurnsFromServer(turns) {
if (!Array.isArray(turns) || !turns.length) return '';
let out = '';
const stack = []; // [{ level, html }]
const wrap = (t) =>
`<details class="email-thread-turn email-quote-fold">`
+ _foldSummary('Earlier reply', _QUOTE_ICON, t.meta || '')
+ `<div class="email-thread-turn-body">${t.html}</div>`
+ '</details>';
for (const t of turns) {
if (t.level === 0) {
while (stack.length) {
const top = stack.pop();
const w = wrap(top);
if (stack.length) stack[stack.length - 1].html += w; else out += w;
}
out += _sanitizeHtml(t.body_html || '');
} else {
while (stack.length && stack[stack.length - 1].level > t.level) {
const top = stack.pop();
const w = wrap(top);
if (stack.length) stack[stack.length - 1].html += w; else out += w;
}
if (!stack.length || stack[stack.length - 1].level < t.level) {
stack.push({ level: t.level, meta: t.meta, html: _sanitizeHtml(t.body_html || '') });
} else {
stack[stack.length - 1].html += _sanitizeHtml(t.body_html || '');
if (t.meta && !stack[stack.length - 1].meta) {
stack[stack.length - 1].meta = t.meta;
}
}
}
}
while (stack.length) {
const top = stack.pop();
const w = wrap(top);
if (stack.length) stack[stack.length - 1].html += w; else out += w;
}
// Mark the bottom-most fold for rounded corners.
const lastIdx = out.lastIndexOf('<details class="email-thread-turn email-quote-fold"');
if (lastIdx >= 0) {
out = out.slice(0, lastIdx)
+ out.slice(lastIdx).replace(
'email-thread-turn email-quote-fold"',
'email-thread-turn email-quote-fold last-fold"'
);
}
return out;
}
/**
* Parse an email body's reply chain into a stack of turn-cards.
* Each turn = { author, date, bodyHtml, nested[] } where the body is
* everything UP TO the next quote boundary, and `nested` is the sub-thread
* inside (recursively parsed). Returns null if the email has no quoted
* thread to parse (single message, no folds needed).
*/
// ── Talon-inspired multilingual quote-detection patterns ──
// Sources:
// github.com/mailgun/talon (HTML/text quote detection)
// github.com/crisp-oss/email-reply-parser (locale list)
//
// _TALON_* / _SIG_BLOAT_MIN_CHARS live in ./emailLibrary/utils.js
// _SIG_ICON / _QUOTE_ICON live in ./emailLibrary/signatureFold.js
function _renderThreadStructure(html) {
if (!html || typeof html !== 'string' || html.length > 200000) return null;
let doc;
try { doc = new DOMParser().parseFromString(`<div id="__t">${html}</div>`, 'text/html'); }
catch { return null; }
const root = doc.getElementById('__t');
if (!root) return null;
// Find top-level blockquotes (not nested inside another blockquote).
const tops = Array.from(root.querySelectorAll('blockquote')).filter(b =>
!b.parentElement.closest('blockquote')
);
if (!tops.length) return null;
// Build the current-message body: everything in root up to the first
// top-level blockquote, minus the "On <date>, <author> wrote:" attribution
// line that introduces it.
const head = doc.createElement('div');
let cursor = root.firstChild;
while (cursor && cursor !== tops[0]) {
const next = cursor.nextSibling;
head.appendChild(cursor);
cursor = next;
}
// Strip trailing "On <date>, <name> wrote:" / Outlook-style attribution
// from `head` since the same info will appear in the turn header.
let attribution = _harvestAttribution(head);
// Recursively parse each top-level blockquote into a turn (and its nested chain).
const turnsHtml = [];
for (let i = 0; i < tops.length; i++) {
const bq = tops[i];
// The blockquote may have an Outlook-style "From: / Sent: / Subject:"
// header inside as the first text. Extract that as the turn meta.
const meta = _extractTurnMetaFromBlockquote(bq) || attribution || _extractQuoteMeta(bq.innerHTML);
const innerHtml = bq.innerHTML;
// Heuristic: if a blockquote has no detectable attribution (no "From:",
// no "On <date>... wrote:") AND its content matches signature-style
// patterns (corporate disclaimer, "registered in", legal notices, just
// a name + title), treat it as a Signature fold instead of an Earlier
// Reply. This stops mail clients that wrap signatures in <blockquote>
// from making the signature appear as a phantom prior email.
if (!meta && _looksLikeSignature(innerHtml)) {
turnsHtml.push(
'<details class="email-sig-fold">'
+ _foldSummary('Signature', _SIG_ICON)
+ `<div class="email-sig-body">${innerHtml}</div>`
+ '</details>'
);
attribution = null;
continue;
}
// Recursively render the inside of this blockquote (which may contain
// its own nested blockquotes representing earlier replies).
const nested = _renderThreadStructure(innerHtml);
const bodyHtml = nested || innerHtml;
const isLast = i === tops.length - 1;
turnsHtml.push(
`<details class="email-thread-turn email-quote-fold${isLast ? ' last-fold' : ''}">`
+ _foldSummary('Earlier reply', _QUOTE_ICON, meta || '')
+ `<div class="email-thread-turn-body">${bodyHtml}</div>`
+ '</details>'
);
// Only the first turn uses the harvested attribution; deeper turns
// get their own from inside the blockquote.
attribution = null;
}
return head.innerHTML + turnsHtml.join('');
}
// Looks like a signature / corporate disclaimer rather than a quoted email.
// Used to demote attribution-less blockquotes that some senders wrap their
// sig+disclaimer in (Outlook, EY, big firms) from "Earlier reply" to a
// proper Signature fold. Conservative — only fires when there's no quoted
// reply markers AND it matches strong corporate-noise phrases.
// _looksLikeSignature / _harvestAttribution / _extractTurnMetaFromBlockquote
// live in ./emailLibrary/signatureFold.js
/**
* Wrap any quoted reply chain in a collapsed <details> so deep email threads
* don't dominate the reader. Detects:
* - <blockquote> tags (Gmail / native quoted replies)
* - Outlook-style "From: ... Sent: ... To: ... Subject: ..." headers
* Each gets its own "Earlier thread" toggle.
*/
/**
* Parse a plaintext email body into stacked turn-cards by walking
* `> ` quote-prefix levels and Outlook-style "On X wrote:" / Original-Message
* boundaries. Returns rendered HTML, or null when there's no quoted content
* (caller falls back to flat rendering).
*
* Mirrors talon's `extract_from_plain` and email-reply-parser fragments:
* 1. Lines starting with one or more `>` chars are quoted (level = count of >).
* 2. Increasing the level opens a deeper turn (nested reply).
* 3. `-----Original Message-----` and `On <date>, <name> wrote:` start a
* new turn even without `>`.
* 4. The leading non-quoted segment is the current message.
*/
function _renderPlaintextThread(text) {
if (!text || typeof text !== 'string' || text.length > 200000) return null;
const lines = text.split(/\r?\n/);
const levels = lines.map(l => {
const m = l.match(/^((?:>\s?)+)/);
return m ? (m[1].match(/>/g) || []).length : 0;
});
const hasQuotes = levels.some(l => l > 0);
const attribLineRe = new RegExp(`(?:^|\\n)\\s*On\\s.+?\\s${_TALON_WROTE}\\s*:\\s*$`, 'im');
const hasAttrib = attribLineRe.test(text) || _TALON_ORIG_RE.test(text);
if (!hasQuotes && !hasAttrib) return null;
const turns = [];
let buf = [];
let curLevel = 0;
let pendingMeta = null;
const flush = () => {
if (!buf.length) return;
const t = buf.join('\n').trimEnd();
if (t || curLevel > 0) turns.push({ level: curLevel, text: t, meta: pendingMeta });
buf = [];
pendingMeta = null;
};
for (let i = 0; i < lines.length; i++) {
const lvl = levels[i];
const raw = lines[i];
const stripped = lvl > 0 ? raw.replace(/^(?:>\s?)+/, '') : raw;
const isSeparatorLine = lvl === 0 && /^-{5,}\s*Previous message\s*-{5,}$/i.test(raw.trim());
const isAttribLine = lvl === 0
&& (new RegExp(`^\\s*On\\s.+?\\s${_TALON_WROTE}\\s*:\\s*$`, 'i').test(raw)
|| _TALON_ORIG_RE.test('\n' + raw));
if (isSeparatorLine || isAttribLine) {
flush();
pendingMeta = isSeparatorLine ? null : (_extractQuoteMeta(raw) || raw.trim());
curLevel = 1;
continue;
}
if (lvl !== curLevel) {
flush();
curLevel = lvl;
}
buf.push(stripped);
}
flush();
if (!turns.length || (turns.length === 1 && turns[0].level === 0)) return null;
const fmt = s => _escLinkify(s).replace(/\n/g, '<br>');
let out = '';
const stack = [];
const wrapTurn = (t) =>
`<details class="email-thread-turn email-quote-fold">`
+ _foldSummary('Earlier reply', _QUOTE_ICON, t.meta || '')
+ `<div class="email-thread-turn-body">${t.html}</div>`
+ '</details>';
for (const t of turns) {
if (t.level === 0) {
while (stack.length) {
const top = stack.pop();
const wrapped = wrapTurn(top);
if (stack.length) stack[stack.length - 1].html += wrapped; else out += wrapped;
}
out += fmt(t.text);
} else {
while (stack.length && stack[stack.length - 1].level > t.level) {
const top = stack.pop();
const wrapped = wrapTurn(top);
if (stack.length) stack[stack.length - 1].html += wrapped; else out += wrapped;
}
if (!stack.length || stack[stack.length - 1].level < t.level) {
stack.push({ level: t.level, meta: t.meta, html: fmt(t.text) });
} else {
stack[stack.length - 1].html += '<br>' + fmt(t.text);
if (t.meta && !stack[stack.length - 1].meta) stack[stack.length - 1].meta = t.meta;
}
}
}
while (stack.length) {
const top = stack.pop();
const wrapped = wrapTurn(top);
if (stack.length) stack[stack.length - 1].html += wrapped; else out += wrapped;
}
const lastIdx = out.lastIndexOf('<details class="email-thread-turn email-quote-fold"');
if (lastIdx >= 0) {
out = out.slice(0, lastIdx)
+ out.slice(lastIdx).replace(
'email-thread-turn email-quote-fold"',
'email-thread-turn email-quote-fold last-fold"'
);
}
return out;
}
// _foldSummary / _extractQuoteMeta / _SIG_ICON / _QUOTE_ICON
// live in ./emailLibrary/signatureFold.js
function _foldQuotedReplies(html) {
if (!html || typeof html !== 'string') return html;
if (html.length > 200000) return html;
const before = html;
// Use DOMParser for proper nested-blockquote handling. Regex against HTML
// mishandles nesting and leaves orphan close tags that the browser
// re-balances, producing two visually inconsistent fold styles.
try {
const doc = new DOMParser().parseFromString(`<div id="__r">${html}</div>`, 'text/html');
const root = doc.getElementById('__r');
if (root) {
// Only fold TOP-LEVEL blockquotes (children of the root that are not
// already inside another blockquote). The inner blockquote chain stays
// intact inside the fold and renders with the existing
// .email-quote-fold blockquote styles, so everything matches.
const tops = Array.from(root.querySelectorAll('blockquote')).filter(b =>
!b.parentElement.closest('blockquote')
);
if (tops.length) {
for (const bq of tops) {
const det = doc.createElement('details');
det.className = 'email-quote-fold';
// Build the summary as raw HTML — easier than building DOM by hand.
const summary = _foldSummary('Earlier thread', _QUOTE_ICON, _extractQuoteMeta(bq.innerHTML));
det.innerHTML = summary;
bq.parentNode.insertBefore(det, bq);
det.appendChild(bq); // move the original blockquote (and any nested ones) into the details
}
// Tag only the last fold so CSS can give it rounded bottom corners.
const allFolds = root.querySelectorAll('.email-quote-fold');
if (allFolds.length) allFolds[allFolds.length - 1].classList.add('last-fold');
return root.innerHTML;
}
}
} catch (e) {
// Fall through to the legacy regex path below if DOMParser fails
}
// If DOM-pass already wrapped something, we returned above. Otherwise no
// blockquotes were found — try the Outlook-header heuristic.
if (html !== before) return html;
// Outlook-style quoted-reply header — multilingual. Fold from the first
// "From: ... Sent: ... Subject: ..." block through end-of-body so all
// prior thread levels collapse together.
const FROM = '(?:From|Från|Von|De|De\\s|Da|От|Od|Van)';
const SENT = '(?:Sent|Skickat|Gesendet|Envoyé|Inviato|Enviado|Verzonden|Отправлено|Wysłane)';
const SUBJ = '(?:Subject|Ämne|Betreff|Objet|Oggetto|Asunto|Onderwerp|Тема|Temat)';
const outlookRe = new RegExp(
`(<br\\s*/?>|</p>|</div>|<p[^>]*>|<div[^>]*>|\\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]
+ '<details class="email-quote-fold last-fold">'
+ _foldSummary('Earlier thread', _QUOTE_ICON, _extractQuoteMeta(m[2]))
+ m[2] + '</details>';
}
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 =
'<div class="email-summary-header email-summary-toggle" role="button" tabindex="0">'
+ '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>'
+ '<span>Summary</span>'
+ '<svg class="email-summary-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-left:auto;transition:transform .15s ease;"><polyline points="6 9 12 15 18 9"/></svg>'
+ '</div>'
+ '<div class="email-summary-content"></div>';
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
? `<span class="email-attachment-calendar-open" role="button" tabindex="0" aria-label="Add to calendar" title="Add to calendar" data-open-uid="${_esc(chipUid)}" data-open-index="${a.index}" data-open-name="${_esc(a.filename)}" data-open-folder="${_esc(chipFolder)}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="17" rx="2"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="m8 15 2 2 5-5"/></svg></span>`
: openable
? `<span class="email-attachment-open" role="button" tabindex="0" aria-label="Open in document editor" title="Open in document editor" data-open-uid="${_esc(chipUid)}" data-open-index="${a.index}" data-open-name="${_esc(a.filename)}" data-open-folder="${_esc(chipFolder)}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><line x1="8" y1="9" x2="10" y2="9"/></svg></span>`
: '';
const downloadBtn = `<span class="email-attachment-download" role="button" tabindex="0" aria-label="Download attachment" title="Download attachment"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span>`;
return `<button type="button" class="email-attachment-chip${extraClass}" data-att-uid="${_esc(chipUid)}" data-att-index="${a.index}" data-att-name="${_esc(a.filename)}" data-att-folder="${_esc(chipFolder)}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg><span>${_esc(a.filename)}</span><span class="att-size">${Math.round((a.size||0)/1024)} KB</span>${openBtn}${downloadBtn}</button>`;
};
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
? '<div class="email-reader-atts">' + chips + '</div>'
: '';
const relatedSection = related.length
? '<div class="email-reader-atts-hidden-note">From earlier in this thread</div><div class="email-reader-atts email-reader-atts-related">' + relatedChips + '</div>'
: '';
const hiddenSection = hidden.length && state._libViewInlineImages !== false
? '<div class="email-reader-atts-hidden-note">Filtered inline images / signature files</div><div class="email-reader-atts email-reader-atts-hidden">' + hiddenChips + '</div>'
: '';
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
? `<button type="button" class="email-attachments-download-all" title="Download all attachments" data-att-uid="${_esc(uid)}" data-att-folder="${_esc(data.folder || state._libFolder || 'INBOX')}" data-att-count="${visible.length}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg><span>All</span></button>`
: '';
return (
`<div class="email-reader-atts-wrap${startCollapsed ? ' collapsed' : ''}">`
+ '<div class="email-reader-atts-header email-summary-toggle" role="button" tabindex="0">'
+ '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>'
+ `<span>${label}</span>`
+ downloadAllBtn
+ '<svg class="email-summary-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-left:auto;transition:transform .15s ease;"><polyline points="6 9 12 15 18 9"/></svg>'
+ '</div>'
+ visibleSection
+ relatedSection
+ hiddenSection
+ '</div>'
);
}
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 <body> 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 = `
<div class="modal-content doclib-modal-content email-reader-tab-content" style="background:var(--bg);width:min(720px, 92vw);display:flex;flex-direction:column;">
<div class="modal-header">
<h4 style="display:flex;align-items:center;gap:6px;min-width:0;flex:1;">
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-left:8px;">${_esc(em.subject || '(no subject)')}</span>
</h4>
<button class="minimize-btn" type="button" title="Minimize">_</button>
<button class="close-btn" type="button" title="Close">&#x2716;</button>
</div>
<div class="modal-body email-reader-tab-body" style="display:flex;flex-direction:column;overflow:hidden;flex:1;min-height:0;padding:0;">
<div class="email-card-reader email-card-expanded" style="flex:1;min-height:0;display:flex;flex-direction:column;">
<div class="email-reader-tab-loading" style="padding:24px;display:flex;justify-content:center;"></div>
</div>
</div>
</div>
`;
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 = `
<div class="email-reader-header">
<div class="email-reader-meta">
<div class="email-reader-meta-row email-reader-meta-from">
<strong>From:</strong>
<span class="recipient-chips">${fromChip}${_recipientMetaToggleHtml(data)}</span>
</div>
${(data.to || data.cc) ? `<div class="email-reader-meta-details" hidden>
${data.to ? `<div class="email-reader-meta-row"><strong>To:</strong><span class="recipient-chips">${buildChips(data.to)}</span></div>` : ''}
${data.cc ? `<div class="email-reader-meta-row"><strong>Cc:</strong><span class="recipient-chips">${buildChips(data.cc)}</span></div>` : ''}
</div>` : ''}
<div class="email-reader-actions-inline">
<button class="memory-toolbar-btn reader-icon-btn" data-act="ai-reply" title="${data.cached_ai_reply ? 'AI Reply (cached draft ready)' : 'AI Reply'}">${_aiReplyIcon(data)}<span class="reader-btn-label">AI reply</span></button>
<button class="memory-toolbar-btn reader-icon-btn" data-act="reply" title="Reply"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg><span class="reader-btn-label">Reply</span></button>
${_hasMultipleRecipients(data) ? `<button class="memory-toolbar-btn reader-icon-btn" data-act="reply-all" title="Reply All"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 17 2 12 7 7"/><polyline points="12 17 7 12 12 7"/><path d="M22 18v-2a4 4 0 0 0-4-4H7"/></svg><span class="reader-btn-label">Reply all</span></button>` : ''}
<button class="memory-toolbar-btn reader-icon-btn" data-act="forward" title="Forward"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 17 20 12 15 7"/><path d="M4 18v-2a4 4 0 0 1 4-4h12"/></svg><span class="reader-btn-label">Forward</span></button>
<div class="email-reader-more-wrap" style="position:relative">
<button class="memory-toolbar-btn reader-icon-btn" data-act="more" title="More actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg><span class="reader-btn-label">More</span></button>
</div>
</div>
</div>
</div>
${attsHtml}
<div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_safeRenderEmailBody(data)}</div>
`;
_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 = `
<div class="modal-content email-window-content" style="width:min(640px, 92vw);display:flex;flex-direction:column;background:var(--bg);">
<div class="modal-header">
<h4 style="display:flex;align-items:center;gap:6px;min-width:0;flex:1;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
<span class="email-window-subject" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${_esc(em.subject || '(no subject)')}</span>
</h4>
<button class="close-btn" type="button" title="Close">&#x2716;</button>
</div>
<div class="modal-body email-window-body" style="overflow:auto;padding:14px 16px;flex:1;min-height:0;">
<div class="email-window-loading" style="display:flex;justify-content:center;padding:24px;"></div>
</div>
</div>
`;
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 = `<div style="color:var(--red,#e55);padding:16px;">${_esc(data.error)}</div>`;
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 = `
<div class="email-reader-header">
<div class="email-reader-meta">
<div class="email-reader-meta-row email-reader-meta-from">
<strong>From:</strong>
<span class="recipient-chips">${fromChip}${_recipientMetaToggleHtml(data)}</span>
</div>
${(data.to || data.cc) ? `<div class="email-reader-meta-details" hidden>
${data.to ? `<div class="email-reader-meta-row"><strong>To:</strong><span class="recipient-chips">${_chipsFor(data.to)}</span></div>` : ''}
${data.cc ? `<div class="email-reader-meta-row"><strong>Cc:</strong><span class="recipient-chips">${_chipsFor(data.cc)}</span></div>` : ''}
</div>` : ''}
<div class="email-reader-actions-inline">
<button class="memory-toolbar-btn reader-icon-btn" data-act="ai-reply" title="${data.cached_ai_reply ? 'AI Reply (cached draft ready)' : 'AI Reply (suggest a draft)'}">${_aiReplyIcon(data)}<span class="reader-btn-label">AI reply</span></button>
<button class="memory-toolbar-btn reader-icon-btn" data-act="reply" title="Reply"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg><span class="reader-btn-label">Reply</span></button>
${_hasMultipleRecipients(data) ? `<button class="memory-toolbar-btn reader-icon-btn" data-act="reply-all" title="Reply All"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 17 2 12 7 7"/><polyline points="12 17 7 12 12 7"/><path d="M22 18v-2a4 4 0 0 0-4-4H7"/></svg><span class="reader-btn-label">Reply all</span></button>` : ''}
<button class="memory-toolbar-btn reader-icon-btn" data-act="forward" title="Forward"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 17 20 12 15 7"/><path d="M4 18v-2a4 4 0 0 1 4-4h12"/></svg><span class="reader-btn-label">Forward</span></button>
<div class="email-reader-more-wrap" style="position:relative">
<button class="memory-toolbar-btn reader-icon-btn" data-act="more" title="More actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg><span class="reader-btn-label">More</span></button>
</div>
</div>
</div>
</div>
${attsHtml}
<div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_safeRenderEmailBody(data)}</div>
`;
_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 = `<div style="color:var(--red,#e55);padding:16px;">Failed to load: ${_esc(String(err))}</div>`;
}
}
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 = `
<div class="email-summary-header">
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>
<span>Summary</span>
</div>
<div class="email-summary-content" style="white-space:normal;display:flex;align-items:center;flex-wrap:wrap;gap:6px;"><span style="opacity:0.65">No AI summary generated.</span><button class="memory-toolbar-btn" data-act="summary-generate" style="font-size:10px;margin-left:auto;">Generate now</button></div>`;
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 =
'<div class="email-summary-header email-summary-toggle" role="button" tabindex="0">'
+ '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>'
+ '<span>Summary</span>'
+ '<svg class="email-summary-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-left:auto;transition:transform .15s ease;"><polyline points="6 9 12 15 18 9"/></svg>'
+ '</div>'
+ '<div class="email-summary-content"></div>';
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 =
'<div class="email-summary-header email-summary-toggle" role="button" tabindex="0">'
+ '<svg class="email-translation-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/></svg>'
+ `<span>Translation · ${_esc(targetLanguage)}</span>`
+ '<svg class="email-summary-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-left:auto;transition:transform .15s ease;"><polyline points="6 9 12 15 18 9"/></svg>'
+ '</div>'
+ '<div class="email-summary-content email-translation-loading"><span class="email-translation-busy"><span class="email-translation-spinner"></span><span class="email-translation-loading-text">Translating...</span></span></div>';
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*<<<TRANSLATION>>>\s*/i, '')
.replace(/\s*<<<END>>>\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) => `<span class="dropdown-icon">${svg}</span>`;
const _unreadIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
const _archIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>';
const _spamIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>';
const _trashIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>';
const _deleteForeverIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="14" y2="15"/><line x1="14" y1="11" x2="10" y2="15"/></svg>';
const _bellIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
const _newTabIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
const _checkIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
const _translateIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent-primary, var(--red))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/></svg>';
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 = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
const _contactIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>';
// 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: '<svg width="14" height="14" viewBox="0 0 24 24" fill="' + (em.is_flagged ? 'currentColor' : 'none') + '" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>',
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 ? '<span style="margin-left:auto;opacity:0.5;">›</span>' : '';
item.innerHTML = _icon(a.icon) + `<span>${a.label}</span>${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 = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
const cancelItem = document.createElement('div');
cancelItem.className = 'dropdown-item-compact dropdown-cancel-mobile';
cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
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) => `<span class="dropdown-icon">${svg}</span>`;
const _replyIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>';
const _archIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>';
const _delIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>';
const _unreadIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
const _checkIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
const _cardBellIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
const isSentFolder = /sent/i.test(state._libFolder);
const _newTabIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
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: '<svg width="14" height="14" viewBox="0 0 24 24" fill="' + (em.is_flagged ? 'currentColor' : 'none') + '" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>',
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: '<svg width="14" height="14" viewBox="0 0 24 24" fill="' + (em.is_flagged ? 'currentColor' : 'none') + '" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>',
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 ? '<span style="margin-left:auto;opacity:0.5;">›</span>' : '';
item.innerHTML = _icon(a.icon) + `<span>${a.label}</span>${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 = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
const cancelItem = document.createElement('div');
cancelItem.className = 'dropdown-item-compact dropdown-cancel-mobile';
cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
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 = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"/><path d="m22 2-7 20-4-9-9-4 20-7z"/></svg>';
const _unreadIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
const _doneIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
const _exportIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
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 = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`;
it.addEventListener('click', (e) => { e.stopPropagation(); close(); a.action(); });
dropdown.appendChild(it);
}
// Mobile-only Cancel — matches the per-card and sidebar dropdowns.
const _cancelIco2 = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
const cancelIt = document.createElement('div');
cancelIt.className = 'dropdown-item-compact dropdown-cancel-mobile';
cancelIt.innerHTML = `<span class="dropdown-icon">${_cancelIco2}</span><span>Cancel</span>`;
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 = `<span class="email-bulk-loading-label">${verbing}</span>`;
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 '<svg class="email-ai-reply-icon" width="14" height="14" viewBox="0 0 24 24" fill="var(--accent-primary, var(--red))" aria-hidden="true"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>';
}
function _summaryIcon(data) {
const fill = data?.cached_summary ? 'var(--accent-primary, var(--red))' : 'currentColor';
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="${fill}"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>`;
}
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 = `
<div class="email-ai-reply-row" style="display:flex;flex-direction:column;gap:6px;min-width:180px;">
<textarea data-note-input rows="2" placeholder="Context (optional)" style="width:100%;box-sizing:border-box;resize:vertical;min-height:42px;font-family:inherit;font-size:11px;padding:5px 6px;border-radius:5px;border:1px solid var(--border,#333);background:var(--bg-elev,#1a1a1a);color:var(--fg);"></textarea>
<div style="display:flex;align-items:center;gap:4px;">
<button class="memory-toolbar-btn" data-mode="ai-reply-fast" title="Draft reply" style="display:inline-flex;align-items:center;justify-content:center;gap:5px;flex:1;">
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style="color:var(--accent, var(--red));"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>
Submit
</button>
</div>
</div>
`;
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 = '<span>Translate to</span>';
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 = `
<input type="text" class="email-translate-custom-input" placeholder="Write language..." style="min-width:0;width:136px;height:26px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--fg);font:inherit;font-size:11px;padding:0 7px;">
<button type="button" class="email-translate-custom-go" style="height:26px;padding:0 8px;border:1px solid var(--border);border-radius:6px;background:color-mix(in srgb, var(--fg) 5%, transparent);color:inherit;font:inherit;font-size:11px;cursor:pointer;">Go</button>
`;
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 = `<span>${language}</span>`;
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 = '<span>Remind me</span>';
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 = `<span>${p.label}</span><span style="margin-left:auto;opacity:0.5;font-size:10px;">${p.sub}</span>`;
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 = '<span>Pick date and time…</span>';
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 = '<span>Note</span>';
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 = `
<div style="font-size:11px;opacity:0.6;">Note about ${_esc(subject)}</div>
<textarea data-note placeholder="Write your note…" rows="4" style="resize:vertical;min-height:80px;font-family:inherit;font-size:12px;padding:7px 8px;border-radius:6px;border:1px solid var(--border);background:var(--bg-elev,#1a1a1a);color:var(--fg);box-sizing:border-box;width:100%;"></textarea>
<div class="email-note-actions" style="display:flex;gap:6px;justify-content:flex-end;position:relative;top:4px;">
<button class="memory-toolbar-btn" data-act="cancel"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true" style="vertical-align:-1px;margin-right:4px;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>Cancel</button>
<button class="memory-toolbar-btn active" data-act="save"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-1px;margin-right:4px;"><path d="M4 4h16v16H4z"/><path d="M8 8h8M8 12h8M8 16h5"/></svg>Save</button>
</div>
`;
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 <email@x>" 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.
//
// Denylist sanitizer — has to block every well-known XSS sink:
// - <script>, <iframe>, <object>, <embed>, <form>, <style>, <link>
// - SVG entirely (event handlers, <use href="javascript:">, <foreignObject>,
// <animate>, <set>, etc.). Email clients don't need SVG.
// - <math> (MathML can carry handlers).
// - <base href="...">, <meta http-equiv="refresh">, <noscript>, <frame>,
// <frameset>, <applet>, <portal>.
// - on* attributes; javascript:/vbscript:/data: URLs in href/src/srcset/
// formaction/action/background/poster/data attributes.
// - srcdoc (defensive — iframe is already nuked).
// - inline `style` declarations containing javascript: or expression().
// _sanitizeHtml / _escLinkify live in ./emailLibrary/utils.js