]*>|\\n)\\s*((?:<[^>]+>\\s*)*${FROM}\\s*:\\s*[^<\\n]+(?:<[^>]+>\\s*|\\s)*${SENT}\\s*:[\\s\\S]+?${SUBJ}\\s*:[\\s\\S]+)$`,
'i'
);
const m = html.match(outlookRe);
if (m) {
const idx = html.lastIndexOf(m[0]);
// Outlook fallback only ever produces ONE fold, so tag it as last.
html = html.slice(0, idx) + m[1]
+ '
'
+ _foldSummary('Earlier thread', _QUOTE_ICON, _extractQuoteMeta(m[2]))
+ m[2] + ' ';
}
return html;
}
// Global preference: AI summary panels stay collapsed across every email
// once the user folds one, and stay expanded once they unfold. Stored in
// localStorage so the choice survives reloads.
const _SUMMARY_COLLAPSED_KEY = 'odysseus.email.summaryCollapsed';
function _summaryCollapsedPref() {
try { return localStorage.getItem(_SUMMARY_COLLAPSED_KEY) === '1'; } catch { return false; }
}
function _setSummaryCollapsedPref(v) {
try { localStorage.setItem(_SUMMARY_COLLAPSED_KEY, v ? '1' : '0'); } catch {}
}
function _showCachedSummary(reader, summary, btn) {
const body = reader.querySelector('.email-reader-body');
if (!body) return;
if (body.querySelector('.email-summary-panel')) return;
const panel = document.createElement('div');
panel.className = 'email-summary-panel';
if (_summaryCollapsedPref()) panel.classList.add('collapsed');
panel.innerHTML =
''
+ '
';
panel.querySelector('.email-summary-content').textContent = summary;
body.insertBefore(panel, body.firstChild);
const toggle = panel.querySelector('.email-summary-toggle');
// Header click folds/unfolds. Persists so the next email opens in the
// same state.
const _flip = () => {
panel.classList.toggle('collapsed');
_setSummaryCollapsedPref(panel.classList.contains('collapsed'));
};
if (toggle) {
toggle.addEventListener('click', (ev) => { ev.stopPropagation(); _flip(); });
toggle.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); _flip(); }
});
}
if (btn) {
btn.classList.add('active');
const label = btn.querySelector('.btn-label');
if (label) label.textContent = 'Summary';
}
}
// "Other from this sender" — slide-out panel inside the reader listing
// recent emails from the same address. Click an item to load it in place.
async function _toggleFromSenderPanel(reader, data, btn) {
const body = reader.querySelector('.email-reader-body');
if (!body) return;
// Recenter the modal after its size changes (CSS widens + heightens the
// modal-content when the from-sender panel is mounted/unmounted). Without
// this the modal grows only to the right/down and can overflow the
// viewport on narrow / short windows.
const _recenterModal = () => {
const modal = document.getElementById('email-lib-modal');
const content = modal?.querySelector('.modal-content');
if (!content) return;
requestAnimationFrame(() => {
const w = content.offsetWidth;
const h = content.offsetHeight;
const newLeft = Math.max(20, (window.innerWidth - w) / 2);
const newTop = Math.max(20, (window.innerHeight - h) / 2);
content.style.left = newLeft + 'px';
content.style.top = newTop + 'px';
});
};
// Already open? Close it.
const existing = reader.querySelector('.from-sender-panel');
if (existing) {
existing.remove();
reader.classList.remove('from-sender-open');
if (btn) btn.classList.remove('active');
_recenterModal();
return;
}
const fromAddr = String(data.from_address || '').trim();
if (!fromAddr) {
if (typeof showError === 'function') showError('No sender address available');
return;
}
const panel = document.createElement('div');
panel.className = 'from-sender-panel';
const displayName = (data.from_name && data.from_name.trim()) || fromAddr;
const firstName = displayName.split(' ')[0] || displayName;
panel.innerHTML = `
`;
reader.appendChild(panel);
reader.classList.add('from-sender-open');
if (btn) btn.classList.add('active');
_recenterModal();
// Header close — same as the toolbar funnel button so the close path
// stays single-sourced (panel removal + active class drop).
const headerClose = panel.querySelector('.from-sender-close');
if (headerClose) {
headerClose.addEventListener('click', (ev) => {
ev.stopPropagation();
const toolbarBtn = reader.querySelector('[data-act="from-sender"]');
if (toolbarBtn) toolbarBtn.click();
else { panel.remove(); reader.classList.remove('from-sender-open'); }
});
}
const listEl = panel.querySelector('.from-sender-list');
// Hoisted so panel._originalEmails (assigned later, outside the try) can see it.
let emails = [];
// Multi-tag model — the header is now a list of {name, address} chips.
// Filter logic: an email matches when EVERY tag's address appears in
// from/to/cc (case-insensitive substring on the joined header strings).
panel._tags = [{ name: displayName, address: fromAddr }];
panel._attachmentsOnly = false;
const searchEl = panel.querySelector('.from-sender-search');
const chipsContainer = panel.querySelector('.from-sender-chips');
const emptyLabel = panel.querySelector('.from-sender-header-empty');
const suggestEl = panel.querySelector('.from-sender-suggest');
const attToggle = panel.querySelector('[data-toggle="attachments"]');
const _renderChips = () => {
chipsContainer.innerHTML = panel._tags.map((t, i) => `
${_esc(t.name || t.address)}
×
`).join('');
if (emptyLabel) emptyLabel.hidden = panel._tags.length > 0;
chipsContainer.querySelectorAll('.from-sender-chip-x').forEach(btn => {
btn.addEventListener('click', (ev) => {
ev.stopPropagation();
const idx = Number(btn.closest('.from-sender-chip')?.dataset.tagIndex || -1);
if (idx < 0) return;
panel._tags.splice(idx, 1);
_renderChips();
_refreshList();
});
});
};
// Filter loaded emails (or recents) by every active tag.
const _matchesTags = (em) => {
if (!panel._tags.length) return true;
const haystack = [
String(em.from_address || ''),
String(em.to || ''),
String(em.cc || ''),
].join(' ').toLowerCase();
return panel._tags.every(t => haystack.includes(String(t.address || '').toLowerCase()));
};
const _applyToggles = () => {
const base = panel._lastResults || [];
let view = base.filter(_matchesTags);
if (panel._attachmentsOnly) view = view.filter(e => e.has_attachments);
if (!view.length) {
const why = panel._attachmentsOnly
? 'No emails with attachments in this view.'
: (panel._tags.length > 1 ? 'No emails involve all those people.' : 'No matches.');
listEl.innerHTML = `
${why}
`;
} else {
_renderFromSenderRows(view, listEl, reader, { showFolder: !!panel._lastShowFolder });
}
};
panel._setResults = (rows, opts = {}) => {
panel._lastResults = rows || [];
panel._lastShowFolder = !!opts.showFolder;
_applyToggles();
};
// Re-runs the appropriate fetch path for the current tag set / query.
// Declared early so chip-removal handlers above can call it.
let _refreshList = () => {};
if (attToggle) {
attToggle.addEventListener('click', (ev) => {
ev.stopPropagation();
panel._attachmentsOnly = !panel._attachmentsOnly;
attToggle.classList.toggle('is-active', panel._attachmentsOnly);
attToggle.setAttribute('aria-pressed', panel._attachmentsOnly ? 'true' : 'false');
_applyToggles();
});
}
try {
const sp = spinnerModule.createWhirlpool(20);
const loading = panel.querySelector('.from-sender-loading');
loading.appendChild(sp.element);
const params = new URLSearchParams({
q: fromAddr,
folder: state._libFolder || 'INBOX',
limit: '25',
});
const acct = _acct();
const acctSuffix = acct ? acct.replace(/^&?/, '&') : '';
const res = await fetch(`${API_BASE}/api/email/search?${params.toString()}${acctSuffix}`);
const j = await res.json();
let raw = Array.isArray(j.emails) ? j.emails : [];
const target = fromAddr.toLowerCase();
raw = raw.filter(e => String(e.from_address || '').toLowerCase() === target);
raw = raw.filter(e => String(e.uid) !== String(data.uid));
emails = raw;
if (!emails.length) {
listEl.innerHTML = `
No other emails from this sender in ${_esc(state._libFolder || 'INBOX')}.
`;
} else {
panel._setResults(emails, { showFolder: false });
}
} catch (err) {
listEl.innerHTML = `
Failed to load: ${_esc(String(err))}
`;
}
const updatePlaceholder = () => {
if (!searchEl) return;
searchEl.placeholder = panel._tags.length
? 'Add another person…'
: 'Search people or emails…';
};
updatePlaceholder();
_renderChips();
// Used both when chips change AND when the user clears their query.
// Pulls the most-recent emails across the common folders so the user
// lands on something useful, then _applyToggles narrows by tags.
let _recentToken = 0;
const _loadRecentAcross = async () => {
const myToken = ++_recentToken;
const folders = _crossFolderCandidates();
const acct = _acct();
const acctSuffix = acct ? acct.replace(/^&?/, '&') : '';
listEl.innerHTML = `
`;
try {
const sp = spinnerModule.createWhirlpool(18);
listEl.querySelector('.from-sender-loading')?.appendChild(sp.element);
const results = await Promise.all(folders.map(async (f) => {
const params = new URLSearchParams({ folder: f, limit: '40', offset: '0', filter: 'all' });
const res = await fetch(`${API_BASE}/api/email/list?${params.toString()}${acctSuffix}`);
const j = await res.json();
return (j.emails || []).map(em => ({ ...em, _folder: f }));
}));
if (myToken !== _recentToken) return;
let merged = [].concat(...results);
merged.sort((a, b) => {
const da = a.date ? Date.parse(a.date) : 0;
const db = b.date ? Date.parse(b.date) : 0;
return db - da;
});
// Take a wider slice up front; tag/attachment filters trim it.
merged = merged.slice(0, 80);
panel._setResults(merged, { showFolder: true });
updatePlaceholder();
} catch (err) {
if (myToken !== _recentToken) return;
listEl.innerHTML = `
Failed to load: ${_esc(String(err))}
`;
}
};
// Adds a contact as a tag, clears input, refreshes the list.
const _addTag = (contact) => {
if (!contact || !contact.address) return;
const addr = String(contact.address).toLowerCase();
if (panel._tags.some(t => String(t.address).toLowerCase() === addr)) return;
panel._tags.push({ name: contact.name || contact.address, address: contact.address });
_renderChips();
if (searchEl) { searchEl.value = ''; }
if (suggestEl) { suggestEl.hidden = true; suggestEl.innerHTML = ''; }
updatePlaceholder();
_refreshList();
};
// Cross-folder search — when the user types, also honor the sender chip if
// it's still active. Empty input with chip active restores the original
// "from this sender" view; empty input with chip removed shows the prompt.
if (searchEl) {
let searchToken = 0;
let debounceTimer = null;
let suggestToken = 0;
let highlightedIdx = -1;
// Free-text email search across folders. Tag filter is applied via
// _applyToggles inside panel._setResults.
const runSearch = async (q) => {
const myToken = ++searchToken;
const folders = _crossFolderCandidates();
const acct = _acct();
const acctSuffix = acct ? acct.replace(/^&?/, '&') : '';
try {
const results = await Promise.all(folders.map(async (f) => {
const params = new URLSearchParams({ q, folder: f, limit: '15' });
const res = await fetch(`${API_BASE}/api/email/search?${params.toString()}${acctSuffix}`);
const j = await res.json();
return (j.emails || []).map(em => ({ ...em, _folder: f }));
}));
if (myToken !== searchToken) return;
let merged = [].concat(...results);
merged.sort((a, b) => {
const da = a.date ? Date.parse(a.date) : 0;
const db = b.date ? Date.parse(b.date) : 0;
return db - da;
});
if (!merged.length) {
listEl.innerHTML = `
No matches for "${_esc(q)}".
`;
return;
}
panel._setResults(merged, { showFolder: true });
} catch (err) {
if (myToken !== searchToken) return;
listEl.innerHTML = `
Search failed: ${_esc(String(err))}
`;
}
};
// Hook up _refreshList so chip removal / tag add can rerun whichever
// path matches the current input state.
_refreshList = () => {
const q = (searchEl.value || '').trim();
if (q.length >= 2) runSearch(q);
else _loadRecentAcross();
};
// Contact suggestions — fetched from /api/email/contacts. Renders a
// small absolutely-positioned dropdown under the input. Up/Down/Enter/
// Esc handled in the keydown listener below.
const _renderSuggestions = (items) => {
if (!suggestEl) return;
if (!items || !items.length) {
suggestEl.hidden = true;
suggestEl.innerHTML = '';
highlightedIdx = -1;
return;
}
highlightedIdx = 0;
suggestEl.innerHTML = items.map((c, i) => `
${_esc(c.name || c.address)}
${_esc(c.address)}
`).join('');
suggestEl.hidden = false;
suggestEl.querySelectorAll('.from-sender-suggest-item').forEach(item => {
item.addEventListener('mouseenter', () => {
suggestEl.querySelectorAll('.from-sender-suggest-item').forEach(n => n.classList.remove('active'));
item.classList.add('active');
highlightedIdx = Number(item.dataset.idx);
});
item.addEventListener('mousedown', (ev) => {
// mousedown so we add the chip BEFORE blur takes the focus away
ev.preventDefault();
_addTag({ name: item.dataset.name, address: item.dataset.addr });
});
});
};
const _fetchSuggestions = async (q) => {
const myToken = ++suggestToken;
try {
// Use the same contact source as the email composer's To/Cc fields
// (/api/contacts/search → {results: [{name, emails:[...]}]}). Flatten
// to {name, address} pairs and drop any already-tagged address.
const res = await fetch(`${API_BASE}/api/contacts/search?q=${encodeURIComponent(q)}`);
const j = await res.json();
if (myToken !== suggestToken) return;
const tagged = new Set(panel._tags.map(t => String(t.address).toLowerCase()));
const items = [];
for (const c of (j.results || [])) {
for (const addr of (c.emails || [])) {
if (tagged.has(String(addr).toLowerCase())) continue;
items.push({ name: c.name || addr, address: addr });
if (items.length >= 8) break;
}
if (items.length >= 8) break;
}
_renderSuggestions(items);
} catch {}
};
searchEl.addEventListener('input', () => {
clearTimeout(debounceTimer);
const q = searchEl.value.trim();
if (q.length < 2) {
searchToken++;
suggestToken++;
if (suggestEl) { suggestEl.hidden = true; suggestEl.innerHTML = ''; }
_loadRecentAcross();
return;
}
// Fire suggestions immediately (cheap SQL) and defer the email search.
_fetchSuggestions(q);
debounceTimer = setTimeout(() => runSearch(q), 220);
});
searchEl.addEventListener('keydown', (ev) => {
const items = suggestEl && !suggestEl.hidden
? [...suggestEl.querySelectorAll('.from-sender-suggest-item')]
: [];
if (ev.key === 'ArrowDown' && items.length) {
ev.preventDefault();
highlightedIdx = (highlightedIdx + 1) % items.length;
items.forEach((n, i) => n.classList.toggle('active', i === highlightedIdx));
} else if (ev.key === 'ArrowUp' && items.length) {
ev.preventDefault();
highlightedIdx = (highlightedIdx - 1 + items.length) % items.length;
items.forEach((n, i) => n.classList.toggle('active', i === highlightedIdx));
} else if (ev.key === 'Enter') {
if (items.length && highlightedIdx >= 0) {
ev.preventDefault();
const item = items[highlightedIdx];
_addTag({ name: item.dataset.name, address: item.dataset.addr });
}
} else if (ev.key === 'Escape') {
if (suggestEl && !suggestEl.hidden) {
ev.preventDefault();
suggestEl.hidden = true;
}
} else if (ev.key === 'Backspace' && searchEl.value === '' && panel._tags.length) {
// Empty input + Backspace pops the rightmost chip — common chip-input idiom.
ev.preventDefault();
panel._tags.pop();
_renderChips();
_refreshList();
}
});
searchEl.addEventListener('blur', () => {
// Hide suggestions on blur, with a tiny delay so click-on-suggestion
// gets a chance to fire (mousedown-add covers most cases anyway).
setTimeout(() => { if (suggestEl) suggestEl.hidden = true; }, 120);
});
}
// Stash the sender's emails for restoring after a search is cleared.
panel._originalEmails = (typeof emails !== 'undefined') ? emails : [];
}
const _ATT_ICON = '
';
function _renderFromSenderRows(emails, listEl, reader, opts = {}) {
const { showFolder = false } = opts;
listEl.innerHTML = emails.map(em => {
const subj = em.subject || '(no subject)';
const date = em.date ? new Date(em.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : (em.date_display || '');
const unread = em.is_read ? '' : ' from-sender-unread';
const att = em.has_attachments ? _ATT_ICON : '';
const folder = em._folder || state._libFolder || 'INBOX';
const folderChip = showFolder ? `
${_esc(folder)} ` : '';
return `
${_esc(subj)}
${att}
${_esc(date)}
${folderChip}
`;
}).join('');
listEl.querySelectorAll('.from-sender-row').forEach(row => {
const main = row.querySelector('.from-sender-row-main');
const more = row.querySelector('.from-sender-row-more');
main?.addEventListener('click', async () => {
const uid = row.dataset.uid;
const folder = row.dataset.folder || state._libFolder;
if (!uid) return;
await _swapReaderToUid(reader, uid, folder);
});
more?.addEventListener('click', async (ev) => {
ev.stopPropagation();
const uid = row.dataset.uid;
const folder = row.dataset.folder || state._libFolder;
if (!uid) return;
// Look up the row's email in any cache we know about; the menu just
// needs uid + subject + folder for its actions.
const em = (typeof emails !== 'undefined' ? emails : []).find(e => String(e.uid) === String(uid))
|| state._libEmails.find(e => String(e.uid) === String(uid))
|| { uid, subject: row.querySelector('.from-sender-subj')?.textContent || '' };
const card = reader.closest('.doclib-card');
if (card) _showReaderMoreMenu(em, card, reader, more);
});
});
}
// 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-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);
const label = document.createElement('span');
label.className = 'email-attachment-open-label';
label.textContent = 'Open';
openBtn.appendChild(label);
try {
const folderQs = encodeURIComponent(sourceFolder);
const res = await fetch(
`${API_BASE}/api/email/attachment-as-doc/${encodeURIComponent(uid)}/${encodeURIComponent(index)}?folder=${folderQs}${_acct()}`,
{ method: 'POST', credentials: 'same-origin' }
);
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'); 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');
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'); showError('Document opened but panel could not mount'); } catch (_) {}
}
} catch (e) {
console.error('attachment-as-doc error', e);
try { const { showError } = await import('./ui.js'); showError(`Couldn't open ${name}`); } catch (_) {}
} finally {
delete openBtn.dataset.opening;
openBtn.classList.remove('is-loading');
openBtn.innerHTML = origHtml;
}
});
});
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')) 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) {
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 iconSvg = 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 {
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 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));
const renderChip = (a, extraClass = '') => {
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 = openable
? `
Open `
: '';
return ``;
};
const chips = visible.map(a => renderChip(a)).join('');
const hiddenChips = hidden.map(a => renderChip(a, ' email-attachment-chip-muted')).join('');
const relatedChips = related.map(a => renderChip(a, ' email-attachment-chip-related')).join('');
const visibleSection = visible.length
? '
' + chips + '
'
: '';
const relatedSection = related.length
? '
From earlier in this thread
' + relatedChips + '
'
: '';
const hiddenSection = hidden.length
? '
Filtered inline images / signature files
' + hiddenChips + '
'
: '';
const label = visible.length
? `Attachments (${visible.length + related.length})`
: related.length
? `Thread attachments (${related.length})`
: `Hidden inline attachments (${hidden.length})`;
const startCollapsed = !visible.length && !related.length;
return (
`
`
+ ''
+ visibleSection
+ relatedSection
+ hiddenSection
+ '
'
);
}
async function _ensureEmailAttachmentData(uid, folder, data, knownHasAttachments = false) {
if (!data) return data;
const current = Array.isArray(data.attachments) ? data.attachments : [];
const related = Array.isArray(data.related_attachments) ? data.related_attachments : [];
const shouldFetch = data.attachments_deferred || (knownHasAttachments && !current.length && !related.length);
if (!shouldFetch) return data;
try {
const metaRes = await fetch(`${API_BASE}/api/email/attachments/${encodeURIComponent(uid)}?folder=${encodeURIComponent(folder || 'INBOX')}${_acct()}`);
const meta = await metaRes.json().catch(() => ({}));
if (metaRes.ok && Array.isArray(meta.attachments) && meta.attachments.length) {
return {
...data,
attachments: meta.attachments,
related_attachments: related,
attachments_deferred: false,
};
}
} catch (_) {}
try {
const res = await fetch(`${API_BASE}/api/email/read/${encodeURIComponent(uid)}?folder=${encodeURIComponent(folder || 'INBOX')}${_acct()}&mark_seen=false&full=1`);
const full = await res.json();
if (!full || full.error) return data;
return {
...data,
attachments: Array.isArray(full.attachments) ? full.attachments : current,
related_attachments: Array.isArray(full.related_attachments) ? full.related_attachments : related,
attachment_version: full.attachment_version || data.attachment_version,
attachments_deferred: false,
};
} catch (_) {
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;
_ensureEmailAttachmentData(uid, folder, data, knownHasAttachments).then(fullData => {
if (!reader.isConnected || !fullData || fullData === data) return;
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(() => {});
}
// "Open in new tab" — the email opens in the library (expanded inline)
// AND a separate floating "email viewer" overlay modal is created. The
// overlay starts minimized as a chip in the dock; tapping the chip
// brings the viewer up over the library. Multiple tabs = multiple
// overlay modals + chips, each independent.
const _EMAIL_ICON_PATH = 'M2 4h20v16H2zM22 7l-9.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7';
let _emailTabSeq = 0;
// Persistent slot numbers per reader modalId. Once a reader is "tab 2"
// it stays "tab 2" until it's closed — even if tab 1 closes first, the
// remaining reader doesn't renumber down to 1. New tabs claim the
// lowest unused slot.
const _emailReaderSlots = new Map(); // modalId -> slot (1, 2, 3, ...)
function _allocReaderSlot(modalId) {
if (_emailReaderSlots.has(modalId)) return _emailReaderSlots.get(modalId);
const used = new Set(_emailReaderSlots.values());
let n = 1;
while (used.has(n)) n++;
_emailReaderSlots.set(modalId, n);
return n;
}
function _freeReaderSlot(modalId) {
_emailReaderSlots.delete(modalId);
}
// JS-driven gate: sets [data-email-tabs="N"] on so CSS can show
// the per-chip number badge only when 2+ tabs exist.
function _syncEmailTabsCount() {
const tabs = document.querySelectorAll('.minimized-dock-chip[data-modal-id^="email-view-"]');
document.body.dataset.emailTabs = String(tabs.length);
}
// Recompute the email menu chip's tab-count whenever the dock contents
// change. Counts "email-view-*" chips both inside #minimized-dock and
// at body level (free-positioned chips on mobile). Result is written to
// the email-lib-modal chip's data-tab-count attribute; CSS reads it via
// attr() to render the badge.
function _syncEmailTabBadge() {
const readers = document.querySelectorAll('.minimized-dock-chip[data-modal-id^="email-reader-"]');
document.body.dataset.emailReaders = String(readers.length);
// Stamp each chip with its persistent slot number. CSS reads
// data-tab-num via attr() instead of using a counter so the number
// stays stable when other tabs close.
readers.forEach(chip => {
const slot = _emailReaderSlots.get(chip.dataset.modalId);
if (slot) chip.dataset.tabNum = String(slot);
});
}
let _emailTabObserverWired = false;
let _badgeSyncScheduled = false;
function _ensureEmailTabObserver() {
if (_emailTabObserverWired) return;
_emailTabObserverWired = true;
// Debounce so a burst of mutations (e.g. _renderDock rebuilding the
// whole dock in one pass) collapses to a single sync per animation
// frame. Without this the chip badge could flicker as the observer
// fires repeatedly during dock rerenders.
const handler = () => {
if (_badgeSyncScheduled) return;
_badgeSyncScheduled = true;
requestAnimationFrame(() => {
_badgeSyncScheduled = false;
_syncEmailTabBadge();
});
};
const tryWire = () => {
const dock = document.getElementById('minimized-dock');
if (!dock) { setTimeout(tryWire, 200); return; }
// Only watch what we care about: chip add/remove in the dock.
const obs = new MutationObserver(handler);
obs.observe(dock, { childList: true });
// Watch the library grid so toggling a card expanded/collapsed
// updates the lib chip's "has-expanded" badge in real time.
const wireGridObs = () => {
const grid = document.getElementById('email-lib-grid');
if (!grid) { setTimeout(wireGridObs, 500); return; }
const gridObs = new MutationObserver(handler);
gridObs.observe(grid, { subtree: true, attributes: true, attributeFilter: ['class'] });
};
wireGridObs();
handler();
};
tryWire();
}
// Hybrid model:
// - email-lib-modal (the inbox library) is unique. Its chip just
// restores it.
// - Each "Open in new tab" creates a separate per-email reader modal
// (id "email-reader-{uid}-{seq}") with the SAME structure & classes
// as the library's inline reader, so they look identical. Each
// reader registers its own dock chip with a number badge.
async function _openEmailAsTab(em, folder) {
const useFolder = folder || state._libFolder || 'INBOX';
_emailTabSeq += 1;
const modalId = `email-reader-${em.uid}-${_emailTabSeq}`;
_allocReaderSlot(modalId);
// Build the modal shell. Uses the same doclib-modal-content sizing
// as the email library so it feels like a sibling window. The reader
// body inside uses the exact same email-card-reader / email-reader-*
// classes the inline reader uses → identical styling.
const modal = document.createElement('div');
modal.className = 'modal email-reader-tab-modal';
modal.id = modalId;
modal.innerHTML = `
`;
document.body.appendChild(modal);
// Inherit display from .modal (flex-center). z-index above the library
// (which uses default .modal z-index 250) so the new tab sits on top.
modal.style.zIndex = '270';
// Opened last → email windows in front of any open doc (alternation flag).
document.body.classList.add('email-front');
Modals.register(modalId, {
label: 'Email',
icon: _EMAIL_ICON_PATH,
closeFn: () => {
modal.remove();
_freeReaderSlot(modalId);
Promise.resolve().then(_syncEmailTabBadge);
},
restoreFn: () => {
// Reopened last → bring the email windows in front of any open doc.
document.body.classList.add('email-front');
// Mobile: only one email window visible at a time. Tapping this
// chip chips down the library + any other reader, so the user
// toggles between them via the dock instead of stacking.
if (window.innerWidth <= 768) {
try {
if (Modals.isRegistered('email-lib-modal') && !Modals.isMinimized('email-lib-modal')) {
Modals.minimize('email-lib-modal');
}
} catch {}
document.querySelectorAll('.modal[id^="email-reader-"]').forEach(other => {
if (other.id === modalId) return;
try {
if (Modals.isRegistered(other.id) && !Modals.isMinimized(other.id)) {
Modals.minimize(other.id);
}
} catch {}
});
}
},
});
// Wire the `_` minimize button via modalManager (it sees our .minimize-btn
// already exists and just binds the click handler).
try { Modals.injectMinimizeButton(modal, modalId); } catch {}
// X button fully closes the tab (tears down and unregisters).
modal.querySelector('.close-btn')?.addEventListener('click', (ev) => {
ev.stopPropagation();
Modals.close(modalId);
});
// Wire dragging on the header (desktop only). Matches the global pattern
// in app.js initUIVisibility, but that runs once at boot and doesn't see
// dynamically-created modals — so we replicate it here.
const content = modal.querySelector('.modal-content');
const mh = modal.querySelector('.modal-header');
if (mh && content) {
let dragX = 0, dragY = 0, startLeft = 0, startTop = 0, dragging = false;
const startDrag = (clientX, clientY) => {
dragging = true;
const rect = content.getBoundingClientRect();
dragX = clientX; dragY = clientY;
startLeft = rect.left; startTop = rect.top;
content.style.position = 'fixed';
content.style.left = startLeft + 'px';
content.style.top = startTop + 'px';
content.style.margin = '0';
};
const onDrag = (e) => {
if (!dragging) return;
content.style.left = (startLeft + e.clientX - dragX) + 'px';
content.style.top = (startTop + e.clientY - dragY) + 'px';
};
const stopDrag = () => {
dragging = false;
document.removeEventListener('mousemove', onDrag);
document.removeEventListener('mouseup', stopDrag);
};
mh.addEventListener('mousedown', (e) => {
if (e.target.closest('.close-btn, .minimize-btn, .modal-minimize-btn')) return;
e.preventDefault();
startDrag(e.clientX, e.clientY);
document.addEventListener('mousemove', onDrag);
document.addEventListener('mouseup', stopDrag);
});
}
// Open the new tab in front, on top of the email library. The user
// can tap `_` to tab it down to a chip when they're done reading.
//
// Mobile: bottom-sheet windows fill the viewport, so stacking multiple
// readers on top of each other is confusing — only one window can be
// meaningfully visible at a time. So when the new tab opens, chip down
// the library AND any other email-reader-* tab that's currently up.
// The user gets a stack of mini chips to toggle between them.
if (window.innerWidth <= 768) {
try {
if (Modals.isRegistered('email-lib-modal') && !Modals.isMinimized('email-lib-modal')) {
Modals.minimize('email-lib-modal');
}
} catch {}
document.querySelectorAll('.modal[id^="email-reader-"]').forEach(other => {
if (other.id === modalId) return;
try {
if (Modals.isRegistered(other.id) && !Modals.isMinimized(other.id)) {
Modals.minimize(other.id);
}
} catch {}
});
}
_ensureEmailTabObserver();
_syncEmailTabBadge();
// Fetch + render the email body using the exact same template as
// _toggleCardPreview so the visuals match perfectly.
const reader = modal.querySelector('.email-card-reader');
_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()}`);
let data = await res.json();
if (data.error) {
reader.innerHTML = `
Error: ${_esc(data.error)}
`;
return;
}
_syncEmailReadState(em.uid, true);
_stampReaderContext(reader, { ...em, ...data }, useFolder, state._libAccountId);
const buildChips = (str) => {
if (!str) return '';
return _splitRecipientList(str).map(a => {
const name = _extractName(a);
return _recipientChipHtml(a, name);
}).join('');
};
const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip');
let attsHtml = '';
try { attsHtml = _buildAttsHtmlFor(em.uid, data); } catch {}
reader.innerHTML = `
${attsHtml}
${_safeRenderEmailBody(data)}
`;
_markEmailReaderActive(reader);
reader.classList.remove('email-card-reader-loading');
_wireRecipientChips(reader);
_wireEmailAttachmentWrap(reader, useFolder);
_wireEmailInlineImages(reader);
_loadDeferredAttachmentsIntoReader(reader, em.uid, useFolder, data, !!em.has_attachments);
_maybeAutoTranslateEmail(reader);
reader.querySelector('[data-act="reply"]')?.addEventListener('click', async (ev) => {
ev.stopPropagation();
_snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal'));
if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply' });
});
reader.querySelector('[data-act="reply-all"]')?.addEventListener('click', async (ev) => {
ev.stopPropagation();
_snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal'));
if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply-all' });
});
reader.querySelector('[data-act="ai-reply"]')?.addEventListener('click', (ev) => _handleAiReplyButton(ev, em, data));
reader.querySelector('[data-act="forward"]')?.addEventListener('click', async (ev) => {
ev.stopPropagation();
if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'forward' });
});
reader.querySelector('[data-act="summarize"]')?.addEventListener('click', async (ev) => {
ev.stopPropagation();
try { await _summarizeEmail(reader, data, ev.currentTarget); } catch {}
});
_wireMetaToggle(reader);
reader.querySelector('[data-act="from-sender"]')?.remove();
reader.querySelector('[data-act="from-sender"]')?.addEventListener('click', async (ev) => {
ev.stopPropagation();
try { await _toggleFromSenderPanel(reader, data, ev.currentTarget); } catch {}
});
reader.querySelector('[data-act="more"]')?.addEventListener('click', (ev) => {
ev.stopPropagation();
try { _showReaderMoreMenu(em, modal, reader, ev.currentTarget); } catch {}
});
} catch (err) {
reader.innerHTML = `
Failed to load: ${_esc(String(err))}
`;
}
}
// "Open in new window" — spawns a floating draggable modal that shows just
// the email content. Multiple windows can stack; each has its own DOM id
// and close button. Uses `_makeDraggable` so dragging the header pans the
// window around. Renders the body via _renderEmailBody for parity with the
// expanded reader.
let _emailWindowSeq = 0;
async function _openEmailWindow(em, folder) {
const useFolder = folder || state._libFolder || 'INBOX';
_emailWindowSeq += 1;
const winId = `email-window-${em.uid}-${_emailWindowSeq}`;
const modal = document.createElement('div');
modal.className = 'modal email-window-modal';
modal.id = winId;
modal.style.cssText = 'pointer-events:none;background:transparent;';
modal.innerHTML = `
`;
document.body.appendChild(modal);
modal.style.display = 'block';
const content = modal.querySelector('.modal-content');
// Position offset from screen center so successive windows cascade.
const isMobile = window.innerWidth <= 768;
if (isMobile) {
content.style.position = 'fixed';
content.style.pointerEvents = 'auto';
content.style.left = '0';
content.style.right = '0';
content.style.bottom = '0';
content.style.top = 'auto';
} else {
content.style.position = 'fixed';
content.style.pointerEvents = 'auto';
requestAnimationFrame(() => {
const w = content.offsetWidth, h = content.offsetHeight;
const off = (_emailWindowSeq % 6) * 28;
content.style.left = Math.max(20, (window.innerWidth - w) / 2 + off) + 'px';
content.style.top = Math.max(20, (window.innerHeight - h) / 3 + off) + 'px';
});
}
modal.querySelector('.close-btn')?.addEventListener('click', () => modal.remove());
try { _makeDraggable(content, modal, 'email-window-fullscreen'); } catch {}
// Load + render
const bodyEl = modal.querySelector('.email-window-body');
const loading = modal.querySelector('.email-window-loading');
try {
if (loading) loading.remove();
if (bodyEl) {
bodyEl.classList.add('email-card-reader', 'email-card-reader-loading');
bodyEl.style.padding = '0';
bodyEl.innerHTML = _emailReaderSkeletonHtml();
}
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(useFolder)}${_acct()}`);
let data = await res.json();
if (data.error) {
bodyEl.innerHTML = `
${_esc(data.error)}
`;
return;
}
_syncEmailReadState(em.uid, true);
const subjEl = modal.querySelector('.email-window-subject');
if (subjEl && data.subject) subjEl.textContent = data.subject;
// Build recipient chips the same way the inline reader does so the
// standalone viewer looks/feels exactly like a real email view.
const _chipsFor = (addrs) => {
if (!addrs) return '';
const list = _splitRecipientList(addrs);
return list.map(a => {
const name = _extractName(a);
return _recipientChipHtml(a, name);
}).join('');
};
const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip');
let attsHtml = '';
try { attsHtml = _buildAttsHtmlFor(em.uid, data); } catch {}
// Repurpose bodyEl as a full email-card-reader so the inline reader's
// CSS applies (sized header, action buttons in two rows, etc.).
bodyEl.classList.add('email-card-reader');
bodyEl.classList.remove('email-card-reader-loading');
_stampReaderContext(bodyEl, { ...em, ...data }, useFolder, state._libAccountId);
_markEmailReaderActive(bodyEl);
bodyEl.style.padding = '0';
bodyEl.innerHTML = `
${attsHtml}
${_safeRenderEmailBody(data)}
`;
_markEmailReaderActive(bodyEl);
_wireRecipientChips(bodyEl);
// Wire all the same action handlers the inline reader has.
_wireEmailAttachmentWrap(bodyEl, useFolder);
_wireEmailInlineImages(bodyEl);
_loadDeferredAttachmentsIntoReader(bodyEl, em.uid, useFolder, data, !!em.has_attachments);
_maybeAutoTranslateEmail(bodyEl);
bodyEl.querySelector('[data-act="reply"]')?.addEventListener('click', async (ev) => {
ev.stopPropagation();
_snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal'));
if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply' });
});
bodyEl.querySelector('[data-act="reply-all"]')?.addEventListener('click', async (ev) => {
ev.stopPropagation();
_snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal'));
if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply-all' });
});
bodyEl.querySelector('[data-act="ai-reply"]')?.addEventListener('click', (ev) => _handleAiReplyButton(ev, em, data));
bodyEl.querySelector('[data-act="forward"]')?.addEventListener('click', async (ev) => {
ev.stopPropagation();
if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'forward' });
});
bodyEl.querySelector('[data-act="summarize"]')?.addEventListener('click', async (ev) => {
ev.stopPropagation();
try { await _summarizeEmail(bodyEl, data, ev.currentTarget); } catch {}
});
_wireMetaToggle(bodyEl);
bodyEl.querySelector('[data-act="from-sender"]')?.remove();
bodyEl.querySelector('[data-act="from-sender"]')?.addEventListener('click', async (ev) => {
ev.stopPropagation();
try { await _toggleFromSenderPanel(bodyEl, data, ev.currentTarget); } catch {}
});
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); } catch {}
});
} catch (err) {
bodyEl.innerHTML = `
Failed to load: ${_esc(String(err))}
`;
}
}
// Fetch a new email's content and replace the current reader body with it
// (preserving the from-sender panel). Used for in-place navigation between
// emails of the same sender — `folder` defaults to the library's current
// folder but is overridable so cross-folder search results can open the
// correct one.
async function _swapReaderToUid(reader, uid, folder) {
const body = reader.querySelector('.email-reader-body');
if (!body) return;
body.innerHTML = '';
const sp = spinnerModule.createWhirlpool(24);
const wrap = document.createElement('div');
wrap.style.cssText = 'padding:20px;display:flex;justify-content:center';
wrap.appendChild(sp.element);
body.appendChild(wrap);
const useFolder = folder || state._libFolder;
try {
const res = await fetch(`${API_BASE}/api/email/read/${uid}?folder=${encodeURIComponent(useFolder)}${_acct()}`);
let data = await res.json();
if (data.error) {
body.innerHTML = `
${_esc(data.error)}
`;
return;
}
_syncEmailReadState(uid, true);
_stampReaderContext(reader, { ...data, uid }, useFolder, state._libAccountId);
// Update the header meta (From/To/Subject) so it matches the new email.
const headerMeta = reader.querySelector('.email-reader-meta');
if (headerMeta) {
const subj = data.subject || '(no subject)';
const date = data.date ? new Date(data.date).toLocaleString() : '';
const chipsFor = (addrs) => {
if (!addrs) return '';
return _splitRecipientList(addrs).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');
headerMeta.innerHTML = `
Subject: ${_esc(subj)}
From: ${fromChip}
${data.to ? `
To: ${chipsFor(data.to)}
` : ''}
${data.cc ? `
Cc: ${chipsFor(data.cc)}
` : ''}
${date ? `
Date: ${_esc(date)}
` : ''}
`;
_wireRecipientChips(reader);
}
// Refresh the attachments block to match the new email. Build fresh HTML
// and either replace the existing block, remove it (if the new email has
// none), or insert one before the body (if the previous email had none
// but the new one does).
const newAttsHtml = _buildAttsHtmlFor(uid, data);
const oldAtts = reader.querySelector('.email-reader-atts-wrap');
if (newAttsHtml) {
if (oldAtts) {
const tmp = document.createElement('div');
tmp.innerHTML = newAttsHtml;
oldAtts.replaceWith(tmp.firstChild);
} else {
body.insertAdjacentHTML('beforebegin', newAttsHtml);
}
_wireEmailAttachmentWrap(reader, useFolder);
} else if (oldAtts) {
oldAtts.remove();
}
body.innerHTML = _safeRenderEmailBody(data);
body.classList.toggle('html-body', !!data.body_html);
_wireEmailInlineImages(reader);
_wireEmailAttachmentWrap(reader, useFolder);
_loadDeferredAttachmentsIntoReader(reader, uid, useFolder, data, !!data.attachments_deferred);
_maybeAutoTranslateEmail(reader);
} catch (err) {
body.innerHTML = `
${_esc(String(err))}
`;
}
}
async function _summarizeEmail(reader, data, btn) {
const body = reader.querySelector('.email-reader-body');
if (!body) return;
// If a summary panel already exists, toggle: hide/show
const existing = body.querySelector('.email-summary-panel');
if (existing) {
if (existing.style.display === 'none') {
existing.style.display = '';
if (btn) {
btn.classList.add('active');
btn.querySelector('.btn-label').textContent = 'Summary';
}
} else {
existing.style.display = 'none';
if (btn) {
btn.classList.remove('active');
btn.querySelector('.btn-label').textContent = 'Summary';
}
}
return;
}
// No panel yet. If the email has no cached AI summary, show a placeholder
// "not generated — create now?" prompt instead of firing the LLM immediately.
// This avoids accidental LLM spend and makes the state explicit to the user.
if (!data.cached_summary) {
const prompt = document.createElement('div');
prompt.className = 'email-summary-panel';
prompt.innerHTML = `
No AI summary generated. Generate now
`;
body.insertBefore(prompt, body.firstChild);
if (btn) {
btn.classList.add('active');
const label = btn.querySelector('.btn-label');
if (label) label.textContent = 'Summary';
}
// No Cancel button — toggling the Summary button again hides this panel
// (handled by the existing-panel branch above), so it'd be redundant.
prompt.querySelector('[data-act="summary-generate"]').addEventListener('click', async (ev) => {
ev.stopPropagation();
prompt.remove();
await _generateSummary(reader, data, btn);
});
return;
}
// Cached summary exists — show it immediately.
await _generateSummary(reader, data, btn);
}
async function _generateSummary(reader, data, btn) {
const body = reader.querySelector('.email-reader-body');
if (!body) return;
const panel = document.createElement('div');
panel.className = 'email-summary-panel';
panel.innerHTML =
''
+ '
';
if (_summaryCollapsedPref()) panel.classList.add('collapsed');
body.insertBefore(panel, body.firstChild);
const _genToggle = panel.querySelector('.email-summary-toggle');
if (_genToggle) {
const _genFlip = () => {
panel.classList.toggle('collapsed');
_setSummaryCollapsedPref(panel.classList.contains('collapsed'));
};
_genToggle.addEventListener('click', (ev) => { ev.stopPropagation(); _genFlip(); });
_genToggle.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); _genFlip(); }
});
}
const sp = spinnerModule.createWhirlpool(18);
const content = panel.querySelector('.email-summary-content');
content.appendChild(sp.element);
if (btn) btn.disabled = true;
try {
const res = await fetch(`${API_BASE}/api/email/summarize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
body: data.body,
subject: data.subject,
from: `${data.from_name} <${data.from_address}>`,
// Send identifiers so the backend can fetch the raw message and
// pull attachment text for the summary (PDFs, invoices, etc.).
uid: data.uid || '',
folder: state._libFolder || 'INBOX',
message_id: data.message_id || '',
account_id: data.account_id || '',
}),
});
const result = await res.json();
sp.destroy();
content.innerHTML = '';
if (result.success && result.summary) {
content.textContent = result.summary;
if (btn) {
btn.classList.add('active');
const label = btn.querySelector('.btn-label');
if (label) label.textContent = 'Summary';
}
} else {
content.innerHTML = `
${_esc(result.error || 'Failed to summarize')} `;
panel.remove();
}
} catch (e) {
sp.destroy();
panel.remove();
if (uiModule) uiModule.showError?.('Failed to summarize');
} finally {
if (btn) btn.disabled = false;
}
}
function _emailBodyTextForTranslate(reader) {
const body = reader?.querySelector?.('.email-reader-body');
if (!body) return '';
const clone = body.cloneNode(true);
clone.querySelectorAll('.email-summary-panel, details.email-quote-fold, details.email-sig-fold').forEach(n => n.remove());
return (clone.innerText || clone.textContent || '').trim();
}
async function _translateEmail(reader, language, opts = {}) {
const body = reader?.querySelector?.('.email-reader-body');
if (!body) return;
const existing = body.querySelector('.email-translation-panel');
if (existing) {
if (opts.auto && !opts.force) return;
existing.remove();
}
const targetLanguage = language || 'English';
const sourceText = _emailBodyTextForTranslate(reader);
if (!sourceText) {
try { uiModule?.showError?.('No email body to translate'); } catch {}
return;
}
body.querySelectorAll('.email-translation-panel').forEach(p => p.remove());
const panel = document.createElement('div');
panel.className = 'email-summary-panel email-translation-panel';
panel.innerHTML =
''
+ '
Translating...
';
body.insertBefore(panel, body.firstChild);
const translationToggle = panel.querySelector('.email-summary-toggle');
if (translationToggle) {
const flip = () => panel.classList.toggle('collapsed');
translationToggle.addEventListener('click', (ev) => { ev.stopPropagation(); flip(); });
translationToggle.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); flip(); }
});
}
const content = panel.querySelector('.email-summary-content');
const sp = spinnerModule.createWhirlpool(18);
content.querySelector('.email-translation-spinner')?.appendChild(sp.element);
try {
const res = await fetch(`${API_BASE}/api/email/translate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
body: sourceText,
subject: reader.dataset.emailSubject || '',
from: reader.dataset.emailFrom || '',
target_language: targetLanguage,
auto: !!opts.auto,
}),
});
const result = await res.json().catch(() => ({}));
sp.destroy();
content.innerHTML = '';
if (res.ok && result.success && result.same_language) {
panel.remove();
} else if (res.ok && result.success && result.translation) {
content.textContent = String(result.translation || '')
.replace(/^\s*<<
>>\s*/i, '')
.replace(/\s*<<>>\s*$/i, '')
.trim();
} else {
panel.remove();
try { uiModule?.showError?.(result.error || 'Failed to translate'); } catch {}
}
} catch (_) {
sp.destroy();
panel.remove();
try { uiModule?.showError?.('Failed to translate'); } catch {}
}
}
async function _maybeAutoTranslateEmail(reader) {
if (!reader || reader.dataset.autoTranslateChecked === '1') return;
reader.dataset.autoTranslateChecked = '1';
try {
const res = await fetch(`${API_BASE}/api/email/config`);
const cfg = await res.json();
if (!cfg || !cfg.email_auto_translate) return;
await _translateEmail(reader, cfg.email_translate_language || 'English', { auto: true });
} catch (_) {}
}
// 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 _showReaderMoreMenu(em, card, reader, anchor) {
// Toggle: if a dropdown for THIS anchor is already open, close it.
const existing = document.querySelector('.email-card-dropdown');
if (existing && existing._anchor === anchor) {
dismissOrRemove(existing);
return;
}
// Otherwise close any other open dropdown (its own teardown clears its
// anchor's active state) before opening a fresh one.
document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
const dropdown = document.createElement('div');
dropdown.className = 'email-card-dropdown';
dropdown._anchor = anchor;
anchor.classList.add('reader-more-active');
const rect = anchor.getBoundingClientRect();
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;right:${window.innerWidth - rect.right}px;`;
const _icon = (svg) => `${svg} `;
const _unreadIcon = ' ';
const _archIcon = ' ';
const _spamIcon = ' ';
const _trashIcon = ' ';
const _deleteForeverIcon = ' ';
const _bellIcon = ' ';
const _newTabIcon = ' ';
const _checkIcon = ' ';
const _translateIcon = ' ';
const closeAndRemove = async () => {
// Pick the next neighbour BEFORE we re-render so we know which email to
// jump to. Prefer the next card; fall back to the previous one if this
// was the last card.
const sibling = _findSiblingEmailCard(card, +1) || _findSiblingEmailCard(card, -1);
const nextUid = sibling ? sibling.dataset.uid : null;
await _animateEmailCardRemoval([em.uid]);
state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid));
_renderGrid();
_libCacheWriteBack();
if (!nextUid) return;
// After _renderGrid, the card nodes are fresh — re-resolve and expand.
const grid = document.getElementById('email-lib-grid');
const nextCard = grid?.querySelector(`.doclib-card[data-uid="${CSS.escape(String(nextUid))}"]`);
const nextEm = state._libEmails.find(e => String(e.uid) === String(nextUid));
if (nextCard && nextEm) {
_toggleCardPreview(nextCard, nextEm);
nextCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
};
const _bubblesIcon = ' ';
const _contactIcon = ' ';
// Three groups separated by dividers:
// 1. Open / Mark Unread / Remind — the per-email view actions
// 2. Save sender / Not Done / Archive — non-destructive state changes
// 3. Move to Spam / Move to Trash / Delete — destructive
const actions = [
{
label: 'Open in new tab',
icon: _newTabIcon,
action: async () => {
const folder = state._libFolder || 'INBOX';
await _openEmailAsTab(em, folder);
},
},
{
label: 'Remind to reply',
icon: _bellIcon,
submenu: 'remind',
},
{
label: 'Translate',
icon: _translateIcon,
submenu: 'translate',
},
{ separator: true },
{
label: em.is_read ? 'Mark as Unread' : 'Mark as Read',
icon: _unreadIcon,
action: async () => {
const newRead = !em.is_read;
_syncEmailReadState(em.uid, newRead);
try {
if (newRead) {
await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
} else {
await fetch(`${API_BASE}/api/email/mark-unread/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
}
} catch (e) { console.error(e); }
_renderGrid();
},
},
{
// Favorite (pin to top). Same bookmark glyph we use for the
// sidebar-pin / favorites filter so the visual language stays
// consistent. Toggling updates em.is_flagged and re-sorts via
// _renderGrid (favorited rows are always pinned at the top).
label: em.is_flagged ? 'Unfavorite' : 'Favorite (pin to top)',
icon: ' ',
action: async () => {
const next = !em.is_flagged;
em.is_flagged = next;
_renderGrid();
try {
await fetch(`${API_BASE}/api/email/flag/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&on=${next ? 'true' : 'false'}`, { method: 'POST' });
} catch (e) {
// Roll back the optimistic flip if the server didn't take it.
em.is_flagged = !next;
_renderGrid();
console.error('Failed to toggle favorite:', e);
}
},
},
{
label: em.is_answered ? 'Mark as Not Done' : 'Mark as Done',
icon: _checkIcon,
action: async () => {
const newState = !em.is_answered;
em.is_answered = newState;
if (newState) {
_clearDoneResponseTagsLocal(em);
_syncEmailReadState(em.uid, true);
}
try {
if (newState) {
await fetch(`${API_BASE}/api/email/mark-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
} else {
await fetch(`${API_BASE}/api/email/clear-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
}
} catch (e) { console.error('Failed to toggle done:', e); }
_renderGrid();
},
},
{
label: 'Move to Archive',
icon: _archIcon,
action: async () => {
try {
await fetch(`${API_BASE}/api/email/archive/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
} catch (e) { console.error(e); }
await closeAndRemove();
},
},
{
// Save the sender to CardDAV contacts. Pulls name + address off the
// list-item (em); falls back to splitting the local-part for a name.
label: 'Save sender to contacts',
icon: _contactIcon,
action: async () => {
const email = (em.from_address || em.from || '').trim();
if (!email) {
import('./ui.js').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').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').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 {
await fetch(`${API_BASE}/api/email/delete/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' });
} 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 {
await fetch(`${API_BASE}/api/email/delete-permanent/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' });
} catch (e) {
console.error(e);
busy?.remove?.();
showToast('Failed to delete email');
return;
}
busy?.remove?.();
await closeAndRemove();
},
},
];
for (const a of actions) {
if (a.separator) {
const sep = document.createElement('div');
sep.className = 'dropdown-divider';
dropdown.appendChild(sep);
continue;
}
const item = document.createElement('div');
item.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
const arrow = a.submenu ? '› ' : '';
item.innerHTML = _icon(a.icon) + `${a.label} ${arrow}`;
item.addEventListener('click', (e) => {
e.stopPropagation();
if (a.submenu === 'remind') {
_showLibRemindSubmenu(em, dropdown);
return;
}
if (a.submenu === 'translate') {
_showEmailTranslateSubmenu(reader, dropdown);
return;
}
close();
a.action();
});
dropdown.appendChild(item);
}
// Mobile-only Cancel item — explicit close for touch users. CSS hides it
// on desktop where outside-click already dismisses cleanly.
const _cancelIco = ' ';
const cancelItem = document.createElement('div');
cancelItem.className = 'dropdown-item-compact dropdown-cancel-mobile';
cancelItem.innerHTML = _icon(_cancelIco) + 'Cancel ';
cancelItem.addEventListener('click', (e) => {
e.stopPropagation();
close();
});
dropdown.appendChild(cancelItem);
document.body.appendChild(dropdown);
_fitEmailDropdown(dropdown, rect);
const close = bindMenuDismiss(dropdown, () => {
dropdown.remove();
anchor.classList.remove('reader-more-active');
}, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
}
function _showCardMenu(em, anchor) {
document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
const dropdown = document.createElement('div');
dropdown.className = 'email-card-dropdown';
const rect = anchor.getBoundingClientRect();
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:140px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;right:${window.innerWidth - rect.right}px;`;
const _icon = (svg) => `${svg} `;
const _replyIcon = ' ';
const _archIcon = ' ';
const _delIcon = ' ';
const _unreadIcon = ' ';
const _checkIcon = ' ';
const _cardBellIcon = ' ';
const isSentFolder = /sent/i.test(state._libFolder);
const _newTabIcon = ' ';
const actions = [
{ label: 'Open', icon: _replyIcon, action: async () => {
// Just expand inline (same as tapping the row).
const card = anchor.closest('.doclib-card');
if (card && !card.classList.contains('doclib-card-expanded')) {
await _toggleCardPreview(card, em);
}
}},
{ label: 'Open in new tab', icon: _newTabIcon, action: async () => {
// Open this email as its own in-app modal that registers a dock
// chip — multiple emails can be opened simultaneously, each gets
// its own chip in the minimized dock.
const folder = state._libFolder || 'INBOX';
await _openEmailAsTab(em, folder);
}},
{ label: 'Remind to reply', icon: _cardBellIcon, submenu: 'remind' },
];
if (!isSentFolder) {
// Source of truth = the visible "active" class on the card's done
// check, so the menu label and the actual toggle behaviour can't
// disagree with what the user sees.
const _cardForLabel = anchor.closest('.doclib-card');
const _checkForLabel = _cardForLabel ? _cardForLabel.querySelector('.email-card-done') : null;
const _currentlyDone = _checkForLabel ? _checkForLabel.classList.contains('active') : !!em.is_answered;
actions.push({
label: _currentlyDone ? 'Not Done' : 'Done',
icon: _checkIcon,
action: async () => {
const card = anchor.closest('.doclib-card');
const check = card ? card.querySelector('.email-card-done') : null;
const wasActive = check ? check.classList.contains('active') : !!em.is_answered;
const newState = !wasActive;
em.is_answered = newState;
if (newState) {
_clearDoneResponseTagsLocal(em);
_syncEmailReadState(em.uid, true); // mark-done implies mark-read
}
try {
if (newState) {
await fetch(`${API_BASE}/api/email/mark-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
} else {
await fetch(`${API_BASE}/api/email/clear-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
}
} catch (e) { console.error('Failed to toggle done:', e); }
if (card) {
if (check) check.classList.toggle('active', newState);
if (newState) {
_syncEmailReadState(em.uid, true);
card.querySelectorAll('.email-tag-urgent, .email-tag-reply-soon, .email-tag-action-needed').forEach(n => n.remove());
}
}
},
});
actions.push({
label: em.is_flagged ? 'Unfavorite' : 'Favorite (pin to top)',
icon: ' ',
action: async () => {
const next = !em.is_flagged;
em.is_flagged = next;
_renderGrid();
try {
await fetch(`${API_BASE}/api/email/flag/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&on=${next ? 'true' : 'false'}`, { method: 'POST' });
} catch (e) {
em.is_flagged = !next;
_renderGrid();
console.error('Failed to toggle favorite:', e);
}
},
});
actions.push({
label: 'Archive',
icon: _archIcon,
action: async () => {
await fetch(`${API_BASE}/api/email/archive/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
await _animateEmailCardRemoval([em.uid]);
state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid));
_renderGrid();
_libCacheWriteBack();
},
});
} else {
actions.push({
label: em.is_flagged ? 'Unfavorite' : 'Favorite (pin to top)',
icon: ' ',
action: async () => {
const next = !em.is_flagged;
em.is_flagged = next;
_renderGrid();
try {
await fetch(`${API_BASE}/api/email/flag/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&on=${next ? 'true' : 'false'}`, { method: 'POST' });
} catch (e) {
em.is_flagged = !next;
_renderGrid();
console.error('Failed to toggle favorite:', e);
}
},
});
actions.push({
label: 'Archive',
icon: _archIcon,
action: async () => {
await fetch(`${API_BASE}/api/email/archive/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
await _animateEmailCardRemoval([em.uid]);
state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid));
_renderGrid();
_libCacheWriteBack();
},
});
}
// "Select" — switch to multi-select mode with THIS email pre-selected so
// the user can quickly fan-out to neighbours with the bulk bar.
// Match the chat-sidebar Select icon — a thick bullet character reads
// much heavier than a small SVG circle. Nudged up 2px so its visual
// center lines up with the SVG icons above (which sit a bit higher).
const _selectIcon = '● ';
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 {
await fetch(`${API_BASE}/api/email/delete/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' });
} catch (e) {
busy?.remove?.();
showToast('Failed to delete email');
throw e;
}
busy?.remove?.();
await _animateEmailCardRemoval([em.uid]);
state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid));
_renderGrid();
_libCacheWriteBack();
}},
);
for (const a of actions) {
const item = document.createElement('div');
item.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
const arrow = a.submenu ? '› ' : '';
item.innerHTML = _icon(a.icon) + `${a.label} ${arrow}`;
item.addEventListener('click', (e) => {
e.stopPropagation();
if (a.submenu === 'remind') {
_showLibRemindSubmenu(em, dropdown);
return;
}
close();
a.action();
});
dropdown.appendChild(item);
}
// Mobile-only Cancel item — explicit close for touch users. CSS hides it
// on desktop where outside-click already dismisses cleanly.
const _cancelIco = ' ';
const cancelItem = document.createElement('div');
cancelItem.className = 'dropdown-item-compact dropdown-cancel-mobile';
cancelItem.innerHTML = _icon(_cancelIco) + 'Cancel ';
cancelItem.addEventListener('click', (e) => {
e.stopPropagation();
close();
});
dropdown.appendChild(cancelItem);
document.body.appendChild(dropdown);
_fitEmailDropdown(dropdown, rect);
const close = bindMenuDismiss(dropdown, () => {
dropdown.remove();
anchor.classList.remove('reader-more-active');
}, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
}
// Bulk "Actions" dropdown for select mode — Delete is a separate visible button.
function _showBulkActionsMenu(anchor) {
document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
const dropdown = document.createElement('div');
dropdown.className = 'email-card-dropdown email-bulk-menu';
const rect = anchor.getBoundingClientRect();
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:160px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:${rect.left}px;`;
const _readIco = ' ';
const _unreadIco = ' ';
const _doneIco = ' ';
const items = [
{ label: 'Done', icon: _doneIco, action: () => _bulkAction('done') },
{ label: 'Mark Read', icon: _readIco, action: () => _bulkAction('read') },
{ label: 'Mark Unread', icon: _unreadIco, action: () => _bulkAction('unread') },
];
for (const a of items) {
const it = document.createElement('div');
it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
it.innerHTML = `${a.icon} ${a.label} `;
it.addEventListener('click', (e) => { e.stopPropagation(); close(); a.action(); });
dropdown.appendChild(it);
}
// Mobile-only Cancel — matches the per-card and sidebar dropdowns.
const _cancelIco2 = ' ';
const cancelIt = document.createElement('div');
cancelIt.className = 'dropdown-item-compact dropdown-cancel-mobile';
cancelIt.innerHTML = `${_cancelIco2} Cancel `;
cancelIt.addEventListener('click', (e) => {
e.stopPropagation();
close();
// Cancel inside the bulk-Actions menu also exits select mode — matches the
// documents bulk dropdown.
state._selectMode = false;
state._selectedUids.clear();
_updateBulkBar();
_renderGrid();
});
dropdown.appendChild(cancelIt);
document.body.appendChild(dropdown);
_fitEmailDropdown(dropdown, rect);
const close = bindMenuDismiss(dropdown, () => {
dropdown.remove();
}, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
}
function _updateBulkBar() {
const bar = document.getElementById('email-lib-bulk');
const 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;
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;
}
const deleteBtn = action === 'delete' ? document.getElementById('email-lib-bulk-delete') : null;
const actionsBtn = document.getElementById('email-lib-bulk-actions');
const cancelBtn = document.getElementById('email-lib-bulk-cancel');
const selectAll = document.getElementById('email-lib-select-all');
const countEl = document.getElementById('email-lib-selected-count');
const originalDeleteHtml = deleteBtn?.innerHTML || '';
const originalCountText = countEl?.textContent || '';
let busySpinner = null;
// Loading state for every bulk action, not just delete — large
// selections (e.g. 90+ Dones) used to silently hammer the server
// with sequential requests and the user got zero feedback. Now the
// Actions button (or Delete button) shows a whirlpool + verb-ing
// label, and the count surfaces progress.
const verbing = {
delete: 'Deleting',
archive: 'Archiving',
done: 'Marking done',
read: 'Marking read',
unread: 'Marking unread',
}[action] || 'Updating';
const targetBtn = action === 'delete' ? deleteBtn : actionsBtn;
let originalTargetHtml = '';
if (targetBtn) {
originalTargetHtml = targetBtn.innerHTML;
targetBtn.disabled = true;
targetBtn.classList.add('email-bulk-loading');
targetBtn.innerHTML = `${verbing} `;
busySpinner = spinnerModule.create('', 'clean', 'whirlpool');
const spEl = busySpinner.createElement();
spEl.classList.add('email-bulk-whirlpool');
targetBtn.appendChild(spEl);
busySpinner.start();
}
if (action !== 'delete' && deleteBtn) deleteBtn.disabled = true;
if (action === 'delete' && actionsBtn) actionsBtn.disabled = true;
if (cancelBtn) cancelBtn.disabled = true;
if (selectAll) selectAll.disabled = true;
if (countEl) countEl.textContent = `${verbing} ${uids.length}…`;
// Single-uid worker.
const handleOne = async (uid) => {
try {
if (action === 'archive') {
await fetch(`${API_BASE}/api/email/archive/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
} else if (action === 'delete') {
await fetch(`${API_BASE}/api/email/delete/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' });
} 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;
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;
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') {
await _animateEmailCardRemoval(uids);
const removed = new Set(uids.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 {
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'}`);
}
// 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) {
const cachedSpark = data?.cached_ai_reply
? ' '
: '';
return ` ${cachedSpark} `;
}
function _summaryIcon(data) {
const fill = data?.cached_summary ? 'var(--accent-primary, var(--red))' : 'currentColor';
return ` `;
}
async function _emailTranslateLanguage() {
try {
const res = await fetch(`${API_BASE}/api/email/config`);
const cfg = await res.json();
return cfg?.email_translate_language || 'English';
} catch (_) {
return 'English';
}
}
async function _runAiReplyFromButton(btn, em, data, mode, noteHint = '') {
_snapEmailModalToLeftSidebar(btn.closest('.modal'));
btn.disabled = true;
const orig = btn.innerHTML;
let wp = null;
try {
wp = spinnerModule.createWhirlpool(14);
wp.element.style.cssText = 'width:14px;height:14px;display:inline-block;vertical-align:middle;position:relative;top:-2px;';
btn.innerHTML = '';
btn.appendChild(wp.element);
} catch (_) {}
try {
if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode, noteHint });
} finally {
try { wp && wp.stop(); } catch (_) {}
btn.disabled = false;
btn.innerHTML = orig;
}
}
function _closeAiReplyChoice() {
document.querySelectorAll('.email-ai-reply-choice').forEach(el => el.remove());
document.removeEventListener('click', _closeAiReplyChoice, true);
}
function _showAiReplyChoice(btn, em, data) {
_closeAiReplyChoice();
const rect = btn.getBoundingClientRect();
const menu = document.createElement('div');
menu.className = 'email-ai-reply-choice';
/* Clamp width to viewport minus 16px margin so the menu (textarea
+ Fast/Full buttons) 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(';');
// Fast = lightning bolt (already used as a 'fast' glyph elsewhere in the app).
// Full = layered concentric circles to suggest "more, deeper" — not a fully
// filled circle so it reads as a complement to the lightning, not as a "stop".
menu.innerHTML = `
`;
const noteInput = menu.querySelector('[data-note-input]');
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';
const noteHint = (noteInput.value || '').trim();
_closeAiReplyChoice();
await _runAiReplyFromButton(btn, em, data, mode, noteHint);
});
// 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);
_closeAiReplyChoice();
};
setTimeout(() => document.addEventListener('click', outsideClose, true), 0);
}
function _handleAiReplyButton(ev, em, data) {
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);
}
function _hasMultipleRecipients(data) {
// Count distinct addresses in To + Cc (minus the current user). Empty
// fallback when the user's address isn't yet known — no exclusion.
const myAddress = (window._myEmailAddress || '').toLowerCase();
const extractEmails = (str) => {
if (!str) return [];
return str.split(',')
.map(s => {
const m = s.match(/<([^>]+)>/);
return (m ? m[1] : s).trim().toLowerCase();
})
.filter(e => e && e !== myAddress);
};
const recipients = new Set([
...extractEmails(data.to),
...extractEmails(data.cc),
]);
// Sender counts as one other person too
if (data.from_address && data.from_address.toLowerCase() !== myAddress) {
recipients.add(data.from_address.toLowerCase());
}
return recipients.size > 1;
}
// _esc lives in ./emailLibrary/utils.js
function _showEmailTranslateSubmenu(reader, parentDropdown) {
parentDropdown.innerHTML = '';
const header = document.createElement('div');
header.className = 'dropdown-item-compact';
header.style.cssText = 'opacity:0.5;font-size:10px;pointer-events:none;text-transform:uppercase;letter-spacing:0.5px;padding-top:6px;';
header.innerHTML = 'Translate to ';
parentDropdown.appendChild(header);
const customRow = document.createElement('div');
customRow.className = 'dropdown-item-compact email-translate-custom-row';
customRow.style.cssText = 'display:flex;gap:5px;align-items:center;padding:5px 7px;cursor:default;';
customRow.innerHTML = `
Go
`;
parentDropdown.appendChild(customRow);
const input = customRow.querySelector('.email-translate-custom-input');
const go = customRow.querySelector('.email-translate-custom-go');
const runCustom = async () => {
const language = (input?.value || '').trim();
if (!language) {
input?.focus();
return;
}
parentDropdown.remove();
await _translateEmail(reader, language);
};
customRow.addEventListener('click', e => e.stopPropagation());
go?.addEventListener('click', async e => {
e.stopPropagation();
await runCustom();
});
input?.addEventListener('keydown', async e => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
await runCustom();
}
});
_emailTranslateLanguage().then(language => {
if (input && !input.value) input.value = language || 'English';
}).catch(() => {});
const languages = ['English', 'Swedish', 'Japanese', 'Spanish', 'French', 'German'];
for (const language of languages) {
const item = document.createElement('div');
item.className = 'dropdown-item-compact';
item.innerHTML = `${language} `;
item.addEventListener('click', async (e) => {
e.stopPropagation();
parentDropdown.remove();
await _translateEmail(reader, language);
});
parentDropdown.appendChild(item);
}
}
// ---- Reminder submenu (used by both email menus) ----
function _showLibRemindSubmenu(em, parentDropdown) {
parentDropdown.innerHTML = '';
const header = document.createElement('div');
header.className = 'dropdown-item-compact';
header.style.cssText = 'opacity:0.5;font-size:10px;pointer-events:none;text-transform:uppercase;letter-spacing:0.5px;padding-top:6px;';
header.innerHTML = 'Remind me ';
parentDropdown.appendChild(header);
const now = new Date();
const laterToday = new Date(now);
const sixPm = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0);
if (sixPm - now < 60*60*1000) laterToday.setTime(now.getTime() + 3*60*60*1000);
else laterToday.setTime(sixPm.getTime());
const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate()+1); tomorrow.setHours(8,0,0,0);
const daysUntilMon = (8 - now.getDay()) % 7 || 7;
const nextWeek = new Date(now); nextWeek.setDate(now.getDate()+daysUntilMon); nextWeek.setHours(8,0,0,0);
const presets = [
{ label: 'Later today', sub: laterToday.toLocaleTimeString([], { hour:'numeric', minute:'2-digit' }), date: laterToday },
{ label: 'Tomorrow', sub: tomorrow.toLocaleTimeString([], { hour:'numeric', minute:'2-digit' }), date: tomorrow },
{ label: 'Next week', sub: nextWeek.toLocaleDateString([], { weekday:'short' }) + ' ' + nextWeek.toLocaleTimeString([], { hour:'numeric', minute:'2-digit' }), date: nextWeek },
];
for (const p of presets) {
const item = document.createElement('div');
item.className = 'dropdown-item-compact';
item.innerHTML = `${p.label} ${p.sub} `;
item.addEventListener('click', async (e) => {
e.stopPropagation();
parentDropdown.remove();
await _createEmailReplyReminder(em, p.date);
});
parentDropdown.appendChild(item);
}
const customItem = document.createElement('div');
customItem.className = 'dropdown-item-compact';
customItem.innerHTML = 'Pick date and time… ';
customItem.addEventListener('click', (e) => {
e.stopPropagation();
parentDropdown.remove();
const tmp = document.createElement('input');
tmp.type = 'datetime-local';
const def = new Date(tomorrow);
const pad = n => String(n).padStart(2,'0');
tmp.value = `${def.getFullYear()}-${pad(def.getMonth()+1)}-${pad(def.getDate())}T${pad(def.getHours())}:${pad(def.getMinutes())}`;
tmp.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:99999;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:13px;';
document.body.appendChild(tmp);
tmp.focus();
if (typeof tmp.showPicker === 'function') { try { tmp.showPicker(); } catch {} }
tmp.addEventListener('change', async () => {
if (tmp.value) await _createEmailReplyReminder(em, new Date(tmp.value));
tmp.remove();
});
tmp.addEventListener('blur', () => setTimeout(() => tmp.remove(), 200));
});
parentDropdown.appendChild(customItem);
// "Note" — prompts for free-text and saves it as a note without a
// due_date, so no timer/reminder fires.
const noteItem = document.createElement('div');
noteItem.className = 'dropdown-item-compact';
noteItem.innerHTML = 'Note ';
noteItem.addEventListener('click', (e) => {
e.stopPropagation();
parentDropdown.remove();
_promptEmailNote(em);
});
parentDropdown.appendChild(noteItem);
}
function _promptEmailNote(em) {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;z-index:99998;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;padding:16px;';
const card = document.createElement('div');
card.style.cssText = 'background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:14px;min-width:280px;max-width:min(420px, 92vw);display:flex;flex-direction:column;gap:8px;box-shadow:0 12px 32px rgba(0,0,0,0.4);';
const subject = em.subject || '(no subject)';
card.innerHTML = `
Note about ${_esc(subject)}
Cancel
Save
`;
overlay.appendChild(card);
document.body.appendChild(overlay);
const ta = card.querySelector('[data-note]');
setTimeout(() => ta.focus(), 0);
const close = () => overlay.remove();
overlay.addEventListener('click', (ev) => { if (ev.target === overlay) close(); });
card.querySelector('[data-act="cancel"]').addEventListener('click', close);
card.querySelector('[data-act="save"]').addEventListener('click', async () => {
const text = (ta.value || '').trim();
if (!text) { ta.focus(); return; }
close();
await _createEmailReplyReminder(em, null, text);
});
ta.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') close();
else if ((ev.ctrlKey || ev.metaKey) && ev.key === 'Enter') card.querySelector('[data-act="save"]').click();
});
}
async function _createEmailReplyReminder(em, dueDate, customText = '') {
const pad = n => String(n).padStart(2,'0');
const iso = dueDate
? `${dueDate.getFullYear()}-${pad(dueDate.getMonth()+1)}-${pad(dueDate.getDate())}T${pad(dueDate.getHours())}:${pad(dueDate.getMinutes())}`
: null;
const fullFrom = em.from || em.sender || '';
// Extract just the first name from "First Last " or fall back to email local part
let from = 'someone';
if (fullFrom) {
const fullName = _extractName(fullFrom);
if (fullName) {
// Strip quotes, take the first whitespace-separated word, capitalize
const first = fullName.replace(/^["']|["']$/g, '').trim().split(/[\s,]+/)[0] || '';
if (first) from = first.charAt(0).toUpperCase() + first.slice(1);
}
}
const subject = em.subject || '(no subject)';
const folder = state._libFolder || 'INBOX';
const deepLink = `${window.location.origin}/#email=${encodeURIComponent(folder)}:${em.uid}`;
const itemText = customText || `Reply to ${from}: ${subject}`;
const payload = {
title: `Reply: ${subject}`,
note_type: 'todo',
items: [
{ text: itemText, checked: false },
],
content: `Open email: ${deepLink}`,
label: 'email reminder',
source: 'email',
};
if (iso) payload.due_date = iso;
try {
const res = await fetch(`${API_BASE}/api/notes`, {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error('Failed');
const { showToast } = await import('./ui.js');
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');
showError('Failed to create reminder');
}
}
// Sanitize untrusted HTML email bodies before injecting via innerHTML.
//
// Denylist sanitizer — has to block every well-known XSS sink:
// -