mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 23:12:22 +02:00
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.
11376 lines
529 KiB
JavaScript
11376 lines
529 KiB
JavaScript
/**
|
||
* 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 › 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 · >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, '&').replace(/</g, '<').replace(/"/g, '"');
|
||
// 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, '&').replace(/</g, '<').replace(/"/g, '"');
|
||
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, '&').replace(/</g, '<').replace(/"/g, '"');
|
||
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, '&').replace(/</g, '<').replace(/"/g, '"');
|
||
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 › 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">✖</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">✖</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
|